Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
694366a6c8
|
||
|
|
a2c609803e
|
||
|
|
ad4e4ae357
|
||
|
|
1a166eb4e3
|
||
|
|
362980d008
|
||
|
|
e3ab7973d6
|
||
|
|
1758c253a1
|
||
|
|
67017409d7
|
||
|
|
e7de627752
|
||
|
|
d8d842b1ef
|
||
|
|
acda3cda0f
|
||
|
|
e389e40843
|
||
|
|
6c92fc35fd
|
||
|
|
cd86f6bdeb
|
||
|
|
63ee248695
|
||
|
|
91eb30b222
|
||
|
|
c4b2b5ccff
|
||
|
|
a4f694a966
|
||
|
|
b9f7c96ce6 | ||
|
|
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 |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(composer test:*)",
|
||||
"Bash(composer lint *)",
|
||||
"Bash(tea actions:*)",
|
||||
"Bash(tea issue *)",
|
||||
"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
|
||||
|
||||
@@ -143,6 +149,50 @@ jobs:
|
||||
{ print }
|
||||
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
|
||||
|
||||
# main requires signed commits, and Gitea refuses to merge a pull request
|
||||
# that carries an unsigned one. The key Gitea signs merge commits with
|
||||
# lives on the server and is not reachable from a runner, so the bump
|
||||
# commit is signed here with a dedicated release-bot key that the instance
|
||||
# trusts via TRUSTED_SSH_KEYS. Generating that key, trusting it and storing
|
||||
# the secret is documented in docs/ci.md.
|
||||
- name: Configure signing as Release Bot
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -z "${SIGNING_KEY}" ]; then
|
||||
echo "RELEASE_BOT_SIGNING_KEY is not set - the bump commit would be unsigned and unmergeable." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ssh-keygen > /dev/null; then
|
||||
echo "ssh-keygen is missing from the runner image; git cannot make SSH signatures without it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The secret holds an OpenSSH private key ("-----BEGIN OPENSSH PRIVATE
|
||||
# KEY-----"). git signs by shelling out to ssh-keygen, which wants that
|
||||
# key on disk next to the .pub it is pointed at, readable only by us,
|
||||
# and rejects it unless the final newline survived the round trip.
|
||||
keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing"
|
||||
install -m 700 -d "${keydir}"
|
||||
printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key"
|
||||
chmod 600 "${keydir}/key"
|
||||
# Doubles as a format check: a truncated or re-wrapped key fails here,
|
||||
# with a clearer cause than "gpg failed to sign the data" later on.
|
||||
if ! ssh-keygen -y -f "${keydir}/key" < /dev/null > "${keydir}/key.pub"; then
|
||||
echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No Gitea account backs this address; TRUSTED_SSH_KEYS verifies the
|
||||
# signature without an account lookup, so it is a label, not an identity.
|
||||
git config user.name 'Release Bot'
|
||||
git config user.email '[email protected]'
|
||||
# Named gpg.format for historical reasons; "ssh" is what switches git
|
||||
# over to signing with the SSH key above rather than a GPG key.
|
||||
git config gpg.format ssh
|
||||
git config user.signingkey "${keydir}/key.pub"
|
||||
git config commit.gpgsign true
|
||||
|
||||
- name: Open pull request
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -151,10 +201,14 @@ jobs:
|
||||
branch="release/bump-${next}"
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
git config user.name 'Release Bot'
|
||||
git config user.email '[email protected]'
|
||||
git checkout -b "${branch}"
|
||||
git commit -am "Bump version to ${next} and open changelog section"
|
||||
# A commit that came out unsigned would otherwise go unnoticed until
|
||||
# someone tried to merge the PR, so fail here instead.
|
||||
if ! git cat-file commit HEAD | grep -q '^gpgsig'; then
|
||||
echo "Bump commit is unsigned; refusing to push it." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "${branch}"
|
||||
|
||||
curl -fsS -X POST "${api}/pulls" \
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
vendor/
|
||||
composer.lock
|
||||
coverage/
|
||||
.phpunit.result.cache
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
composer install
|
||||
composer test # PHPUnit — run after every code change
|
||||
composer lint # PHPStan (level 10, `src/` only)
|
||||
composer cs # PHPCS (WordPress standard + exclusions in phpcs.xml.dist)
|
||||
composer cs:fix # auto-fix coding standards
|
||||
composer build # -> dist/unsupervised-schedular-<version>.zip
|
||||
|
||||
./vendor/bin/phpunit tests/Unit/Offering/OfferingRepositoryTest.php
|
||||
./vendor/bin/phpunit --filter testInsertReturnsId
|
||||
```
|
||||
|
||||
CI (`.gitea/workflows/ci.yml`): `phpcs`, `phpstan`, `test` (PHP 8.1/8.2/8.3/8.5), `no-debug`. Write only PHP 8.1-compatible syntax. No `var_dump|var_export|print_r|error_log|dd|dump(` in `src/` — CI greps and fails.
|
||||
|
||||
## Architecture
|
||||
|
||||
- WordPress plugin, no front-end build (vanilla JS/CSS in `assets/`). PSR-4 `Unsupervised\Schedular\` -> `src/`.
|
||||
- **Package-by-domain:** `src/<Domain>/` (Auth, Availability, Booking, GroupClass, Guardian, Offering, Payment, Policy, Registration) owns its repos, services, endpoints, pages. Cross-cutting wiring lives directly in `src/`: `Plugin`, `Installer`, `Schema`, `AdminMenu`, `RestRegistrar`, `ShortcodeRegistrar`, `BlockRegistrar`, `Val`.
|
||||
- Entry: `unsupervised-schedular.php` -> `Plugin::boot()` (wires all dependencies). **Slug is `schedular`, not `scheduler`** — filename, text domain (`unsupervised-schedular`), option `us_schedular_version`, table prefix `us_`. Never "fix" the spelling.
|
||||
- REST: `/wp-json/us-scheduler/v1/`, `permission_callback` uses capability checks, never role names.
|
||||
- DB: custom `us_*` tables via `dbDelta`; `Schema::tables()` is the source of truth. **All `$wpdb` access inside repository classes only.**
|
||||
- `src/Val.php` coerces untyped WP input (`Val::int()`, `Val::string()`, `...OrNull`, etc.). For PHPCS, `Val::int/float/bool/...` count as unslashing passthrough only — still wrap with a real sanitizer: `absint( Val::int( $_GET['id'] ?? 0 ) )`.
|
||||
|
||||
## Schema changes (gotcha)
|
||||
|
||||
- `Plugin::boot()` only re-runs `Installer`/migrations when stored `us_schedular_version !== USC_VERSION`. **Bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php` or the change never reaches existing sites.**
|
||||
- `dbDelta` does not reliably relax column NULL-ability. Follow the existing pattern in `Plugin::boot()`: repository repair method + own `us_*` option flag (e.g. `us_questions_offering_nullable`), not the version gate.
|
||||
|
||||
## Tests
|
||||
|
||||
- Brain Monkey + Mockery, no live WP. All test classes extend `tests/Unit/TestCase.php` (handles `Monkey\setUp/tearDown`, stubs translations/escaping/`checked`/`selected`).
|
||||
- Mirror layout: `tests/Unit/<Domain>/` mirrors `src/<Domain>/`.
|
||||
- `Functions\when('fn')->alias(fn() => ...)` (never `returnUsing()`); `->justReturn($v)` for constants.
|
||||
- Use `when()` not `expect()` for argument-dependent routing.
|
||||
- No `\Mockery::type()` inside plain arrays passed to `with()` — use `\Mockery::on()` or `\Mockery::any()`.
|
||||
- `$wpdb` mock needs `$mock->prefix = 'wp_'` as a property.
|
||||
|
||||
## Adding a feature
|
||||
|
||||
1. Spec first: `docs/features/<feature-name>.md` (data model, API, classes, test paths).
|
||||
2. Code in `src/<Domain>/`; templates in `templates/` if needed.
|
||||
3. Tests in `tests/Unit/<Domain>/`.
|
||||
4. `composer test` must pass (also `composer lint` + `composer cs` before finishing).
|
||||
@@ -11,6 +11,63 @@ 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.6.0]
|
||||
|
||||
### Added
|
||||
- **You can now read, rewrite and preview the "Payment due" email, on Studio Settings → Payment Due Email.** The notice a family gets when the daily scan finds lessons to pay for was fixed wording baked into the plugin; now its subject and body sit in an editor you can change to match how your studio talks to its students. Drop in `{student_name}`, `{items}`, `{total_due}` and the rest wherever you want them, and a preview below fills those tokens with sample values and updates as you type, so you see the actual email a scan would send before you save. Leave a field blank to fall back to the built-in wording, or use the reset button to restore all of it at once. Nothing about how or when the email is sent changes — only what it says — and until you touch it, students receive exactly the notice they always did.
|
||||
|
||||
## [1.5.8]
|
||||
|
||||
### Added
|
||||
- **A student's account credit balance now shows at the top of their detail page.** Credit from a cancelled paid lesson was already recorded and listed further down the page, but you had to scroll to the Account credit section to find out a student was owed anything. When there is a balance to report it now appears up top the moment you open the page, so you can see at a glance that this student's future billing will be offset — and, for a child, that the balance sits on their guardian's account. The full breakdown of where the credit came from stays where it was.
|
||||
|
||||
### Fixed
|
||||
- **Rebooking a cancelled paid lesson in the same month no longer charges the family twice.** Cancelling a paid lesson credits the account for it, and that credit is meant to cover the next lesson booked in its place. But a lesson booked back into a month already billed is charged there and then, and that charge skipped the step where credit is applied — so the family was billed in full for the replacement while the credit for the cancelled lesson sat unused, in effect paying twice for the one slot. Account credit is now applied to a charge raised at booking, so the credit settles the rebooking the same way it settles a scheduled charge; a lesson fully covered by credit is confirmed with nothing left to pay.
|
||||
- **A student is no longer emailed the same "Payment due" notice twice.** The daily billing scan runs whenever the site gets traffic, and on a busy day two copies of it could end up running at the same time. Neither knew about the other, so each would send its own notice for the same charge — one payment on the books, but the family saw two identical requests to pay and reasonably read it as being billed twice. Each payment is now stamped the moment its notice goes out, and a second run that reaches the same payment sees the stamp and stays quiet, so exactly one notice is sent no matter how the scan is triggered. Payments already noticed before this update are marked as such on upgrade, so nobody gets a fresh round of reminders for charges they were already told about.
|
||||
- **Switching a group class to monthly billing no longer charges students who already paid up front a second time.** When a class was set up to be paid once at sign-up and later changed to bill monthly, the daily scan did not recognise the payment already taken at enrolment — it carried no billing month — and raised a fresh charge for the current month on top of it. Families who had already paid were billed again, sometimes for a month they had covered. Changing a class to monthly now marks each enrolled student's up-front payment as covering the current month, so the scan bills them from the following month on and never doubles up on the month already paid. (Enrolments made after the switch, and classes that were always monthly, were never affected.)
|
||||
|
||||
## [1.5.7]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,31 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
**Run `composer test` after every code change before considering a task complete.**
|
||||
|
||||
## Architecture
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
### 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 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.
|
||||
1. Write the feature doc in `docs/features/<feature-name>.md` (data model, API, classes, test paths).
|
||||
2. Create a domain package under `src/<Domain>/` containing all classes for that feature.
|
||||
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.
|
||||
See `AGENTS.md` — it is the single source of truth for working in this repo.
|
||||
|
||||
+115
-39
@@ -137,7 +137,78 @@
|
||||
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
|
||||
}
|
||||
|
||||
function renderClasses(offerings, enrolledMap) {
|
||||
// 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);
|
||||
@@ -149,44 +220,29 @@
|
||||
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>` : ''}
|
||||
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
|
||||
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||
: ''}
|
||||
${enrolledMap.has(Number(o.id))
|
||||
? `<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>
|
||||
${isWithdrawalOpen(o)
|
||||
? `<button data-enrollment-id="${enrolledMap.get(Number(o.id))}" class="us-withdraw-btn">Withdraw</button>`
|
||||
: '<p class="us-withdraw-closed">Withdrawal has closed — contact the studio to withdraw.</p>'}`
|
||||
: (isEnrollmentOpen(o)
|
||||
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
|
||||
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
|
||||
</div>
|
||||
`).join('');
|
||||
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', () => {
|
||||
hideConfirmation();
|
||||
openEnrolment(offering);
|
||||
openEnrolment(offering, availableStudents(offering.id, enrolled));
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId));
|
||||
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId, btn.dataset.student || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function withdraw(enrollmentId) {
|
||||
function withdraw(enrollmentId, studentName) {
|
||||
clearError();
|
||||
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) {
|
||||
// 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' })
|
||||
@@ -194,22 +250,45 @@
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function openEnrolment(offering) {
|
||||
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">
|
||||
${window.usGuardian.selectorHtml(students, 'us-enrol-student')}
|
||||
${studentFieldHtml(available)}
|
||||
${questions.map(questionField).join('')}
|
||||
${policies.map(policyField).join('')}
|
||||
${window.usPricing.summaryHtml(offering)}
|
||||
@@ -306,20 +385,17 @@
|
||||
function loadClasses() {
|
||||
clearError();
|
||||
hideConfirmation();
|
||||
// The student's own enrolments are fetched alongside the catalog so a
|
||||
// class they already have an active enrolment in shows its status
|
||||
// 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.
|
||||
// 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 Map(enrollments
|
||||
.filter((e) => e.status === 'active')
|
||||
.map((e) => [Number(e.offering_id), e.id]))
|
||||
))
|
||||
.then(([offerings, enrollments]) => renderClasses(offerings, activeByOffering(enrollments)))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Payment due email editor: live preview.
|
||||
*
|
||||
* Posts the subject/body/item-line the admin is editing to the read-only preview
|
||||
* REST endpoint and swaps the rendered result into the preview panel, debounced
|
||||
* as they type. The server always renders from the same sample values, so this
|
||||
* mirrors exactly what a real billing scan would send. Purely a convenience — the
|
||||
* page already shows a server-rendered preview of the saved template without it.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const config = window.uscPaymentEmailPreview;
|
||||
if (!config || !config.url) return;
|
||||
|
||||
const subjectEl = document.getElementById('usc-pe-subject');
|
||||
const bodyEl = document.getElementById('usc-pe-body');
|
||||
const itemLineEl = document.getElementById('usc-pe-item-line');
|
||||
const outSubject = document.getElementById('usc-pe-preview-subject');
|
||||
const outBody = document.getElementById('usc-pe-preview-body');
|
||||
|
||||
if (!subjectEl || !bodyEl || !itemLineEl || !outSubject || !outBody) return;
|
||||
|
||||
let timer = null;
|
||||
|
||||
function refresh() {
|
||||
fetch(config.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': config.nonce
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subject: subjectEl.value,
|
||||
body: bodyEl.value,
|
||||
item_line: itemLineEl.value
|
||||
})
|
||||
})
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error('preview failed');
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
outSubject.textContent = data.subject || '';
|
||||
outBody.textContent = data.body || '';
|
||||
})
|
||||
.catch(function () {
|
||||
// Leave the last good preview in place on error.
|
||||
});
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(refresh, 300);
|
||||
}
|
||||
|
||||
[subjectEl, bodyEl, itemLineEl].forEach(function (el) {
|
||||
el.addEventListener('input', schedule);
|
||||
});
|
||||
})();
|
||||
@@ -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
+101
@@ -0,0 +1,101 @@
|
||||
# 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.
|
||||
|
||||
## Signing the version bump commit
|
||||
|
||||
`main` is a protected branch that requires signed commits, and Gitea will not
|
||||
merge a pull request containing an unsigned one. The `bump-version` job in
|
||||
`release.yml` therefore signs the commit it makes, using a dedicated
|
||||
`release-bot` SSH key rather than the key Gitea signs merge commits with —
|
||||
that one is `[repository.signing] SIGNING_KEY` on the server and no runner can
|
||||
reach it. Keeping the CI key separate also means it can be rotated on its own
|
||||
if the secret ever leaks.
|
||||
|
||||
There is deliberately no `release-bot` Gitea account. A key attached to an
|
||||
account is only consulted for signature checking after it has been through the
|
||||
web *Verify* flow, and that flow has no API — a bot account would need an
|
||||
interactive login to be worth anything. Listing the key under
|
||||
`TRUSTED_SSH_KEYS` instead makes Gitea verify commits signed with it without
|
||||
any account lookup, which is all the protected branch asks for.
|
||||
|
||||
Set up once for the instance, and again only if the key is rotated:
|
||||
|
||||
1. Generate a passphrase-less key (it has to be usable unattended):
|
||||
|
||||
```
|
||||
ssh-keygen -t ed25519 -C '[email protected]' -f release-bot -N ''
|
||||
```
|
||||
|
||||
2. Add the public half to `app.ini` and restart Gitea:
|
||||
|
||||
```ini
|
||||
[repository.signing]
|
||||
TRUSTED_SSH_KEYS = ssh-ed25519 AAAAC3Nza... [email protected]
|
||||
```
|
||||
|
||||
3. Store the private half as the **organisation** Actions secret
|
||||
`RELEASE_BOT_SIGNING_KEY` (Org → Settings → Actions → Secrets): the whole
|
||||
`release-bot` file verbatim, `-----BEGIN OPENSSH PRIVATE KEY-----` header
|
||||
and footer included — not the `.pub`, and not a GPG export. Organisation
|
||||
secrets are readable as `secrets.RELEASE_BOT_SIGNING_KEY` from every
|
||||
repository in the org, so no repository-level copy is needed. Delete both
|
||||
local files afterwards.
|
||||
|
||||
Two consequences of trusting the key instance-wide are worth knowing. Any
|
||||
commit signed with it verifies in *every* repository on the instance, not just
|
||||
these — the trust is in the key, not in a user with permissions you can scope.
|
||||
And the signature is attributed to `SIGNING_NAME` / `SIGNING_EMAIL`, not to the
|
||||
`Release Bot <release-bot@unsupervised.ca>` committer the job sets; that
|
||||
address backs no account and is only a label.
|
||||
|
||||
The job fails fast if the secret is missing or `ssh-keygen` is absent from the
|
||||
runner image, and it re-reads the commit it just made to confirm a signature
|
||||
is attached before pushing — an unsigned bump commit would otherwise look fine
|
||||
until someone tried to merge the PR.
|
||||
|
||||
Nothing else in the pipeline signs anything: release tags are made by a human
|
||||
through Gitea's release UI, and the merge commit is signed by the server when
|
||||
the PR is merged.
|
||||
@@ -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
|
||||
@@ -198,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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -81,11 +82,20 @@ student detail page. Only *upcoming* sessions are added there — the
|
||||
term's worth of past dates would bury the lessons under "Past lessons".
|
||||
|
||||
## 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).
|
||||
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`).
|
||||
@@ -114,7 +124,8 @@ closed. Past the deadline the details page labels these as late enrolments. See
|
||||
|
||||
## 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.
|
||||
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
|
||||
@@ -125,10 +136,12 @@ Self-withdrawal is bounded by the class's **withdrawal deadline** (the instructo
|
||||
implicit default — a class with no deadline set stays open to withdrawal for its
|
||||
whole life. Past the deadline `POST /enrollments/{id}/withdraw` rejects the request
|
||||
with `403 withdrawal_closed`, and the class card shows "Withdrawal has closed —
|
||||
contact the studio to withdraw." in place of the Withdraw button. The endpoint also
|
||||
returns `404 not_found` for an unknown enrolment and `403 forbidden` when the
|
||||
enrolment is not the caller's own; a withdrawal of an already-cancelled enrolment is
|
||||
idempotent.
|
||||
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`),
|
||||
@@ -170,6 +183,8 @@ controls beneath it:
|
||||
settled at once by `PaymentService`). No access grant is needed — this writes straight
|
||||
to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and
|
||||
capacity, so it doubles as the **late-enrolment** path after a class has closed.
|
||||
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.
|
||||
@@ -179,6 +194,17 @@ controls beneath it:
|
||||
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.
|
||||
@@ -220,12 +246,46 @@ class becomes enrollable for them — they choose whether to enrol.
|
||||
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`
|
||||
- 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`)
|
||||
- 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). 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`)
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
@@ -82,6 +83,103 @@ 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).
|
||||
Cancelling marks the lesson `cancelled`, frees the availability slot for
|
||||
@@ -138,8 +236,8 @@ 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 — and upcoming sessions of the instructor's own group classes — 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
|
||||
@@ -157,10 +255,14 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
||||
|
||||
## 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\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. A weekly series is answered for and agreed to once, against the anchor lesson, so the presenter reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first.
|
||||
- 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.
|
||||
@@ -181,10 +283,11 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
||||
> 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/LessonDetailTest.php`
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php`
|
||||
|
||||
## Booking For Someone Else
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,9 +47,21 @@ 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`
|
||||
|
||||
@@ -36,7 +36,10 @@ 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.
|
||||
@@ -91,3 +94,10 @@ cover every policy's current version or the registration is rejected.
|
||||
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`.
|
||||
|
||||
@@ -57,6 +57,9 @@ account-holder form to differ from.
|
||||
| `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 |
|
||||
|
||||
+51
-13
@@ -17,19 +17,22 @@ 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\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassController;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
use Unsupervised\Schedular\Offering\BillingModeReconciler;
|
||||
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\PaymentEmailController;
|
||||
use Unsupervised\Schedular\Payment\PaymentReportController;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
@@ -42,6 +45,9 @@ 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 {
|
||||
|
||||
@@ -51,6 +57,12 @@ class AdminMenu {
|
||||
*/
|
||||
private string $availabilityHook = '';
|
||||
|
||||
/**
|
||||
* Hook suffix of the payment-email screen, captured when the page is added so
|
||||
* its live-preview script loads on that screen only.
|
||||
*/
|
||||
private string $paymentEmailHook = '';
|
||||
|
||||
private AvailabilityController $availabilityController;
|
||||
private LessonController $lessonController;
|
||||
private OfferingController $offeringController;
|
||||
@@ -64,22 +76,29 @@ class AdminMenu {
|
||||
private StudioSettings $settings;
|
||||
private AccessSettings $accessSettings;
|
||||
private PaymentController $paymentController;
|
||||
private PaymentEmailController $paymentEmailController;
|
||||
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, CreditRepository $credits, GuardianService $guardians ) {
|
||||
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, BillingModeReconciler $billingModeReconciler ) {
|
||||
// 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, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
|
||||
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording );
|
||||
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ), $billingModeReconciler );
|
||||
$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->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();
|
||||
$this->paymentController = new PaymentController( $payments, $paymentService );
|
||||
$this->paymentEmailController = new PaymentEmailController();
|
||||
$this->paymentReportController = new PaymentReportController( $payments );
|
||||
}
|
||||
|
||||
@@ -95,17 +114,26 @@ class AdminMenu {
|
||||
* @param string $hookSuffix Screen the enqueue is running for.
|
||||
*/
|
||||
public function enqueueAssets( string $hookSuffix ): void {
|
||||
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) {
|
||||
if ( '' !== $this->availabilityHook && $hookSuffix === $this->availabilityHook ) {
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-availability-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
|
||||
[],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-availability-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
|
||||
[],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
if ( '' !== $this->paymentEmailHook && $hookSuffix === $this->paymentEmailHook ) {
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-payment-email-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/payment-email-admin.js',
|
||||
[],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function addPages(): void {
|
||||
@@ -253,6 +281,16 @@ class AdminMenu {
|
||||
30
|
||||
);
|
||||
|
||||
// Studio admin: view, edit and preview the payment-due email template.
|
||||
$this->paymentEmailHook = (string) add_submenu_page(
|
||||
'us-settings',
|
||||
__( 'Payment Due Email', 'unsupervised-schedular' ),
|
||||
__( 'Payment Due Email', 'unsupervised-schedular' ),
|
||||
RoleManager::CAP_MANAGE_BILLING,
|
||||
'us-payment-email',
|
||||
[ $this->paymentEmailController, 'renderPage' ]
|
||||
);
|
||||
|
||||
// Site owner: whether WordPress administrators are studio admins / instructors.
|
||||
// Gated on the core manage_options capability — never the plugin's own grants —
|
||||
// so an administrator can always reach it to re-enable a disabled grant.
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' );
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -431,6 +431,13 @@ class RegistrationPage {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-152
@@ -7,9 +7,7 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
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\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
@@ -17,18 +15,13 @@ 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,
|
||||
@@ -227,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' ) ) );
|
||||
@@ -282,93 +235,28 @@ class BookingEndpoint {
|
||||
return $gateError;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$ids = $reservation['ids'];
|
||||
$anchorId = $reservation['anchor_id'];
|
||||
|
||||
// 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() );
|
||||
|
||||
$payment = null;
|
||||
$status = Lesson::STATUS_PENDING;
|
||||
|
||||
// Scheduled billing (weekly / monthly) normally defers payment to the daily
|
||||
// scan, but a single lesson booked once its scheduled due date has already
|
||||
// passed — e.g. an extra lesson added to a month that was already billed — is
|
||||
// charged at booking instead, so it is never missed or billed late.
|
||||
$chargeAtBooking = $offering->price > 0.0 && (
|
||||
! $offering->isScheduledBilling()
|
||||
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
|
||||
);
|
||||
|
||||
if ( $chargeAtBooking ) {
|
||||
// A full-term price already covers the whole reservation; a per-lesson
|
||||
// (one_time) price is owed once per occurrence actually claimed, so a
|
||||
// weekly reservation cannot hold a term while paying for one week.
|
||||
$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 )
|
||||
);
|
||||
|
||||
if ( null !== $payment && $payment->isPaid() ) {
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
}
|
||||
} else {
|
||||
// Either a free offering, or scheduled billing (weekly / monthly) whose
|
||||
// payment is deferred to the daily billing scan. Either way there is no
|
||||
// payment step now to confirm the lessons, so the reserved slots are
|
||||
// confirmed at booking time; the billing scan bills them when they come due.
|
||||
foreach ( $ids as $lessonId ) {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
$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(
|
||||
@@ -424,23 +312,6 @@ class BookingEndpoint {
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled-billing offering's due date for a given session has
|
||||
* already passed at booking time. Weekly bills 24 hours before the lesson;
|
||||
* monthly bills on the 1st, so its due moment has passed once "now" is in the
|
||||
* lesson's month or later. Only meaningful for weekly / monthly offerings.
|
||||
*/
|
||||
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
|
||||
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
|
||||
$start = new \DateTimeImmutable( $slotStart );
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
|
||||
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
|
||||
}
|
||||
|
||||
return $now >= $start->modify( '-1 day' );
|
||||
}
|
||||
|
||||
private function clientIp(): ?string {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
|
||||
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||
@@ -459,14 +330,19 @@ class BookingEndpoint {
|
||||
$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 ( ! $this->guardians->canActFor( 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 ) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
+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,241 @@
|
||||
<?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 );
|
||||
|
||||
$payerId = $this->guardians->payerFor( $studentId );
|
||||
$payment = $this->payments->createForRegistration(
|
||||
Payment::REG_LESSON,
|
||||
$anchorId,
|
||||
$studentId,
|
||||
$slot->instructorId,
|
||||
$amount,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
payerId: $payerId
|
||||
);
|
||||
|
||||
// Apply any available credit to a charge raised at booking.
|
||||
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$this->payments->applyCredits( $payerId, [ $payment ] );
|
||||
$payment = $this->payments->findPayment( (int) $payment->id ) ?? $payment;
|
||||
}
|
||||
|
||||
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' );
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ 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 {
|
||||
@@ -19,7 +22,9 @@ class LessonController {
|
||||
private PaymentRepository $payments,
|
||||
private AvailabilityRepository $availability,
|
||||
private OfferingRepository $offerings,
|
||||
private LessonDetail $detail,
|
||||
private IntakeAudit $detail,
|
||||
private AdminBooking $adminBooking,
|
||||
private IntakeRecording $intake,
|
||||
) {}
|
||||
|
||||
public function renderAdminDashboard(): void {
|
||||
@@ -31,11 +36,11 @@ class LessonController {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( false );
|
||||
[ $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 {
|
||||
@@ -47,11 +52,13 @@ class LessonController {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( true );
|
||||
$instructorId = get_current_user_id();
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
|
||||
[ $notice, $error ] = $this->handleFormAction( true, $instructorId );
|
||||
|
||||
$this->renderLessonsPage( $rows, 'us-my-lessons' );
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( $instructorId ) );
|
||||
|
||||
$this->renderLessonsPage( $rows, 'us-my-lessons', $instructorId, $notice, $error );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,15 +75,23 @@ class LessonController {
|
||||
|
||||
$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';
|
||||
@@ -84,13 +99,86 @@ class LessonController {
|
||||
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
|
||||
@@ -103,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.
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
|
||||
/**
|
||||
* Builds the display rows for the admin lesson detail view: the intake answers
|
||||
* the student submitted and the policy versions they accepted when booking.
|
||||
*
|
||||
* Scoped to a single lesson (the `lesson` registration type), mirroring the
|
||||
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
|
||||
*
|
||||
* A weekly reservation is answered for and agreed to once, so its answers and
|
||||
* acceptances hang off the series anchor. Every occurrence therefore reads its
|
||||
* series' registration, not its own id — otherwise only the first lesson of a
|
||||
* series showed the intake and the audit trail, and the rest looked as though
|
||||
* nothing had been accepted.
|
||||
*/
|
||||
class LessonDetail {
|
||||
|
||||
public function __construct(
|
||||
private AnswerRepository $answers,
|
||||
private QuestionRepository $questions,
|
||||
private AcceptanceRepository $acceptances,
|
||||
private PolicyRepository $policies,
|
||||
private PolicyVersionRepository $versions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The intake-question answers recorded for this lesson, in submission order.
|
||||
*
|
||||
* @return list<array{question: string, answer: string}>
|
||||
*/
|
||||
public function answers( Lesson $lesson ): array {
|
||||
return array_map(
|
||||
function ( Answer $answer ): array {
|
||||
$question = $this->questions->findById( $answer->questionId );
|
||||
$value = $answer->answerValue ?? '';
|
||||
|
||||
return [
|
||||
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
|
||||
'answer' => '' === $value ? '—' : $value,
|
||||
];
|
||||
},
|
||||
$this->answers->findByRegistration( Answer::REG_LESSON, $this->registrationId( $lesson ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy versions the student accepted when booking this lesson, with the
|
||||
* captured acceptance time and IP for the audit trail.
|
||||
*
|
||||
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
|
||||
*/
|
||||
public function acceptances( Lesson $lesson ): array {
|
||||
return array_map(
|
||||
function ( PolicyAcceptance $acceptance ): array {
|
||||
$version = $this->versions->findById( $acceptance->policyVersionId );
|
||||
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
|
||||
|
||||
return [
|
||||
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
|
||||
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
|
||||
'accepted_at' => $acceptance->acceptedAt ?? '',
|
||||
'ip' => $acceptance->ipAddress ?? '',
|
||||
];
|
||||
},
|
||||
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $this->registrationId( $lesson ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lesson id the booking's answers and acceptances were recorded against:
|
||||
* the series anchor for a weekly reservation, the lesson itself otherwise.
|
||||
*/
|
||||
private function registrationId( Lesson $lesson ): int {
|
||||
return $lesson->seriesId ?? (int) $lesson->id;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,14 +202,14 @@ class EnrollmentEndpoint {
|
||||
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
||||
$enrollment = $this->enrollments->findById( $id );
|
||||
|
||||
if ( null === $enrollment ) {
|
||||
// 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 ( ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
|
||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -81,6 +82,24 @@ class EnrollmentRepository {
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active enrolments in one offering, oldest first.
|
||||
*
|
||||
* @return list<Enrollment>
|
||||
*/
|
||||
public function findActiveByOffering( int $offeringId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE offering_id = %d AND status = %s ORDER BY id ASC',
|
||||
$this->table,
|
||||
$offeringId,
|
||||
Enrollment::STATUS_ACTIVE
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's enrolments, newest first.
|
||||
*
|
||||
|
||||
@@ -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, deadline: string, enrollment_open: bool, 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,6 +335,7 @@ 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,
|
||||
@@ -334,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(),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -489,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>
|
||||
*/
|
||||
@@ -499,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( ... ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -103,6 +104,14 @@ class GuardianService {
|
||||
$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(
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Offering;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Keeps existing enrolments from being billed twice when a group class is
|
||||
* switched from a pay-now billing mode (one-time / full-term) to a scheduled one
|
||||
* (weekly / monthly).
|
||||
*
|
||||
* A student who enrols while a class is pay-now is charged once at enrolment, and
|
||||
* that charge carries no `period_key`. The daily scan dedups scheduled charges by
|
||||
* `period_key`, so once the class becomes scheduled the scan does not see the
|
||||
* up-front charge and bills the enrolment again for the same period. This adopts
|
||||
* each such up-front charge into the current billing period so the scan treats it
|
||||
* as already billed; later periods bill normally.
|
||||
*
|
||||
* Only the transition *into* scheduled billing is handled — going the other way,
|
||||
* or editing an already-scheduled class, needs no reconciliation. Weekly is left
|
||||
* alone: a weekly class is billed per session as sessions come due, so there is
|
||||
* no single up-front period a prior charge maps onto.
|
||||
*/
|
||||
class BillingModeReconciler {
|
||||
|
||||
public function __construct(
|
||||
private EnrollmentRepository $enrollments,
|
||||
private PaymentRepository $payments,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reconcile an offering edit. Given the offering as it was and as it now is,
|
||||
* adopt up-front enrolment charges into the current month when the class has
|
||||
* just become monthly. Returns the number of charges adopted (0 when the edit
|
||||
* is not a pay-now -> monthly transition, or nothing needed adopting).
|
||||
*/
|
||||
public function reconcile( Offering $before, Offering $after ): int {
|
||||
if ( null === $after->id || Offering::KIND_GROUP_CLASS !== $after->kind ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Only a fresh switch into monthly scheduling can strand an up-front charge.
|
||||
$becameMonthly = Offering::BILLING_MONTHLY === $after->billingMode
|
||||
&& Offering::BILLING_MONTHLY !== $before->billingMode;
|
||||
if ( ! $becameMonthly ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$period = $this->currentMonth();
|
||||
$dueDate = $period . '-01';
|
||||
$adopted = 0;
|
||||
|
||||
foreach ( $this->enrollments->findActiveByOffering( $after->id ) as $enrollment ) {
|
||||
$enrollmentId = (int) $enrollment->id;
|
||||
|
||||
// Nothing to adopt unless the enrolment holds an up-front (unscheduled)
|
||||
// charge, and never when the scan has already billed this month for it —
|
||||
// adopting then would leave two charges for the month, the opposite of
|
||||
// the fix.
|
||||
if ( ! $this->payments->hasUnscheduledCharge( Payment::REG_ENROLLMENT, $enrollmentId )
|
||||
|| $this->payments->existsForPeriod( Payment::REG_ENROLLMENT, $enrollmentId, $period )
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$adopted += $this->payments->claimPeriodForUnscheduled( Payment::REG_ENROLLMENT, $enrollmentId, $period, $dueDate );
|
||||
}
|
||||
|
||||
return $adopted;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current calendar month as a `Y-m` period key, from WordPress site time
|
||||
* so it matches how the billing scan derives its periods.
|
||||
*/
|
||||
private function currentMonth(): string {
|
||||
$mysql = Val::string( current_time( 'mysql' ) );
|
||||
|
||||
return ( false !== strtotime( $mysql ) ? new \DateTimeImmutable( $mysql ) : new \DateTimeImmutable() )->format( 'Y-m' );
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class OfferingController {
|
||||
public function __construct(
|
||||
private OfferingRepository $repository,
|
||||
private ClassSlotReconciler $reconciler,
|
||||
private BillingModeReconciler $billingModeReconciler,
|
||||
private AccessSettings $access = new AccessSettings(),
|
||||
) {}
|
||||
|
||||
@@ -81,6 +82,11 @@ class OfferingController {
|
||||
if ( null !== $offering ) {
|
||||
$this->repository->update( $offeringId, $offering );
|
||||
|
||||
// Adopt any up-front enrolment charge into the current period when
|
||||
// this edit switched the class to monthly, so the daily scan does
|
||||
// not bill those enrolments a second time for the month.
|
||||
$this->billingModeReconciler->reconcile( $existing, $offering );
|
||||
|
||||
return $this->reconcileNotice( $offering );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class OfferingEndpoint {
|
||||
public function __construct(
|
||||
private OfferingRepository $repository,
|
||||
private GroupAccessRepository $access,
|
||||
private BillingModeReconciler $billingModeReconciler,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -237,6 +238,11 @@ class OfferingEndpoint {
|
||||
|
||||
$this->repository->update( $id, $offering );
|
||||
|
||||
// Adopt any up-front enrolment charge into the current period when this edit
|
||||
// switched the class to monthly, so the daily scan does not bill those
|
||||
// enrolments a second time for the month.
|
||||
$this->billingModeReconciler->reconcile( $existing, $offering );
|
||||
|
||||
return new \WP_REST_Response( $offering->toArray(), 200 );
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,13 @@ class Payment {
|
||||
public readonly ?string $stripePaymentIntentId = null,
|
||||
public readonly ?string $receiptNumber = null,
|
||||
public readonly ?string $receiptSentAt = null,
|
||||
/**
|
||||
* When the daily billing scan emailed this payment's due notice, or null
|
||||
* if it has not been noticed yet. Gates the notice so a payment is emailed
|
||||
* exactly once even if the scan runs more than once (WP-Cron fires on
|
||||
* request and can overlap under concurrent traffic).
|
||||
*/
|
||||
public readonly ?string $noticeSentAt = null,
|
||||
public readonly ?string $paidAt = null,
|
||||
public readonly ?string $createdAt = null,
|
||||
public readonly ?int $id = null,
|
||||
@@ -85,6 +92,7 @@ class Payment {
|
||||
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
|
||||
receiptNumber: Val::stringOrNull( $row->receipt_number ),
|
||||
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
|
||||
noticeSentAt: Val::stringOrNull( $row->notice_sent_at ?? null ),
|
||||
paidAt: Val::stringOrNull( $row->paid_at ),
|
||||
createdAt: Val::stringOrNull( $row->created_at ),
|
||||
id: Val::int( $row->id ),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* The editable payment-due email: its subject and body are stored as WordPress
|
||||
* options (falling back to built-in defaults) and rendered by substituting a
|
||||
* small set of `{token}` placeholders with the values the daily billing scan
|
||||
* gathered for a student.
|
||||
*
|
||||
* The body carries an {items} block — one line per charge — and optional
|
||||
* {credit}, {etransfer} and {reference} blocks that the renderer collapses to
|
||||
* nothing when they do not apply, so a studio admin never has to hand-edit
|
||||
* conditional prose.
|
||||
*/
|
||||
class PaymentDueEmailTemplate {
|
||||
|
||||
public const OPT_SUBJECT = 'us_payment_due_email_subject';
|
||||
public const OPT_BODY = 'us_payment_due_email_body';
|
||||
public const OPT_ITEM_LINE = 'us_payment_due_email_item_line';
|
||||
|
||||
/**
|
||||
* Tokens the admin may drop into the subject/body, mapped to a short
|
||||
* translated description shown beside the editor. `{items}` expands to the
|
||||
* itemised list rendered from the item-line template; the credit/etransfer/
|
||||
* reference tokens are whole lines that vanish when not applicable.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function tokens(): array {
|
||||
return [
|
||||
'{student_name}' => __( "The student's display name.", 'unsupervised-schedular' ),
|
||||
'{items}' => __( 'The itemised list of charges (one line each).', 'unsupervised-schedular' ),
|
||||
'{total_due}' => __( 'The grand total due, e.g. CAD 75.00.', 'unsupervised-schedular' ),
|
||||
'{credit}' => __( 'Account-credit line; empty when no credit applies.', 'unsupervised-schedular' ),
|
||||
'{etransfer}' => __( 'E-transfer destination line; empty when nothing is owed.', 'unsupervised-schedular' ),
|
||||
'{reference}' => __( 'Payment reference line; empty when no reference is set.', 'unsupervised-schedular' ),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens the item-line template understands, one charge at a time.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function itemTokens(): array {
|
||||
return [
|
||||
'{label}' => __( 'The charge description, e.g. Piano.', 'unsupervised-schedular' ),
|
||||
'{due_date}' => __( 'The due date, e.g. Jul 15, 2026.', 'unsupervised-schedular' ),
|
||||
'{currency}' => __( 'The currency code, e.g. CAD.', 'unsupervised-schedular' ),
|
||||
'{amount}' => __( 'The charge amount, e.g. 35.00.', 'unsupervised-schedular' ),
|
||||
];
|
||||
}
|
||||
|
||||
public static function defaultSubject(): string {
|
||||
return __( 'Payment due', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
public static function defaultBody(): string {
|
||||
return __(
|
||||
"You have upcoming payments due:\n\n{items}{credit}\n\nTotal due: {total_due}{etransfer}{reference}",
|
||||
'unsupervised-schedular'
|
||||
);
|
||||
}
|
||||
|
||||
public static function defaultItemLine(): string {
|
||||
/* translators: this is a template with tokens; keep the {tokens} intact. */
|
||||
return __( '- {label} (due {due_date}): {currency} {amount}', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
public function subject(): string {
|
||||
$stored = Val::string( get_option( self::OPT_SUBJECT, '' ) );
|
||||
|
||||
return '' !== $stored ? $stored : self::defaultSubject();
|
||||
}
|
||||
|
||||
public function body(): string {
|
||||
$stored = Val::string( get_option( self::OPT_BODY, '' ) );
|
||||
|
||||
return '' !== $stored ? $stored : self::defaultBody();
|
||||
}
|
||||
|
||||
public function itemLine(): string {
|
||||
$stored = Val::string( get_option( self::OPT_ITEM_LINE, '' ) );
|
||||
|
||||
return '' !== $stored ? $stored : self::defaultItemLine();
|
||||
}
|
||||
|
||||
public function saveSubject( string $subject ): void {
|
||||
update_option( self::OPT_SUBJECT, $subject );
|
||||
}
|
||||
|
||||
public function saveBody( string $body ): void {
|
||||
update_option( self::OPT_BODY, $body );
|
||||
}
|
||||
|
||||
public function saveItemLine( string $itemLine ): void {
|
||||
update_option( self::OPT_ITEM_LINE, $itemLine );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stored subject template with the given token values.
|
||||
*
|
||||
* @param array<string, string> $tokens Token => replacement (keys include the braces).
|
||||
*/
|
||||
public function renderSubject( array $tokens ): string {
|
||||
return strtr( $this->subject(), $tokens );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stored body template with the given token values.
|
||||
*
|
||||
* @param array<string, string> $tokens Token => replacement (keys include the braces).
|
||||
*/
|
||||
public function renderBody( array $tokens ): string {
|
||||
return strtr( $this->body(), $tokens );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one itemised charge line from the stored item-line template.
|
||||
*
|
||||
* @param array<string, string> $tokens Item token => replacement (keys include the braces).
|
||||
*/
|
||||
public function renderItemLine( array $tokens ): string {
|
||||
return strtr( $this->itemLine(), $tokens );
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,16 @@ namespace Unsupervised\Schedular\Payment;
|
||||
* 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.
|
||||
*
|
||||
* The subject and body come from {@see PaymentDueEmailTemplate}, an
|
||||
* admin-editable template of `{token}` placeholders; this class gathers the
|
||||
* values for those tokens (items list, totals, credit/e-transfer/reference
|
||||
* lines) and asks the template to render them.
|
||||
*/
|
||||
class PaymentDueMailer {
|
||||
|
||||
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -25,6 +32,29 @@ class PaymentDueMailer {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = $this->buildTokens(
|
||||
(string) $student->display_name,
|
||||
$items,
|
||||
$reference,
|
||||
$creditApplied
|
||||
);
|
||||
|
||||
return (bool) wp_mail(
|
||||
$student->user_email,
|
||||
$this->template->renderSubject( $tokens ),
|
||||
$this->template->renderBody( $tokens )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full token map the subject/body templates are rendered against,
|
||||
* from the same data the daily scan hands the mailer. Exposed so the admin
|
||||
* preview can render the exact email a real scan would produce.
|
||||
*
|
||||
* @param list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}> $items
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function buildTokens( string $studentName, array $items, string $reference = '', float $creditApplied = 0.0 ): array {
|
||||
$currency = (string) $items[0]['currency'];
|
||||
$total = 0.0;
|
||||
$lines = [];
|
||||
@@ -34,13 +64,13 @@ class PaymentDueMailer {
|
||||
$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 )
|
||||
$lines[] = $this->template->renderItemLine(
|
||||
[
|
||||
'{label}' => (string) $item['label'],
|
||||
'{due_date}' => $this->formatDate( $item['due_date'] ?? null ),
|
||||
'{currency}' => $currency,
|
||||
'{amount}' => number_format( $amount, 2 ),
|
||||
]
|
||||
);
|
||||
|
||||
$etransfer = (string) ( $item['etransfer_email'] ?? '' );
|
||||
@@ -53,11 +83,9 @@ class PaymentDueMailer {
|
||||
$creditApplied = round( min( $creditApplied, $total ), 2 );
|
||||
$dueTotal = round( $total - $creditApplied, 2 );
|
||||
|
||||
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
|
||||
. implode( "\n", $lines );
|
||||
|
||||
$credit = '';
|
||||
if ( $creditApplied > 0.0 ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
$credit = "\n\n" . sprintf(
|
||||
/* translators: 1: currency, 2: credit amount */
|
||||
__( 'Account credit applied: -%1$s %2$s', 'unsupervised-schedular' ),
|
||||
$currency,
|
||||
@@ -65,30 +93,32 @@ class PaymentDueMailer {
|
||||
);
|
||||
}
|
||||
|
||||
$body .= "\n\n" . sprintf(
|
||||
/* translators: 1: currency, 2: total amount */
|
||||
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
|
||||
$currency,
|
||||
number_format( $dueTotal, 2 )
|
||||
);
|
||||
|
||||
$etransfer = '';
|
||||
if ( $dueTotal > 0.0 && [] !== $emails ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
$etransfer = "\n\n" . sprintf(
|
||||
/* translators: %s: e-transfer destination email address(es) */
|
||||
__( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ),
|
||||
implode( ', ', array_keys( $emails ) )
|
||||
);
|
||||
}
|
||||
|
||||
$referenceLine = '';
|
||||
if ( '' !== $reference ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
$referenceLine = "\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 );
|
||||
return [
|
||||
'{student_name}' => $studentName,
|
||||
'{items}' => implode( "\n", $lines ),
|
||||
'{total_due}' => $currency . ' ' . number_format( $dueTotal, 2 ),
|
||||
'{credit}' => $credit,
|
||||
'{etransfer}' => $etransfer,
|
||||
'{reference}' => $referenceLine,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Admin screen for viewing and editing the payment-due email template, with a
|
||||
* live preview rendered from sample values. Save is a plain POST (nonce +
|
||||
* capability checked); the preview updates client-side against a REST endpoint
|
||||
* so an admin sees the effect of an edit before saving it.
|
||||
*/
|
||||
class PaymentEmailController {
|
||||
|
||||
public const NONCE_ACTION = 'usc_payment_email_action';
|
||||
|
||||
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
|
||||
|
||||
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( self::NONCE_ACTION ) ) {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified immediately above.
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
|
||||
if ( 'reset' === $action ) {
|
||||
$this->reset();
|
||||
$notice = __( 'Template reset to the built-in default.', 'unsupervised-schedular' );
|
||||
} else {
|
||||
$this->save();
|
||||
$notice = __( 'Payment due email template saved.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
|
||||
$subject = $this->template->subject();
|
||||
$body = $this->template->body();
|
||||
$itemLine = $this->template->itemLine();
|
||||
$tokens = PaymentDueEmailTemplate::tokens();
|
||||
$itemTokens = PaymentDueEmailTemplate::itemTokens();
|
||||
$previewNonce = wp_create_nonce( 'wp_rest' );
|
||||
$previewUrl = rest_url( 'us-scheduler/v1/payment-email/preview' );
|
||||
|
||||
// Server-render the initial preview from sample values so the panel is
|
||||
// populated before any JavaScript runs (and if it never does).
|
||||
$preview = self::renderSample( $this->template, $subject, $body, $itemLine );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/payment-email.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the given template text against a fixed set of sample values, using
|
||||
* the real mailer so the preview matches a genuine scan exactly. The passed
|
||||
* subject/body/item-line override the stored ones so an unsaved edit can be
|
||||
* previewed.
|
||||
*
|
||||
* @return array{subject: string, body: string}
|
||||
*/
|
||||
public static function renderSample( PaymentDueEmailTemplate $stored, string $subject, string $body, string $itemLine ): array {
|
||||
// A throwaway template returning the supplied (possibly unsaved) text.
|
||||
$draft = new class( $subject, $body, $itemLine ) extends PaymentDueEmailTemplate {
|
||||
public function __construct(
|
||||
private string $draftSubject,
|
||||
private string $draftBody,
|
||||
private string $draftItemLine,
|
||||
) {}
|
||||
|
||||
public function subject(): string {
|
||||
return '' !== $this->draftSubject ? $this->draftSubject : self::defaultSubject();
|
||||
}
|
||||
|
||||
public function body(): string {
|
||||
return '' !== $this->draftBody ? $this->draftBody : self::defaultBody();
|
||||
}
|
||||
|
||||
public function itemLine(): string {
|
||||
return '' !== $this->draftItemLine ? $this->draftItemLine : self::defaultItemLine();
|
||||
}
|
||||
};
|
||||
|
||||
$mailer = new PaymentDueMailer( $draft );
|
||||
$tokens = $mailer->buildTokens( self::sampleStudentName(), self::sampleItems(), self::sampleReference(), self::sampleCredit() );
|
||||
|
||||
return [
|
||||
'subject' => $draft->renderSubject( $tokens ),
|
||||
'body' => $draft->renderBody( $tokens ),
|
||||
];
|
||||
}
|
||||
|
||||
public static function sampleStudentName(): string {
|
||||
return __( 'Alex Student', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
public static function sampleReference(): string {
|
||||
return 'REF12345';
|
||||
}
|
||||
|
||||
public static function sampleCredit(): float {
|
||||
return 20.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample charges the preview is rendered against — two lessons on
|
||||
* different dates so the {items} block and grand total are both exercised.
|
||||
*
|
||||
* @return list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>
|
||||
*/
|
||||
public static function sampleItems(): array {
|
||||
return [
|
||||
[
|
||||
'label' => __( 'Piano lesson', 'unsupervised-schedular' ),
|
||||
'amount' => 35.0,
|
||||
'currency' => 'CAD',
|
||||
'due_date' => '2026-07-15',
|
||||
'etransfer_email' => '[email protected]',
|
||||
],
|
||||
[
|
||||
'label' => __( 'Guitar lesson', 'unsupervised-schedular' ),
|
||||
'amount' => 40.0,
|
||||
'currency' => 'CAD',
|
||||
'due_date' => '2026-07-22',
|
||||
'etransfer_email' => '[email protected]',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function save(): void {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$this->template->saveSubject( sanitize_text_field( Val::string( wp_unslash( $_POST['subject'] ?? '' ) ) ) );
|
||||
$this->template->saveBody( sanitize_textarea_field( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) ) );
|
||||
$this->template->saveItemLine( sanitize_text_field( Val::string( wp_unslash( $_POST['item_line'] ?? '' ) ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
private function reset(): void {
|
||||
$this->template->saveSubject( '' );
|
||||
$this->template->saveBody( '' );
|
||||
$this->template->saveItemLine( '' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Renders a live preview of the payment-due email from template text the admin
|
||||
* is editing (not yet saved) against fixed sample values, so the settings screen
|
||||
* can show the resulting email as the admin types. Read-only: it never writes
|
||||
* the template.
|
||||
*/
|
||||
class PaymentEmailPreviewEndpoint {
|
||||
|
||||
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
|
||||
|
||||
/**
|
||||
* Registers this endpoint's REST routes.
|
||||
*
|
||||
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
|
||||
*/
|
||||
public function registerRoutes( string $route_namespace ): void {
|
||||
register_rest_route(
|
||||
$route_namespace,
|
||||
'/payment-email/preview',
|
||||
[
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [ $this, 'preview' ],
|
||||
'permission_callback' => [ $this, 'canManage' ],
|
||||
'args' => [
|
||||
'subject' => [ 'type' => 'string' ],
|
||||
'body' => [ 'type' => 'string' ],
|
||||
'item_line' => [ 'type' => 'string' ],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the submitted (draft) template text against the sample values.
|
||||
*/
|
||||
public function preview( \WP_REST_Request $request ): \WP_REST_Response {
|
||||
$subject = Val::string( $request->get_param( 'subject' ) );
|
||||
$body = Val::string( $request->get_param( 'body' ) );
|
||||
$itemLine = Val::string( $request->get_param( 'item_line' ) );
|
||||
|
||||
$rendered = PaymentEmailController::renderSample( $this->template, $subject, $body, $itemLine );
|
||||
|
||||
return new \WP_REST_Response( $rendered, 200 );
|
||||
}
|
||||
|
||||
public function canManage(): bool {
|
||||
return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_BILLING );
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,11 @@ class PaymentRepository {
|
||||
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
|
||||
'receipt_number' => $payment->receiptNumber,
|
||||
'receipt_sent_at' => $payment->receiptSentAt,
|
||||
'notice_sent_at' => $payment->noticeSentAt,
|
||||
'paid_at' => $payment->paidAt,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%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', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -271,6 +272,121 @@ class PaymentRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a registration has an unscheduled, un-voided charge — one taken at
|
||||
* registration (`period_key IS NULL`, `status != failed`). The billing-mode
|
||||
* reconciler uses this to spot an up-front charge that a newly-scheduled
|
||||
* offering would otherwise cause the scan to bill a second time.
|
||||
*/
|
||||
public function hasUnscheduledCharge( string $registrationType, int $registrationId ): bool {
|
||||
$found = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key IS NULL AND status != %s LIMIT 1',
|
||||
$this->table,
|
||||
$registrationType,
|
||||
$registrationId,
|
||||
Payment::STATUS_FAILED
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a registration's existing unscheduled charge with a billing period and
|
||||
* due date so the daily scan treats that period as already billed. Used when
|
||||
* an offering is switched to scheduled billing: the charge taken at enrolment
|
||||
* (which carries no `period_key`) would otherwise never match the scan's
|
||||
* per-period dedup, and the enrolment would be billed a second time for the
|
||||
* period the up-front charge already covers.
|
||||
*
|
||||
* Only ever adopts a charge that is genuinely unscheduled (`period_key IS
|
||||
* NULL`) and not voided (`status != failed`) — so it cannot overwrite a real
|
||||
* scheduled charge or revive a cancelled one. The caller guards against a
|
||||
* period that already has a scheduled charge (see {@see existsForPeriod}).
|
||||
* Returns the number of rows adopted (0 or 1).
|
||||
*/
|
||||
public function claimPeriodForUnscheduled( string $registrationType, int $registrationId, string $periodKey, string $dueDate ): int {
|
||||
$sql = $this->db->prepare(
|
||||
'UPDATE %i SET period_key = %s, due_date = %s
|
||||
WHERE registration_type = %s AND registration_id = %d
|
||||
AND period_key IS NULL AND status != %s
|
||||
ORDER BY id ASC LIMIT 1',
|
||||
$this->table,
|
||||
$periodKey,
|
||||
$dueDate,
|
||||
$registrationType,
|
||||
$registrationId,
|
||||
Payment::STATUS_FAILED
|
||||
);
|
||||
|
||||
if ( null === $sql ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $this->db->query( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a payment for its one due-payment notice. Stamps
|
||||
* `notice_sent_at` only if it is still null, and returns whether *this* call
|
||||
* won the claim (one row updated). The billing scan calls this before
|
||||
* emailing so a payment's notice is sent exactly once: WP-Cron fires on
|
||||
* request and can overlap under concurrent traffic, so two scans may both
|
||||
* reach the send step for the same payment — the loser here updates zero rows
|
||||
* and skips the email. The conditional `WHERE ... IS NULL` is the guard, not a
|
||||
* prior read, so there is no check-then-act race.
|
||||
*/
|
||||
public function markNoticed( int $id ): bool {
|
||||
$sql = $this->db->prepare(
|
||||
'UPDATE %i SET notice_sent_at = %s WHERE id = %d AND notice_sent_at IS NULL',
|
||||
$this->table,
|
||||
current_time( 'mysql' ),
|
||||
$id
|
||||
);
|
||||
|
||||
if ( null === $sql ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $this->db->query( $sql ) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time repair for sites upgraded before `notice_sent_at` existed: dbDelta
|
||||
* adds the column, and this backfills it so the historical payments those
|
||||
* sites already emailed notices for are not re-noticed on the next scan.
|
||||
* Every pre-existing pending scheduled row is treated as already noticed.
|
||||
* Guarded by its own option flag in {@see \Unsupervised\Schedular\Plugin},
|
||||
* not the version gate, since affected sites may already be on the current
|
||||
* version. Returns false when the column is absent so the caller does not set
|
||||
* its flag before dbDelta has run.
|
||||
*/
|
||||
public function backfillNoticeSent(): bool {
|
||||
$column = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SHOW COLUMNS FROM %i LIKE %s',
|
||||
$this->table,
|
||||
'notice_sent_at'
|
||||
)
|
||||
);
|
||||
|
||||
if ( null === $column ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sql = $this->db->prepare(
|
||||
'UPDATE %i SET notice_sent_at = created_at WHERE notice_sent_at IS NULL AND period_key IS NOT NULL',
|
||||
$this->table
|
||||
);
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function updateStatus( int $id, string $status ): bool {
|
||||
if ( ! in_array( $status, Payment::VALID_STATUSES, true ) ) {
|
||||
return false;
|
||||
|
||||
@@ -106,6 +106,25 @@ class PaymentService {
|
||||
$this->payments->assignNoticeBatch( $ids, $batch );
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a payment for its single due-payment notice, returning
|
||||
* whether this call won the claim. The daily scan calls this before emailing
|
||||
* so a payment is noticed exactly once even when WP-Cron overlaps. Delegates
|
||||
* to the ledger.
|
||||
*/
|
||||
public function markNoticed( int $paymentId ): bool {
|
||||
return $this->payments->markNoticed( $paymentId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read a payment from the ledger — the caller's way to pick up a status or
|
||||
* credit change {@see applyCredits} wrote straight to the row, since the
|
||||
* Payment object it holds is immutable. Delegates to the ledger.
|
||||
*/
|
||||
public function findPayment( int $paymentId ): ?Payment {
|
||||
return $this->payments->findById( $paymentId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Studio-admin confirmation that a pending payment (e-transfer) was received.
|
||||
* Marks it paid, confirms the registration, and emails the receipt.
|
||||
|
||||
@@ -352,6 +352,24 @@ class ScheduledBillingRunner {
|
||||
*/
|
||||
private function sendNotices( array $buckets ): void {
|
||||
foreach ( $buckets as $payerId => $entries ) {
|
||||
// Claim each payment's one-and-only notice up front. markNoticed stamps
|
||||
// notice_sent_at only if still null and reports whether this run won —
|
||||
// so an overlapping scan that also created/collected these payments
|
||||
// finds them already claimed and drops them here, and no payer is
|
||||
// emailed the same charge twice. Only the claimed entries go on to be
|
||||
// credited, batched and listed.
|
||||
$entries = array_values(
|
||||
array_filter(
|
||||
$entries,
|
||||
fn( array $entry ): bool => null !== $entry['payment']->id
|
||||
&& $this->payments->markNoticed( (int) $entry['payment']->id )
|
||||
)
|
||||
);
|
||||
|
||||
if ( [] === $entries ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
|
||||
$applied = $this->payments->applyCredits( $payerId, $payments );
|
||||
|
||||
|
||||
@@ -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 ) );
|
||||
|
||||
+26
-3
@@ -16,6 +16,7 @@ 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;
|
||||
@@ -23,6 +24,7 @@ use Unsupervised\Schedular\Guardian\ChildLoginGate;
|
||||
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||
use Unsupervised\Schedular\Guardian\GuardianRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\BillingModeReconciler;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
@@ -94,13 +96,29 @@ class Plugin {
|
||||
$guardianRepo = new GuardianRepository( $wpdb );
|
||||
$guardians = new GuardianService( $guardianRepo, $bookings, $enrollments );
|
||||
|
||||
$paymentRepo = new PaymentRepository( $wpdb );
|
||||
$paymentRepo = new PaymentRepository( $wpdb );
|
||||
|
||||
// One-time backfill of us_payments.notice_sent_at, which dbDelta adds
|
||||
// defaulting to NULL — leaving every already-noticed scheduled payment
|
||||
// looking un-noticed, which the billing scan would email again. Backfills
|
||||
// existing scheduled rows to their created_at so only genuinely new
|
||||
// payments get a notice from here on. Guarded by its own flag rather than
|
||||
// the version gate, since affected sites may already be on the current
|
||||
// version; set only once the column exists and the update runs.
|
||||
if ( '1' !== get_option( 'us_payments_notice_sent_backfilled', '' ) && $paymentRepo->backfillNoticeSent() ) {
|
||||
update_option( 'us_payments_notice_sent_backfilled', '1' );
|
||||
}
|
||||
|
||||
$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, $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();
|
||||
@@ -112,6 +130,11 @@ class Plugin {
|
||||
$familyPage = new FamilyPage( $guardians, $questions, $answers );
|
||||
$accountPage = new AccountPage();
|
||||
|
||||
// Adopts an up-front enrolment charge into the current billing period when a
|
||||
// group class is switched to monthly, so the daily scan does not bill it a
|
||||
// second time for a month the up-front charge already covers.
|
||||
$billingModeReconciler = new BillingModeReconciler( $enrollments, $paymentRepo );
|
||||
|
||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
|
||||
|
||||
( new UpdateChecker() )->register();
|
||||
@@ -121,8 +144,8 @@ class Plugin {
|
||||
( 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, $creditRepo, $guardians ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
|
||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians, $lessonBooker, $registrationGate, $billingModeReconciler ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians, $lessonBooker, $billingModeReconciler ) )->register();
|
||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
|
||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@ class AcceptanceRepository {
|
||||
'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', '%d', '%s', '%d', '%s', '%s' ]
|
||||
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
|
||||
@@ -32,6 +32,19 @@ class PolicyAcceptance {
|
||||
*/
|
||||
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,
|
||||
) {}
|
||||
@@ -44,6 +57,9 @@ class PolicyAcceptance {
|
||||
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 ),
|
||||
);
|
||||
@@ -81,6 +97,9 @@ class PolicyAcceptance {
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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() )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
/**
|
||||
* Where an intake answer or policy acceptance came from, when it did not come
|
||||
* from the student filling in the booking form.
|
||||
*
|
||||
* A lesson the studio booked on someone's behalf has no answers and no
|
||||
* acceptances — nobody was at a keyboard to give them — so they are collected
|
||||
* some other way and typed in afterwards. What makes that record worth keeping
|
||||
* is knowing *how*: "accepted on 24 Aug" means one thing when a student ticked a
|
||||
* box and quite another when a staff member read it off a signed form, and an
|
||||
* audit trail that cannot tell them apart is worse than no audit trail, because
|
||||
* it looks like one.
|
||||
*
|
||||
* Absent (null) provenance is therefore meaningful in its own right: it is the
|
||||
* ordinary case of the student answering online.
|
||||
*/
|
||||
class IntakeProvenance {
|
||||
|
||||
public const VIA_PAPER = 'paper';
|
||||
public const VIA_IN_PERSON = 'in_person';
|
||||
public const VIA_PHONE = 'phone';
|
||||
public const VIA_EMAIL = 'email';
|
||||
public const VIA_OTHER = 'other';
|
||||
|
||||
/**
|
||||
* How the answers can have reached the studio. `other` exists so the list
|
||||
* never forces a lie, and is the one option that must be explained.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_METHODS = [ self::VIA_PAPER, self::VIA_IN_PERSON, self::VIA_PHONE, self::VIA_EMAIL, self::VIA_OTHER ];
|
||||
|
||||
/** Longest note the `collected_note` VARCHAR(191) column holds. */
|
||||
public const MAX_NOTE_LENGTH = 191;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $collectedVia,
|
||||
public readonly ?string $collectedNote = null,
|
||||
public readonly int $recordedBy = 0,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build from submitted values, or explain what is wrong with them. A method
|
||||
* outside the vocabulary is rejected rather than stored: a column that can say
|
||||
* anything says nothing. `other` requires the note, since "other" on its own
|
||||
* answers the question with the question.
|
||||
*/
|
||||
public static function fromInput( string $collectedVia, string $collectedNote, int $recordedBy ): self|\WP_Error {
|
||||
if ( ! in_array( $collectedVia, self::VALID_METHODS, true ) ) {
|
||||
return new \WP_Error( 'invalid_collection_method', __( 'Choose how these were collected.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$note = trim( $collectedNote );
|
||||
|
||||
if ( self::VIA_OTHER === $collectedVia && '' === $note ) {
|
||||
return new \WP_Error( 'collection_note_required', __( 'Say how these were collected.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
return new self(
|
||||
collectedVia: $collectedVia,
|
||||
collectedNote: '' !== $note ? mb_substr( $note, 0, self::MAX_NOTE_LENGTH ) : null,
|
||||
recordedBy: $recordedBy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The methods as `value => label`, for the form's picker and for reading a
|
||||
* stored value back on screen.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function choices(): array {
|
||||
return [
|
||||
self::VIA_PAPER => __( 'On a signed paper form', 'unsupervised-schedular' ),
|
||||
self::VIA_IN_PERSON => __( 'In person', 'unsupervised-schedular' ),
|
||||
self::VIA_PHONE => __( 'Over the phone', 'unsupervised-schedular' ),
|
||||
self::VIA_EMAIL => __( 'By email', 'unsupervised-schedular' ),
|
||||
self::VIA_OTHER => __( 'Some other way', 'unsupervised-schedular' ),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a stored row reads on screen: the method's label, plus its note. An
|
||||
* empty method is the ordinary case — the student answered online — and says
|
||||
* so rather than showing a blank cell.
|
||||
*/
|
||||
public static function describe( ?string $collectedVia, ?string $collectedNote = null ): string {
|
||||
if ( null === $collectedVia || '' === $collectedVia ) {
|
||||
return __( 'Given online when booking', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$label = self::choices()[ $collectedVia ] ?? $collectedVia;
|
||||
$note = null !== $collectedNote ? trim( $collectedNote ) : '';
|
||||
|
||||
return '' !== $note ? $label . ' — ' . $note : $label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
|
||||
/**
|
||||
* Recording, after the fact, the intake answers and policy acceptances of a
|
||||
* registration the studio made on a student's behalf — a lesson booked from the
|
||||
* Scheduler, a student added straight into a group class.
|
||||
*
|
||||
* Such a registration has neither: nobody was at a keyboard to answer the
|
||||
* questions or tick the boxes, and staff doing it *for* the student at the time
|
||||
* would be an audit trail that says something untrue. The answers are instead
|
||||
* collected some other way — a paper form, a phone call — and typed in here, each
|
||||
* row stamped with how it was obtained ({@see IntakeProvenance}), so a reader can
|
||||
* always tell a student's own click from a studio's transcription.
|
||||
*
|
||||
* Two rules hold this honest:
|
||||
*
|
||||
* 1. **Only a staff-made registration qualifies**
|
||||
* ({@see IntakeSubject::isStaffRegistered()}). One the student made already
|
||||
* has their real answers, and letting staff add more would let the record be
|
||||
* edited after the fact.
|
||||
* 2. **Only what is still missing can be recorded.** Answers and acceptances are
|
||||
* written once and never overwritten, so a second submission cannot quietly
|
||||
* replace what a student actually said.
|
||||
*/
|
||||
class IntakeRecording {
|
||||
|
||||
public function __construct(
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
private PolicyRepository $policies,
|
||||
private PolicyVersionRepository $versions,
|
||||
private AcceptanceRepository $acceptances,
|
||||
private RegistrationGate $gate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* What is still unrecorded for this registration: the intake questions with no
|
||||
* answer, and the current policy versions with no acceptance. An empty pair
|
||||
* means there is nothing left to collect and the form has nothing to show.
|
||||
*
|
||||
* @return array{questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>}
|
||||
*/
|
||||
public function pending( IntakeSubject $subject ): array {
|
||||
$type = $subject->intakeRegistrationType();
|
||||
$registrationId = $subject->intakeRegistrationId();
|
||||
|
||||
$answered = array_map(
|
||||
static fn( Answer $a ): int => $a->questionId,
|
||||
$this->answers->findByRegistration( $type, $registrationId )
|
||||
);
|
||||
|
||||
$accepted = array_map(
|
||||
static fn( PolicyAcceptance $a ): int => $a->policyVersionId,
|
||||
$this->acceptances->findByRegistration( $type, $registrationId )
|
||||
);
|
||||
|
||||
$questions = [];
|
||||
foreach ( $this->questions->findByOffering( $subject->intakeOfferingId(), true ) as $question ) {
|
||||
if ( in_array( (int) $question->id, $answered, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$questions[] = [
|
||||
'id' => (int) $question->id,
|
||||
'label' => $question->label,
|
||||
'required' => $this->isRequiredOf( $question ),
|
||||
];
|
||||
}
|
||||
|
||||
$policies = [];
|
||||
foreach ( $this->gate->requiredPolicyVersionIds() as $versionId ) {
|
||||
if ( in_array( $versionId, $accepted, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->versions->findById( $versionId );
|
||||
$policy = null !== $version ? $this->policies->findById( $version->policyId ) : null;
|
||||
|
||||
$policies[] = [
|
||||
'version_id' => $versionId,
|
||||
'policy' => null !== $policy ? $policy->title : sprintf( '#%d', $versionId ),
|
||||
'version' => null !== $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'questions' => $questions,
|
||||
'policies' => $policies,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the studio collected elsewhere, returning the notice to show.
|
||||
*
|
||||
* Submitted answers and acceptances are narrowed to what is actually still
|
||||
* pending before anything is written, so a stale form — reloaded, or posted
|
||||
* twice — can neither duplicate a row nor overwrite one.
|
||||
*
|
||||
* @param array<int, string> $answers question_id => answer value
|
||||
* @param list<int> $versionIds Policy version ids being accepted
|
||||
*
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
public function record( IntakeSubject $subject, array $answers, array $versionIds, string $collectedVia, string $collectedNote, int $recordedBy ): string|\WP_Error {
|
||||
if ( ! $subject->isStaffRegistered() ) {
|
||||
return new \WP_Error(
|
||||
'not_recordable',
|
||||
__( 'Intake can only be recorded for a registration the studio made on the student\'s behalf.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
$provenance = IntakeProvenance::fromInput( $collectedVia, $collectedNote, $recordedBy );
|
||||
if ( $provenance instanceof \WP_Error ) {
|
||||
return $provenance;
|
||||
}
|
||||
|
||||
$pending = $this->pending( $subject );
|
||||
|
||||
$pendingQuestionIds = array_map( static fn( array $q ): int => $q['id'], $pending['questions'] );
|
||||
$pendingVersionIds = array_map( static fn( array $p ): int => $p['version_id'], $pending['policies'] );
|
||||
|
||||
$newAnswers = [];
|
||||
foreach ( $answers as $questionId => $value ) {
|
||||
$value = trim( $value );
|
||||
if ( '' !== $value && in_array( (int) $questionId, $pendingQuestionIds, true ) ) {
|
||||
$newAnswers[ (int) $questionId ] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$newVersionIds = array_values( array_intersect( $versionIds, $pendingVersionIds ) );
|
||||
|
||||
if ( [] === $newAnswers && [] === $newVersionIds ) {
|
||||
return new \WP_Error( 'nothing_to_record', __( 'Nothing was filled in to record.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
// No IP address is passed: the student was not at a browser, and borrowing
|
||||
// the staff member's would put a false location in the audit trail. Nor is
|
||||
// an acceptor — the student agreed, on paper or over the phone; who typed it
|
||||
// in is `recorded_by`, which the provenance carries.
|
||||
$this->gate->record(
|
||||
$subject->intakeRegistrationType(),
|
||||
$subject->intakeRegistrationId(),
|
||||
$subject->intakeStudentId(),
|
||||
$subject->intakeOfferingId(),
|
||||
$newAnswers,
|
||||
$newVersionIds,
|
||||
null,
|
||||
0,
|
||||
$provenance
|
||||
);
|
||||
|
||||
return $this->notice( count( $newAnswers ), count( $newVersionIds ), $provenance );
|
||||
}
|
||||
|
||||
/** What was written, and how it was said to have been collected. */
|
||||
private function notice( int $answers, int $acceptances, IntakeProvenance $provenance ): string {
|
||||
$parts = [];
|
||||
|
||||
if ( $answers > 0 ) {
|
||||
/* translators: %d: number of intake answers recorded. */
|
||||
$parts[] = sprintf( _n( '%d answer', '%d answers', $answers, 'unsupervised-schedular' ), $answers );
|
||||
}
|
||||
|
||||
if ( $acceptances > 0 ) {
|
||||
/* translators: %d: number of policy acceptances recorded. */
|
||||
$parts[] = sprintf( _n( '%d policy acceptance', '%d policy acceptances', $acceptances, 'unsupervised-schedular' ), $acceptances );
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
/* translators: 1: what was recorded, e.g. "2 answers and 1 policy acceptance", 2: how they were collected. */
|
||||
__( 'Recorded %1$s, collected: %2$s', 'unsupervised-schedular' ),
|
||||
implode( __( ' and ', 'unsupervised-schedular' ), $parts ),
|
||||
IntakeProvenance::describe( $provenance->collectedVia, $provenance->collectedNote )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the question is one the booking form would have insisted on, of
|
||||
* either audience. It is shown as a hint only — a studio that has half the
|
||||
* answers should be able to record the half it has, rather than being made to
|
||||
* invent the rest to get the form to submit.
|
||||
*/
|
||||
private function isRequiredOf( Question $question ): bool {
|
||||
return $question->isRequired || $question->isRequiredChild;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
/**
|
||||
* A registration that intake answers and policy acceptances hang off: a booked
|
||||
* lesson, or a group-class enrolment.
|
||||
*
|
||||
* The two are different enough to keep their own tables and their own booking
|
||||
* flows, but identical in this one respect — somebody registered, questions were
|
||||
* (or were not) answered, policies were (or were not) agreed to — and the rules
|
||||
* for reading and recording that are not worth writing twice. Everything about
|
||||
* intake works against this interface rather than either model.
|
||||
*
|
||||
* The `intake` prefix is not decoration: both implementations already carry
|
||||
* `$studentId` and `$offeringId` properties, and PHP 8.1 has no way to declare a
|
||||
* property on an interface, so the accessors need names of their own.
|
||||
*/
|
||||
interface IntakeSubject {
|
||||
|
||||
/**
|
||||
* Which polymorphic registration table this is — `Answer::REG_LESSON` or
|
||||
* `Answer::REG_ENROLLMENT`, matching the acceptance constants of the same
|
||||
* names.
|
||||
*/
|
||||
public function intakeRegistrationType(): string;
|
||||
|
||||
/**
|
||||
* The id answers and acceptances are stored against. Not always the row's own
|
||||
* id: a weekly lesson series is answered for once, against its anchor, so
|
||||
* every occurrence reads and writes the same registration.
|
||||
*/
|
||||
public function intakeRegistrationId(): int;
|
||||
|
||||
/** The offering whose questions apply. */
|
||||
public function intakeOfferingId(): int;
|
||||
|
||||
/** Who the answers and acceptances belong to. */
|
||||
public function intakeStudentId(): int;
|
||||
|
||||
/**
|
||||
* Whether the studio registered this on the student's behalf, rather than the
|
||||
* student (or their guardian) doing it themselves. Only these can have their
|
||||
* intake recorded after the fact: one the student made already holds their own
|
||||
* answers, and adding to those would make the record editable after the event.
|
||||
*/
|
||||
public function isStaffRegistered(): bool;
|
||||
}
|
||||
@@ -62,10 +62,14 @@ class RegistrationGate {
|
||||
* guardian booking for a child. It defaults to 0, read back as "the student
|
||||
* agreed for themselves".
|
||||
*
|
||||
* `$provenance` marks answers the studio collected some other way and typed in
|
||||
* afterwards; null (the default) is the ordinary case of the student giving
|
||||
* them online. See {@see IntakeProvenance}.
|
||||
*
|
||||
* @param array<int, string> $answers question_id => answer value
|
||||
* @param list<int> $acceptedVersionIds Accepted policy version IDs
|
||||
*/
|
||||
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0 ): void {
|
||||
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0, ?IntakeProvenance $provenance = null ): void {
|
||||
foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) {
|
||||
$value = (string) ( $answers[ (int) $question->id ] ?? '' );
|
||||
if ( '' === $value ) {
|
||||
@@ -79,6 +83,9 @@ class RegistrationGate {
|
||||
registrationId: $registrationId,
|
||||
studentId: $studentId,
|
||||
answerValue: $value,
|
||||
collectedVia: $provenance?->collectedVia,
|
||||
collectedNote: $provenance?->collectedNote,
|
||||
recordedBy: null !== $provenance ? $provenance->recordedBy : 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -96,17 +103,22 @@ class RegistrationGate {
|
||||
registrationId: $registrationId,
|
||||
acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId,
|
||||
ipAddress: $ipAddress,
|
||||
collectedVia: $provenance?->collectedVia,
|
||||
collectedNote: $provenance?->collectedNote,
|
||||
recordedBy: null !== $provenance ? $provenance->recordedBy : 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current published version IDs of every booking-scoped policy.
|
||||
* Current published version IDs of every booking-scoped policy — what a
|
||||
* booking must accept, and so also what a late, collected-elsewhere recording
|
||||
* has to offer.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function requiredPolicyVersionIds(): array {
|
||||
public function requiredPolicyVersionIds(): array {
|
||||
$ids = [];
|
||||
|
||||
foreach ( $this->policies->findForScope( Policy::SCOPE_BOOKING ) as $policy ) {
|
||||
|
||||
+14
-8
@@ -9,13 +9,16 @@ use Unsupervised\Schedular\Availability\WindowValidator;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\BillingModeReconciler;
|
||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentEmailPreviewEndpoint;
|
||||
use Unsupervised\Schedular\Payment\PaymentEndpoint;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
@@ -38,15 +41,17 @@ class RestRegistrar {
|
||||
private PolicyEndpoint $policyEndpoint;
|
||||
private EnrollmentEndpoint $enrollmentEndpoint;
|
||||
private PaymentEndpoint $paymentEndpoint;
|
||||
private PaymentEmailPreviewEndpoint $paymentEmailPreviewEndpoint;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians ) {
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
|
||||
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians, LessonBooker $booker, BillingModeReconciler $billingModeReconciler ) {
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess, $billingModeReconciler );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
|
||||
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
|
||||
$this->paymentEmailPreviewEndpoint = new PaymentEmailPreviewEndpoint();
|
||||
}
|
||||
|
||||
public function register(): void {
|
||||
@@ -61,5 +66,6 @@ class RestRegistrar {
|
||||
$this->policyEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->enrollmentEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->paymentEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->paymentEmailPreviewEndpoint->registerRoutes( self::NAMESPACE );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,33 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
class Schema {
|
||||
|
||||
/**
|
||||
* Every table this plugin owns, unprefixed and in creation order.
|
||||
*
|
||||
* The statements in {@see tables()} spell their own names out, so this list is
|
||||
* what anything that needs to *name* the tables without building them reads —
|
||||
* {@see Uninstaller}, which drops them. Add a table below and add it here, or
|
||||
* uninstalling will leave it behind.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const TABLES = [
|
||||
'us_availability',
|
||||
'us_lessons',
|
||||
'us_offerings',
|
||||
'us_questions',
|
||||
'us_question_answers',
|
||||
'us_policies',
|
||||
'us_policy_versions',
|
||||
'us_policy_acceptances',
|
||||
'us_payments',
|
||||
'us_credits',
|
||||
'us_group_enrollments',
|
||||
'us_invites',
|
||||
'us_guardians',
|
||||
'us_group_access',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns CREATE TABLE statements for dbDelta.
|
||||
*
|
||||
@@ -40,6 +67,7 @@ class Schema {
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
payment_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
notes TEXT,
|
||||
booked_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY slot_id (slot_id),
|
||||
@@ -104,6 +132,9 @@ class Schema {
|
||||
registration_id BIGINT UNSIGNED NOT NULL,
|
||||
student_id BIGINT UNSIGNED NOT NULL,
|
||||
answer_value TEXT,
|
||||
collected_via VARCHAR(20) DEFAULT NULL,
|
||||
collected_note VARCHAR(191) DEFAULT NULL,
|
||||
recorded_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY question_id (question_id),
|
||||
@@ -145,6 +176,9 @@ class Schema {
|
||||
registration_id BIGINT UNSIGNED NOT NULL,
|
||||
accepted_at DATETIME NOT NULL,
|
||||
ip_address VARCHAR(45) DEFAULT NULL,
|
||||
collected_via VARCHAR(20) DEFAULT NULL,
|
||||
collected_note VARCHAR(191) DEFAULT NULL,
|
||||
recorded_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY policy_version_id (policy_version_id),
|
||||
KEY student_id (student_id),
|
||||
@@ -173,6 +207,7 @@ class Schema {
|
||||
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
|
||||
receipt_number VARCHAR(50) DEFAULT NULL,
|
||||
receipt_sent_at DATETIME DEFAULT NULL,
|
||||
notice_sent_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
paid_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
@@ -210,6 +245,7 @@ class Schema {
|
||||
instructor_id BIGINT UNSIGNED NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
payment_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
enrolled_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
enrolled_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY offering_id (offering_id),
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Update\UpdateChecker;
|
||||
|
||||
/**
|
||||
* What deleting the plugin takes with it.
|
||||
*
|
||||
* WordPress gives an uninstall no interface of its own — `uninstall.php` runs
|
||||
* headless, after the plugin is already gone from the screen — so the choice has
|
||||
* to be made in advance, on **Access → Plugin removal**, and read back here. 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 deactivate-and-reinstall, or a delete during
|
||||
* a migration, must not be the thing that loses them. So the data is kept
|
||||
* unless the site owner has explicitly said otherwise.
|
||||
* - Credentials are not records. The Stripe secret and webhook signing key can
|
||||
* be re-pasted from the Stripe dashboard in under a minute, and leaving live
|
||||
* keys in `wp_options` of a site that no longer has the code to use them is
|
||||
* nothing but exposure. So those go every time, choice or no choice.
|
||||
*
|
||||
* The two WordPress settings open registration borrows — `users_can_register`
|
||||
* and `default_role` — are also always put back. They are the site's, not the
|
||||
* plugin's, and leaving them behind would leave the site accepting public
|
||||
* signups into a Student role that may no longer exist.
|
||||
*/
|
||||
class Uninstaller {
|
||||
|
||||
/**
|
||||
* Whether deleting the plugin also erases everything it recorded. Off unless
|
||||
* the site owner turns it on, because the mistake is unrecoverable in one
|
||||
* direction only.
|
||||
*/
|
||||
public const OPT_DELETE_DATA = 'us_delete_data_on_uninstall';
|
||||
|
||||
/**
|
||||
* Stripe credentials and configuration. Always removed — see the class
|
||||
* docblock.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const CREDENTIAL_OPTIONS = [
|
||||
StudioSettings::OPT_PUBLISHABLE,
|
||||
StudioSettings::OPT_SECRET,
|
||||
StudioSettings::OPT_WEBHOOK_SECRET,
|
||||
StudioSettings::OPT_MODE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Every remaining option the plugin writes. Removed only on a full purge.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const SETTING_OPTIONS = [
|
||||
'us_schedular_version',
|
||||
'us_questions_offering_nullable',
|
||||
'us_questions_child_required_backfilled',
|
||||
StudioSettings::OPT_CURRENCY,
|
||||
StudioSettings::OPT_ETRANSFER_EMAIL,
|
||||
StudioSettings::OPT_HST_RATE,
|
||||
StudioSettings::OPT_DEFAULT_PAYMENT_METHOD,
|
||||
StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS,
|
||||
StudioSettings::OPT_REGISTRATION_MODE,
|
||||
RegistrationController::OPTION_PAGE,
|
||||
AccessSettings::OPT_GRANT_STUDIO,
|
||||
AccessSettings::OPT_GRANT_INSTRUCTOR,
|
||||
self::OPT_DELETE_DATA,
|
||||
];
|
||||
|
||||
/**
|
||||
* Every user meta key the plugin writes. Removed for all users on a full
|
||||
* purge, so no student is left carrying a billing override or a half-finished
|
||||
* signup for a plugin that is gone.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const USER_META = [
|
||||
GuardianService::META_CHILD,
|
||||
GuardianService::META_BIRTH_YEAR,
|
||||
GuardianService::META_DOB,
|
||||
GuardianService::META_GUARDIAN_ONLY,
|
||||
BillingMethodResolver::META_METHOD,
|
||||
RegistrationStatus::META_AWAITING_APPROVAL,
|
||||
RegistrationStatus::META_EMAIL_CONFIRMED,
|
||||
RegistrationStatus::META_CONFIRM_TOKEN,
|
||||
RegistrationStatus::META_CONFIRM_EXPIRES,
|
||||
RegistrationStatus::META_AUTO_APPROVE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether a full purge has been asked for.
|
||||
*/
|
||||
public static function deletesDataOnUninstall(): bool {
|
||||
return '1' === Val::string( get_option( self::OPT_DELETE_DATA, '0' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the site owner's answer.
|
||||
*/
|
||||
public static function setDeletesDataOnUninstall( bool $delete ): void {
|
||||
update_option( self::OPT_DELETE_DATA, $delete ? '1' : '0' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the uninstall. Called from `uninstall.php`, which WordPress loads in
|
||||
* isolation once the plugin has been deleted.
|
||||
*/
|
||||
public function run(): void {
|
||||
$this->forgetCredentials();
|
||||
$this->restoreCoreRegistrationSettings();
|
||||
|
||||
// The event is cleared on deactivation too, which always precedes a
|
||||
// delete — repeated here because a site can be left with a stale schedule
|
||||
// if the plugin files went away without deactivating cleanly.
|
||||
wp_clear_scheduled_hook( ScheduledBillingRunner::HOOK );
|
||||
|
||||
if ( ! self::deletesDataOnUninstall() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dropTables();
|
||||
$this->deleteSettings();
|
||||
$this->deleteUserMeta();
|
||||
$this->removeRoles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the Stripe credentials, always. {@see StudioSettings::clearStripeConfig()}
|
||||
* is the same act from the settings page; it is not reused here because that
|
||||
* object belongs to a plugin that, by this point, is no longer loaded as one.
|
||||
*/
|
||||
private function forgetCredentials(): void {
|
||||
foreach ( self::CREDENTIAL_OPTIONS as $option ) {
|
||||
delete_option( $option );
|
||||
}
|
||||
|
||||
delete_transient( UpdateChecker::TRANSIENT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back the two core options open registration borrowed, from the snapshot
|
||||
* taken when it was switched on. Without this, deleting the plugin while open
|
||||
* registration is enabled leaves the site accepting public signups into a role
|
||||
* that is about to stop existing.
|
||||
*
|
||||
* Only acts when a snapshot exists, so a site that never enabled open
|
||||
* registration keeps its own settings untouched.
|
||||
*/
|
||||
private function restoreCoreRegistrationSettings(): void {
|
||||
$snapshot = get_option( StudioSettings::OPT_PREV_USERS_CAN_REGISTER, null );
|
||||
if ( null === $snapshot ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prevRole = Val::string( get_option( StudioSettings::OPT_PREV_DEFAULT_ROLE, 'subscriber' ) );
|
||||
|
||||
update_option( 'users_can_register', '1' === Val::string( $snapshot ) ? '1' : '0' );
|
||||
update_option( 'default_role', '' !== $prevRole ? $prevRole : 'subscriber' );
|
||||
|
||||
delete_option( StudioSettings::OPT_PREV_USERS_CAN_REGISTER );
|
||||
delete_option( StudioSettings::OPT_PREV_DEFAULT_ROLE );
|
||||
}
|
||||
|
||||
private function dropTables(): void {
|
||||
global $wpdb;
|
||||
if ( ! $wpdb instanceof \wpdb ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( Schema::TABLES as $table ) {
|
||||
$sql = $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table );
|
||||
|
||||
if ( null !== $sql ) {
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- $sql is the prepared statement two lines up; the sniff cannot follow it across the null guard, which PHPStan requires because prepare() is nullable. The only interpolated value is a Schema::TABLES constant.
|
||||
$wpdb->query( $sql );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteSettings(): void {
|
||||
foreach ( self::SETTING_OPTIONS as $option ) {
|
||||
delete_option( $option );
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUserMeta(): void {
|
||||
foreach ( self::USER_META as $key ) {
|
||||
delete_metadata( 'user', 0, $key, '', true );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the three roles the plugin adds. Only on a full purge: a site keeping
|
||||
* its data is keeping its students too, and a student whose role has been
|
||||
* deleted out from under them is a user with no capabilities at all until the
|
||||
* plugin is reinstalled.
|
||||
*/
|
||||
private function removeRoles(): void {
|
||||
foreach ( [ RoleManager::STUDIO_ADMIN, RoleManager::INSTRUCTOR, RoleManager::STUDENT ] as $role ) {
|
||||
remove_role( $role );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,8 +178,13 @@ class UpdateChecker {
|
||||
if ( ! is_array( $asset ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
|
||||
$package = Val::string( $asset['browser_download_url'] ?? '' );
|
||||
if ( ! str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = Val::string( $asset['browser_download_url'] ?? '' );
|
||||
if ( self::isTrustedPackage( $url ) ) {
|
||||
$package = $url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -194,4 +199,40 @@ class UpdateChecker {
|
||||
'package' => $package,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a release asset's URL may be handed to core as a plugin package.
|
||||
*
|
||||
* Core downloads whatever this returns and unpacks it over the installed
|
||||
* plugin, so the URL is executable code by another name. It is taken from a
|
||||
* JSON body, which means an answer that is not really the release server's —
|
||||
* a hijacked hostname, a tampered response, a compromised repo host handing
|
||||
* out a package hosted elsewhere — would otherwise install arbitrary code on
|
||||
* every site running this plugin, silently for anyone with auto-updates on.
|
||||
*
|
||||
* So the package has to come from the release host itself, over TLS: the
|
||||
* scheme is `https` and the parsed host equals {@see HOSTNAME} exactly. The
|
||||
* comparison is on the parsed host and not on the string, because a name that
|
||||
* merely *contains* the right one is the whole trick — `evil-git.unsupervised.ca`
|
||||
* would satisfy an endsWith check, `git.unsupervised.ca.attacker.test` a
|
||||
* startsWith one, and `https://[email protected]/x.zip` reads
|
||||
* like the real host to a person while resolving to someone else's.
|
||||
* Subdomains are refused too: nothing but the release host publishes releases.
|
||||
*
|
||||
* This cannot defend against the release host serving a bad zip of its own —
|
||||
* nothing here can — but it keeps the blast radius to that one host.
|
||||
*/
|
||||
private static function isTrustedPackage( string $url ): bool {
|
||||
if ( '' === $url ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = wp_parse_url( $url );
|
||||
if ( ! is_array( $parts ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'https' === strtolower( Val::string( $parts['scheme'] ?? '' ) )
|
||||
&& self::HOSTNAME === strtolower( Val::string( $parts['host'] ?? '' ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var bool $adminsAreStudioAdmins
|
||||
* @var bool $adminsAreInstructors
|
||||
* @var bool $adminsAreStudioAdmins
|
||||
* @var bool $adminsAreInstructors
|
||||
* @var bool $deleteDataOnUninstall
|
||||
* @var string $error Why the data-removal choice was refused, if it was.
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
@@ -19,6 +21,12 @@ if (! defined('ABSPATH')) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error !== '') : ?>
|
||||
<div class="notice notice-error inline">
|
||||
<p><?php echo esc_html($error); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_access_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="save">
|
||||
@@ -45,6 +53,50 @@ if (! defined('ABSPATH')) {
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Plugin removal', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description" style="max-width:46em;">
|
||||
<?php esc_html_e('Deleting a plugin happens on the Plugins screen, and WordPress gives it nothing to ask you at the time — so the answer is given here, in advance.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Always removed', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Your Stripe secret key and webhook signing secret are forgotten whenever this plugin is deleted, whatever you choose below. They can be pasted back in from the Stripe dashboard in a minute; leaving live keys on a site that no longer has the code to use them cannot be undone as easily. The two WordPress settings open registration borrows — whether anyone can register, and the role new users get — are put back as they were, too.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Studio records', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<fieldset>
|
||||
<label>
|
||||
<input type="checkbox" name="delete_data" value="1" <?php checked($deleteDataOnUninstall); ?>>
|
||||
<?php esc_html_e('Erase everything when the plugin is deleted', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Off by default: deleting the plugin leaves your lessons, enrolments, payments, credits, intake answers, policy agreements, invites and family links in the database, so reinstalling picks up exactly where you left off — and a deletion during a migration or a bit of troubleshooting costs you nothing.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<p class="description">
|
||||
<strong><?php esc_html_e('Turn it on and there is no way back.', 'unsupervised-schedular'); ?></strong>
|
||||
<?php esc_html_e('Every table this plugin made is dropped, every setting and student record it stored is deleted, and the Studio Admin, Instructor and Student roles are removed. WordPress does not ask twice and there is no undo. Take a database backup first if the studio has ever taken a payment.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<?php if (! $deleteDataOnUninstall) : ?>
|
||||
<p>
|
||||
<label for="usc-delete-confirm"><?php esc_html_e('To turn it on, type DELETE:', 'unsupervised-schedular'); ?></label>
|
||||
<input type="text" name="delete_data_confirm" id="usc-delete-confirm" value="" autocomplete="off" class="regular-text" style="max-width:12em;">
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Currently on. Untick the box and save to turn it off again.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php submit_button(esc_html__('Save Access Settings', 'unsupervised-schedular')); ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{enrollment_id: int, student: string, class: string, instructor: string, status: string, payment: string}|null $row
|
||||
* @var list<array{question: string, answer: string, source: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string, source: string}> $accepts
|
||||
* @var string $baseUrl
|
||||
* @var string $notice
|
||||
* @var string $error
|
||||
* @var 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>} $intake
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Enrolment details', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<p><a href="<?php echo esc_url($baseUrl); ?>">« <?php esc_html_e('Back to group classes', 'unsupervised-schedular'); ?></a></p>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ('' !== $error) : ?>
|
||||
<div class="notice notice-error"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (null === $row) : ?>
|
||||
<p><?php esc_html_e('This enrolment could not be found.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="form-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['class']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['payment']); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Policies accepted', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($accepts)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this enrolment.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('IP address', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('How it was given', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($accepts as $acceptance) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($acceptance['policy']); ?></td>
|
||||
<td><?php echo esc_html($acceptance['version']); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['accepted_at'] ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['ip'] ? $acceptance['ip'] : '—'); ?></td>
|
||||
<td><?php echo esc_html($acceptance['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($answers)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this enrolment.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('How it was given', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($answers as $answer) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($answer['question']); ?></td>
|
||||
<td><?php echo esc_html($answer['answer']); ?></td>
|
||||
<td><?php echo esc_html($answer['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($intake['recordable']) : ?>
|
||||
<h2><?php esc_html_e('Record intake collected elsewhere', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description" style="max-width:45em;">
|
||||
<?php esc_html_e('The studio enrolled this student, so they were never asked these questions online. Anything collected another way — on paper, in person, over the phone — can be entered here. Each entry is stamped with how it was collected and who entered it, and nothing already recorded can be overwritten.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
|
||||
<?php if (empty($intake['questions']) && empty($intake['policies'])) : ?>
|
||||
<p><?php esc_html_e('Everything has been recorded for this enrolment.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="record_intake">
|
||||
|
||||
<?php if (! empty($intake['policies'])) : ?>
|
||||
<h3><?php esc_html_e('Policies accepted elsewhere', 'unsupervised-schedular'); ?></h3>
|
||||
<?php foreach ($intake['policies'] as $policy) : ?>
|
||||
<p style="margin:0 0 6px;">
|
||||
<label>
|
||||
<input type="checkbox" name="accepted_policy_version_ids[]" value="<?php echo esc_attr((string) $policy['version_id']); ?>">
|
||||
<?php echo esc_html($policy['policy'] . ' (' . $policy['version'] . ')'); ?>
|
||||
</label>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
<p class="description"><?php esc_html_e('Tick only what the student actually agreed to. The acceptance is recorded in their name, against the version shown.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! empty($intake['questions'])) : ?>
|
||||
<h3><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h3>
|
||||
<table class="form-table" role="presentation">
|
||||
<?php foreach ($intake['questions'] as $question) : ?>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="usc-answer-<?php echo esc_attr((string) $question['id']); ?>">
|
||||
<?php echo esc_html($question['label']); ?>
|
||||
<?php if ($question['required']) : ?>
|
||||
<span class="description">(<?php esc_html_e('required of students', 'unsupervised-schedular'); ?>)</span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text" class="regular-text"
|
||||
id="usc-answer-<?php echo esc_attr((string) $question['id']); ?>"
|
||||
name="answers[<?php echo esc_attr((string) $question['id']); ?>]">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p class="description"><?php esc_html_e('Leave blank anything you do not have. You can come back and record the rest later.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e('How were these collected?', 'unsupervised-schedular'); ?></h3>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-collected-via"><?php esc_html_e('Collected', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="collected_via" id="usc-collected-via" required>
|
||||
<option value=""><?php esc_html_e('Choose one', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($intake['methods'] as $value => $label) : ?>
|
||||
<option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('Recorded against every entry below, so the audit trail never mistakes a transcription for something the student typed.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-collected-note"><?php esc_html_e('Details', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" class="regular-text" id="usc-collected-note" name="collected_note" maxlength="191">
|
||||
<p class="description"><?php esc_html_e('Optional — where the paper form is filed, who took the call. Required if you chose "Some other way".', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e('Record intake', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -7,9 +7,12 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
/**
|
||||
* @var array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, status: string, notes: string, payment_id: int, currency: string, total: float}|null $row
|
||||
* @var list<array{question: string, answer: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string}> $accepts
|
||||
* @var list<array{question: string, answer: string, source: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string, source: string}> $accepts
|
||||
* @var string $backUrl
|
||||
* @var string $notice
|
||||
* @var string $error
|
||||
* @var 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>} $intake
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
@@ -17,6 +20,14 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
<p><a href="<?php echo esc_url($backUrl); ?>">« <?php esc_html_e('Back to lessons', 'unsupervised-schedular'); ?></a></p>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ('' !== $error) : ?>
|
||||
<div class="notice notice-error"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (null === $row) : ?>
|
||||
<p><?php esc_html_e('This lesson could not be found.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
@@ -81,6 +92,7 @@ if (! defined('ABSPATH')) {
|
||||
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('IP address', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('How it was given', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -90,6 +102,7 @@ if (! defined('ABSPATH')) {
|
||||
<td><?php echo esc_html($acceptance['version']); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['accepted_at'] ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['ip'] ? $acceptance['ip'] : '—'); ?></td>
|
||||
<td><?php echo esc_html($acceptance['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -105,6 +118,7 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('How it was given', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -112,10 +126,90 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<td><?php echo esc_html($answer['question']); ?></td>
|
||||
<td><?php echo esc_html($answer['answer']); ?></td>
|
||||
<td><?php echo esc_html($answer['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($intake['recordable']) : ?>
|
||||
<h2><?php esc_html_e('Record intake collected elsewhere', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description" style="max-width:45em;">
|
||||
<?php esc_html_e('The studio booked this lesson, so the student was never asked these questions online. Anything collected another way — on paper, in person, over the phone — can be entered here. Each entry is stamped with how it was collected and who entered it, and nothing already recorded can be overwritten.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
|
||||
<?php if (empty($intake['questions']) && empty($intake['policies'])) : ?>
|
||||
<p><?php esc_html_e('Everything has been recorded for this booking.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_lesson_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="record_intake">
|
||||
|
||||
<?php if (! empty($intake['policies'])) : ?>
|
||||
<h3><?php esc_html_e('Policies accepted elsewhere', 'unsupervised-schedular'); ?></h3>
|
||||
<?php foreach ($intake['policies'] as $policy) : ?>
|
||||
<p style="margin:0 0 6px;">
|
||||
<label>
|
||||
<input type="checkbox" name="accepted_policy_version_ids[]" value="<?php echo esc_attr((string) $policy['version_id']); ?>">
|
||||
<?php echo esc_html($policy['policy'] . ' (' . $policy['version'] . ')'); ?>
|
||||
</label>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
<p class="description"><?php esc_html_e('Tick only what the student actually agreed to. The acceptance is recorded in their name, against the version shown.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! empty($intake['questions'])) : ?>
|
||||
<h3><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h3>
|
||||
<table class="form-table" role="presentation">
|
||||
<?php foreach ($intake['questions'] as $question) : ?>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="usc-answer-<?php echo esc_attr((string) $question['id']); ?>">
|
||||
<?php echo esc_html($question['label']); ?>
|
||||
<?php if ($question['required']) : ?>
|
||||
<span class="description">(<?php esc_html_e('required of students', 'unsupervised-schedular'); ?>)</span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text" class="regular-text"
|
||||
id="usc-answer-<?php echo esc_attr((string) $question['id']); ?>"
|
||||
name="answers[<?php echo esc_attr((string) $question['id']); ?>]">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p class="description"><?php esc_html_e('Leave blank anything you do not have. You can come back and record the rest later.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e('How were these collected?', 'unsupervised-schedular'); ?></h3>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-collected-via"><?php esc_html_e('Collected', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="collected_via" id="usc-collected-via" required>
|
||||
<option value=""><?php esc_html_e('Choose one', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($intake['methods'] as $value => $label) : ?>
|
||||
<option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('Recorded against every entry below, so the audit trail never mistakes a transcription for something the student typed.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-collected-note"><?php esc_html_e('Details', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" class="regular-text" id="usc-collected-note" name="collected_note" maxlength="191">
|
||||
<p class="description"><?php esc_html_e('Optional — where the paper form is filed, who took the call. Required if you chose "Some other way".', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e('Record intake', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,103 @@ if (! defined('ABSPATH')) {
|
||||
* @var string $prevWeek
|
||||
* @var string $nextWeek
|
||||
* @var string $baseUrl
|
||||
* @var string $notice
|
||||
* @var string $error
|
||||
* @var array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>} $bookForm
|
||||
* @var array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string} $bookValues
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ('' !== $error) : ?>
|
||||
<div class="notice notice-error"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<details class="usc-book-for-student" style="margin:12px 0;" <?php echo '' !== $error ? 'open' : ''; ?>>
|
||||
<summary style="cursor:pointer; font-weight:600;"><?php esc_html_e('Book a lesson for a student', 'unsupervised-schedular'); ?></summary>
|
||||
|
||||
<?php if (empty($bookForm['students']) || empty($bookForm['slots'])) : ?>
|
||||
<p class="description" style="margin-top:8px;">
|
||||
<?php
|
||||
echo empty($bookForm['students'])
|
||||
? esc_html__('There are no students to book for yet.', 'unsupervised-schedular')
|
||||
: esc_html__('There are no open times in the next eight weeks. Add availability first.', 'unsupervised-schedular');
|
||||
?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<form method="post" style="margin-top:8px;">
|
||||
<?php wp_nonce_field('usc_lesson_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="book_for_student">
|
||||
<p class="description">
|
||||
<?php esc_html_e('Books on the student\'s behalf, without the intake questions and policy agreements they would answer themselves.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-book-student"><?php esc_html_e('Student', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="student_id" id="usc-book-student" required>
|
||||
<option value=""><?php esc_html_e('Choose a student', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($bookForm['students'] as $student) : ?>
|
||||
<option value="<?php echo esc_attr((string) $student['id']); ?>" <?php selected($bookValues['student_id'], $student['id']); ?>><?php echo esc_html($student['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-book-slot"><?php esc_html_e('Time', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="slot_id" id="usc-book-slot" required style="max-width:100%;">
|
||||
<option value=""><?php esc_html_e('Choose an open time', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($bookForm['slots'] as $slot) : ?>
|
||||
<option value="<?php echo esc_attr((string) $slot['id']); ?>" <?php selected($bookValues['slot_id'], $slot['id']); ?>><?php echo esc_html($slot['label']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-book-offering"><?php esc_html_e('Lesson type', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="offering_id" id="usc-book-offering" style="max-width:100%;">
|
||||
<option value="0"><?php esc_html_e('Use the time\'s own lesson type', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($bookForm['offerings'] as $offering) : ?>
|
||||
<option value="<?php echo esc_attr((string) $offering['id']); ?>" <?php selected($bookValues['offering_id'], $offering['id']); ?>><?php echo esc_html($offering['label']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('A time already tied to a lesson type is booked as that type; a general time needs one chosen here.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Options', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" name="recurrence_weekly" value="1" <?php checked($bookValues['weekly']); ?>>
|
||||
<?php esc_html_e('Reserve this time weekly for the rest of the term', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<p class="description"><?php esc_html_e('Only for a time that repeats weekly. Billed upfront as one payment.', 'unsupervised-schedular'); ?></p>
|
||||
<label>
|
||||
<input type="checkbox" name="no_charge" value="1" <?php checked($bookValues['no_charge']); ?>>
|
||||
<?php esc_html_e('No charge — book it free and confirm it now', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<p class="description"><?php esc_html_e('For a make-up or goodwill lesson. Otherwise a pending payment is raised at the lesson type\'s price.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-book-notes"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></label></th>
|
||||
<td><input type="text" name="notes" id="usc-book-notes" class="regular-text" maxlength="500" value="<?php echo esc_attr($bookValues['notes']); ?>"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e('Book lesson', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</details>
|
||||
|
||||
<ul class="subsubsub" style="margin-bottom:12px;">
|
||||
<li>
|
||||
<a href="<?php echo esc_url($baseUrl); ?>" <?php echo 'week' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('Week', 'unsupervised-schedular'); ?></a> |
|
||||
|
||||
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
|
||||
* @var 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}>} $class
|
||||
* @var list<array{id: int, name: string}> $students
|
||||
* @var string $notice
|
||||
* @var string $baseUrl
|
||||
@@ -122,6 +122,7 @@ if (! defined('ABSPATH')) {
|
||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Intake', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -130,6 +131,9 @@ if (! defined('ABSPATH')) {
|
||||
<td><?php echo esc_html($entry['student']); ?></td>
|
||||
<td><?php echo esc_html($entry['status']); ?></td>
|
||||
<td><?php echo esc_html($entry['payment'] ?? '—'); ?></td>
|
||||
<td>
|
||||
<a href="<?php echo esc_url(add_query_arg('enrollment_id', (string) $entry['id'], $baseUrl)); ?>"><?php esc_html_e('View', 'unsupervised-schedular'); ?></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var string $subject
|
||||
* @var string $body
|
||||
* @var string $itemLine
|
||||
* @var array<string, string> $tokens
|
||||
* @var array<string, string> $itemTokens
|
||||
* @var string $previewNonce
|
||||
* @var string $previewUrl
|
||||
* @var array{subject: string, body: string} $preview
|
||||
* @var string $notice
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Payment Due Email', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success inline">
|
||||
<p><?php echo esc_html($notice); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e('This is the email a student receives when the daily billing scan finds payments due for them. Edit the subject and body below, then preview the result with sample values. Leave a field blank to use the built-in default.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
|
||||
<form method="post" id="usc-payment-email-form">
|
||||
<?php wp_nonce_field('usc_payment_email_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="save">
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-pe-subject"><?php esc_html_e('Subject', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" name="subject" id="usc-pe-subject" class="large-text" value="<?php echo esc_attr($subject); ?>">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-pe-body"><?php esc_html_e('Body', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<textarea name="body" id="usc-pe-body" class="large-text code" rows="10"><?php echo esc_textarea($body); ?></textarea>
|
||||
<p class="description"><?php esc_html_e('Available tokens:', 'unsupervised-schedular'); ?></p>
|
||||
<ul>
|
||||
<?php foreach ($tokens as $token => $description) : ?>
|
||||
<li><code><?php echo esc_html($token); ?></code> — <?php echo esc_html($description); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-pe-item-line"><?php esc_html_e('Item line', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" name="item_line" id="usc-pe-item-line" class="large-text code" value="<?php echo esc_attr($itemLine); ?>">
|
||||
<p class="description"><?php esc_html_e('The template for each charge in the {items} block. Available tokens:', 'unsupervised-schedular'); ?></p>
|
||||
<ul>
|
||||
<?php foreach ($itemTokens as $token => $description) : ?>
|
||||
<li><code><?php echo esc_html($token); ?></code> — <?php echo esc_html($description); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php submit_button(esc_html__('Save Template', 'unsupervised-schedular')); ?>
|
||||
</form>
|
||||
|
||||
<h2><?php esc_html_e('Reset to default', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description"><?php esc_html_e('Discards your custom subject, body and item line, restoring the built-in default template.', 'unsupervised-schedular'); ?></p>
|
||||
<form method="post" onsubmit="return confirm('<?php echo esc_js(esc_html__('Reset the payment due email to its default template?', 'unsupervised-schedular')); ?>');">
|
||||
<?php wp_nonce_field('usc_payment_email_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="reset">
|
||||
<?php submit_button(esc_html__('Reset to default', 'unsupervised-schedular'), 'delete', 'submit', true); ?>
|
||||
</form>
|
||||
|
||||
<h2><?php esc_html_e('Preview', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description"><?php esc_html_e('Rendered with sample values. Updates as you edit above.', 'unsupervised-schedular'); ?></p>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Subject', 'unsupervised-schedular'); ?></th>
|
||||
<td><strong id="usc-pe-preview-subject"><?php echo esc_html($preview['subject']); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Body', 'unsupervised-schedular'); ?></th>
|
||||
<td><pre id="usc-pe-preview-body" style="white-space:pre-wrap;background:#fff;border:1px solid #ccd0d4;padding:12px;margin:0;max-width:640px;"><?php echo esc_html($preview['body']); ?></pre></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
window.uscPaymentEmailPreview = {
|
||||
url: <?php echo wp_json_encode($previewUrl); ?>,
|
||||
nonce: <?php echo wp_json_encode($previewNonce); ?>
|
||||
};
|
||||
</script>
|
||||
@@ -15,6 +15,9 @@ if (! defined('ABSPATH')) {
|
||||
* @var string $etransferEmail
|
||||
* @var float $hstRate
|
||||
* @var bool $stripeConfigured
|
||||
* @var string $defaultMethod
|
||||
* @var bool $stripeAnySet
|
||||
* @var string $notice
|
||||
* @var bool $openRegistration
|
||||
* @var int $cancellationCutoffDays
|
||||
*/
|
||||
@@ -22,10 +25,18 @@ if (! defined('ABSPATH')) {
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Studio Settings', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success inline">
|
||||
<p><?php echo esc_html($notice); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="notice notice-info inline">
|
||||
<p>
|
||||
<?php if ($stripeConfigured) : ?>
|
||||
<?php if ($stripeConfigured && 'card' === $defaultMethod) : ?>
|
||||
<?php esc_html_e('Stripe is configured — new registrations default to credit-card billing.', 'unsupervised-schedular'); ?>
|
||||
<?php elseif ($stripeConfigured) : ?>
|
||||
<?php esc_html_e('Stripe is configured, but the studio default is e-transfer — only students you switch to credit card individually are billed by card.', 'unsupervised-schedular'); ?>
|
||||
<?php else : ?>
|
||||
<?php esc_html_e('Stripe is not configured — new registrations default to e-transfer, which a studio admin marks paid on receipt. Add your Stripe keys below to enable card billing.', 'unsupervised-schedular'); ?>
|
||||
<?php endif; ?>
|
||||
@@ -80,6 +91,31 @@ if (! defined('ABSPATH')) {
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Default payment method', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<fieldset>
|
||||
<label>
|
||||
<input type="radio" name="default_payment_method" value="card" <?php checked($defaultMethod, 'card'); ?>>
|
||||
<?php esc_html_e('Credit card (requires Stripe)', 'unsupervised-schedular'); ?>
|
||||
</label><br>
|
||||
<label>
|
||||
<input type="radio" name="default_payment_method" value="etransfer" <?php checked($defaultMethod, 'etransfer'); ?>>
|
||||
<?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Applies to every student without their own billing method. Keep this on e-transfer while you trial card payments: switch individual students to Credit card under Students → student detail → Billing method, confirm their bookings charge correctly, then move the whole studio over by changing this setting.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<?php if (! $stripeConfigured) : ?>
|
||||
<p class="description"><?php esc_html_e('Credit card has no effect until Stripe keys are saved above — until then every student is billed by e-transfer.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
@@ -124,4 +160,16 @@ if (! defined('ABSPATH')) {
|
||||
</table>
|
||||
<?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?>
|
||||
</form>
|
||||
|
||||
<?php if ($stripeAnySet) : ?>
|
||||
<h2><?php esc_html_e('Clear Stripe configuration', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Forgets the publishable key, secret key and webhook signing secret, and returns the mode to Test. Billing falls back to e-transfer until Stripe is set up again; payments already recorded are untouched.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<form method="post" onsubmit="return confirm('<?php echo esc_js(esc_html__('Clear the stored Stripe keys? Card billing stops until you enter them again.', 'unsupervised-schedular')); ?>');">
|
||||
<?php wp_nonce_field('usc_settings_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="clear_stripe">
|
||||
<?php submit_button(esc_html__('Clear Stripe configuration', 'unsupervised-schedular'), 'delete', 'submit', true); ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +114,39 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
||||
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
/*
|
||||
* Surface the credit balance up top the moment there is one, so the studio
|
||||
* sees at a glance that this student is owed against future billing without
|
||||
* scrolling to the Account credit section. Only shown when there is credit to
|
||||
* report — a zero balance is not news. The full breakdown stays below.
|
||||
*/
|
||||
?>
|
||||
<?php if ($canBilling && $creditBalance > 0) : ?>
|
||||
<div class="notice notice-info inline">
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: total available credit, e.g. "45.00 CAD" */
|
||||
esc_html__('Total account credit: %s', 'unsupervised-schedular'),
|
||||
'<strong>' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '</strong>'
|
||||
);
|
||||
?>
|
||||
<?php if ($payer['id'] !== (int) $student->ID) : ?>
|
||||
<span class="description">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: name of the parent/guardian whose account holds the balance. */
|
||||
esc_html__('Held on %s’s account.', 'unsupervised-schedular'),
|
||||
esc_html($payer['name'])
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
|
||||
|
||||
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
||||
|
||||
+1
-12
@@ -1,14 +1,3 @@
|
||||
# Writing tests
|
||||
|
||||
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
|
||||
See `../../AGENTS.md` (Tests section) — it is the single source of truth for working in this repo.
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
use Unsupervised\Schedular\Uninstaller;
|
||||
|
||||
class AccessSettingsTest extends TestCase
|
||||
{
|
||||
@@ -31,4 +32,108 @@ class AccessSettingsTest extends TestCase
|
||||
self::assertFalse($settings->adminsAreStudioAdmins());
|
||||
self::assertTrue($settings->adminsAreInstructors());
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page with the given options stored and the given form posted,
|
||||
* returning the options as they end up.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
* @param array<string, mixed> $post
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function submit(array $options, array $post): array
|
||||
{
|
||||
// A regular closure, not an arrow fn: arrow functions capture by value,
|
||||
// so reads after the save would still see the options as they were.
|
||||
Functions\when('get_option')->alias(
|
||||
static function (string $name, mixed $default = false) use (&$options): mixed {
|
||||
return $options[$name] ?? $default;
|
||||
}
|
||||
);
|
||||
Functions\when('update_option')->alias(
|
||||
static function (string $name, mixed $value) use (&$options): bool {
|
||||
$options[$name] = $value;
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
// The typed confirmation is read through sanitize_key, so "Delete",
|
||||
// " delete " and "DELETE" all arrive here as the same word.
|
||||
Functions\when('sanitize_key')->alias(
|
||||
static fn($v): string => (string) preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $v))
|
||||
);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('submit_button')->justReturn('');
|
||||
|
||||
$_POST = $post + ['usc_action' => 'save'];
|
||||
|
||||
ob_start();
|
||||
(new AccessSettings())->renderPage();
|
||||
ob_end_clean();
|
||||
|
||||
$_POST = [];
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erasing the studio's records cannot be undone, and a checkbox is one stray
|
||||
* click. The tick alone must not be enough.
|
||||
*/
|
||||
public function testTickingDataRemovalWithoutTypingTheWordDoesNotEnableIt(): void
|
||||
{
|
||||
$options = $this->submit(
|
||||
[Uninstaller::OPT_DELETE_DATA => '0'],
|
||||
['delete_data' => '1', 'grant_studio' => '1']
|
||||
);
|
||||
|
||||
self::assertSame('0', $options[Uninstaller::OPT_DELETE_DATA]);
|
||||
// The rest of the page still saved: a mistyped confirmation must not
|
||||
// silently swallow a capability change made in the same submit.
|
||||
self::assertSame('1', $options[AccessSettings::OPT_GRANT_STUDIO]);
|
||||
}
|
||||
|
||||
public function testTickAndTypedConfirmationTogetherEnableIt(): void
|
||||
{
|
||||
$options = $this->submit(
|
||||
[Uninstaller::OPT_DELETE_DATA => '0'],
|
||||
['delete_data' => '1', 'delete_data_confirm' => ' DELETE ']
|
||||
);
|
||||
|
||||
self::assertSame('1', $options[Uninstaller::OPT_DELETE_DATA]);
|
||||
}
|
||||
|
||||
public function testANearMissDoesNotCount(): void
|
||||
{
|
||||
$options = $this->submit(
|
||||
[Uninstaller::OPT_DELETE_DATA => '0'],
|
||||
['delete_data' => '1', 'delete_data_confirm' => 'delete everything']
|
||||
);
|
||||
|
||||
self::assertSame('0', $options[Uninstaller::OPT_DELETE_DATA]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Once it is on, saving the page for some other reason must not turn it off
|
||||
* — nor demand the word again for a setting already made.
|
||||
*/
|
||||
public function testSavingOtherSettingsLeavesAnEnabledChoiceAlone(): void
|
||||
{
|
||||
$options = $this->submit(
|
||||
[Uninstaller::OPT_DELETE_DATA => '1'],
|
||||
['delete_data' => '1', 'grant_instructor' => '1']
|
||||
);
|
||||
|
||||
self::assertSame('1', $options[Uninstaller::OPT_DELETE_DATA]);
|
||||
}
|
||||
|
||||
public function testUntickingTurnsItOffWithNoCeremony(): void
|
||||
{
|
||||
$options = $this->submit([Uninstaller::OPT_DELETE_DATA => '1'], ['grant_studio' => '1']);
|
||||
|
||||
self::assertSame('0', $options[Uninstaller::OPT_DELETE_DATA]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class LoginPageTest extends TestCase
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($_POST['us_login']);
|
||||
unset($_POST['us_login'], $_POST['log'], $_POST['pwd']);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
@@ -91,4 +91,37 @@ class LoginPageTest extends TestCase
|
||||
|
||||
self::assertSame('https://example.com/book/', $this->page->bookingUrl(9));
|
||||
}
|
||||
|
||||
/**
|
||||
* wp_signon()'s second argument decides whether the auth cookie carries the
|
||||
* Secure flag, and *only* its default (the empty string) makes it work that
|
||||
* out from is_ssl(). Passing an explicit false — which reads like "no
|
||||
* preference" and is not — issues the plain, non-Secure cookie on an HTTPS
|
||||
* site, leaving every student's session to leak over the first http://
|
||||
* request to the domain. So the call must pass the credentials and nothing
|
||||
* else, which is what this asserts: a second argument of any kind fails it.
|
||||
*/
|
||||
public function testSignOnDoesNotOverrideWordPressSecureCookieDetection(): void
|
||||
{
|
||||
$_POST['us_login'] = '1';
|
||||
$_POST['log'] = '[email protected]';
|
||||
$_POST['pwd'] = 'hunter2';
|
||||
|
||||
Functions\when('is_user_logged_in')->justReturn(false);
|
||||
Functions\when('get_permalink')->justReturn('https://example.com/login/');
|
||||
Functions\when('sanitize_url')->returnArg();
|
||||
Functions\when('sanitize_user')->returnArg();
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
// The failure branch, so the render returns instead of redirecting and
|
||||
// exiting the test process.
|
||||
Functions\when('is_wp_error')->justReturn(true);
|
||||
|
||||
Functions\expect('wp_signon')->once()->with(\Mockery::type('array'))->andReturn(null);
|
||||
|
||||
$html = $this->page->render([]);
|
||||
|
||||
self::assertStringContainsString('Invalid username or password.', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +115,62 @@ class RegistrationLoginGateTest extends TestCase
|
||||
|
||||
self::assertSame($allcaps, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a user to the `user_register` guard: which role they hold, and
|
||||
* whether whoever created them is studio staff.
|
||||
*/
|
||||
private function stubSignup(string $role, bool $byStaff): void
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->roles = [$role];
|
||||
|
||||
Functions\when('get_userdata')->justReturn($user);
|
||||
Functions\when('current_user_can')->justReturn($byStaff);
|
||||
}
|
||||
|
||||
public function testStudentCreatedByAnUnknownSignupFormIsHeldForApproval(): void
|
||||
{
|
||||
// Open registration switches the site's own users_can_register on and
|
||||
// makes Student the default role, so any other signup form on the site
|
||||
// now mints students. They must not arrive able to book.
|
||||
$this->stubSignup(RoleManager::STUDENT, byStaff: false);
|
||||
|
||||
Functions\expect('update_user_meta')->once()
|
||||
->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1');
|
||||
// Counted as confirmed: nobody asked them to confirm, and there is no
|
||||
// token for them to answer with, so blocking the login would strand them.
|
||||
Functions\expect('update_user_meta')->once()
|
||||
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED, '1');
|
||||
|
||||
(new RegistrationLoginGate())->holdUnknownSignup(7);
|
||||
}
|
||||
|
||||
public function testStudentCreatedByStaffIsLeftActive(): void
|
||||
{
|
||||
// An administrator adding a student from wp-admin could have approved
|
||||
// them in the next click; making them do so is ceremony.
|
||||
$this->stubSignup(RoleManager::STUDENT, byStaff: true);
|
||||
|
||||
Functions\expect('update_user_meta')->never();
|
||||
|
||||
(new RegistrationLoginGate())->holdUnknownSignup(7);
|
||||
}
|
||||
|
||||
public function testNonStudentSignupIsNotHeld(): void
|
||||
{
|
||||
$this->stubSignup(RoleManager::INSTRUCTOR, byStaff: false);
|
||||
|
||||
Functions\expect('update_user_meta')->never();
|
||||
|
||||
(new RegistrationLoginGate())->holdUnknownSignup(7);
|
||||
}
|
||||
|
||||
public function testUnknownUserIdIsIgnored(): void
|
||||
{
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
Functions\expect('update_user_meta')->never();
|
||||
|
||||
(new RegistrationLoginGate())->holdUnknownSignup(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Unsupervised\Schedular\Auth\Invite;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
@@ -29,6 +30,9 @@ class RegistrationPageTest extends TestCase
|
||||
/** @var array<string, mixed> */
|
||||
private array $ctx;
|
||||
|
||||
/** @var list<string> User meta keys cleared during the submit under test. */
|
||||
private array $clearedMeta = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -52,6 +56,20 @@ class RegistrationPageTest extends TestCase
|
||||
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||
Functions\when('wp_localize_script')->justReturn(true);
|
||||
|
||||
// Both success branches clear pending meta on the account they just
|
||||
// created — the invited student is approved outright, the self-signup is
|
||||
// marked unconfirmed. Recorded rather than counted so a test can say
|
||||
// which, without every other test having to expect the calls.
|
||||
$this->clearedMeta = [];
|
||||
$cleared = &$this->clearedMeta;
|
||||
Functions\when('delete_user_meta')->alias(
|
||||
static function (int $userId, string $key) use (&$cleared): bool {
|
||||
$cleared[] = $key;
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
$invites = Mockery::mock(InviteRepository::class);
|
||||
$policies = Mockery::mock(PolicyRepository::class);
|
||||
$questions = Mockery::mock(QuestionRepository::class);
|
||||
@@ -149,6 +167,24 @@ class RegistrationPageTest extends TestCase
|
||||
self::assertSame('invite', $this->submit($invite, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration gate holds every student account created by an
|
||||
* unauthenticated request, which is what a signup is — so the invite branch
|
||||
* has to say that this one is different. Without it an invited student is
|
||||
* logged straight in and then told they cannot book.
|
||||
*/
|
||||
public function testInvitedStudentIsApprovedRatherThanLeftAwaitingApproval(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
|
||||
|
||||
$this->stubInviteSuccess();
|
||||
|
||||
$invite = new Invite(email: '[email protected]', token: 'hash');
|
||||
|
||||
self::assertSame('invite', $this->submit($invite, false));
|
||||
self::assertContains(RegistrationStatus::META_AWAITING_APPROVAL, $this->clearedMeta);
|
||||
}
|
||||
|
||||
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
|
||||
|
||||
@@ -22,6 +22,12 @@ class RegistrationStatusTest extends TestCase
|
||||
Functions\expect('update_user_meta')
|
||||
->once()
|
||||
->with(7, RegistrationStatus::META_CONFIRM_EXPIRES, \Mockery::type('string'));
|
||||
// Explicitly unconfirmed: the account may already have been held (and so
|
||||
// counted as confirmed) by the user_register guard, and this signup did
|
||||
// ask for a confirmation, so it has to be waited for.
|
||||
Functions\expect('delete_user_meta')
|
||||
->once()
|
||||
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED);
|
||||
|
||||
self::assertSame('rawtoken', RegistrationStatus::markPending(7));
|
||||
}
|
||||
@@ -63,10 +69,34 @@ class RegistrationStatusTest extends TestCase
|
||||
Functions\expect('update_user_meta')
|
||||
->once()
|
||||
->with(7, RegistrationStatus::META_AUTO_APPROVE, '1');
|
||||
Functions\expect('delete_user_meta')
|
||||
->once()
|
||||
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED);
|
||||
|
||||
self::assertSame('rawtoken', RegistrationStatus::markPending(7, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* The hold put on a student account that turned up without going through the
|
||||
* studio's signup form. Awaiting approval — so the booking capability is
|
||||
* withheld — but counted as email-confirmed, because no confirmation was ever
|
||||
* asked for and there is no token to answer with: blocking the login instead
|
||||
* would strand the account with no way forward.
|
||||
*/
|
||||
public function testHoldMarksAwaitingApprovalWithoutDemandingAConfirmationThatWasNeverSent(): void
|
||||
{
|
||||
Functions\expect('update_user_meta')
|
||||
->once()
|
||||
->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1');
|
||||
Functions\expect('update_user_meta')
|
||||
->once()
|
||||
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED, '1');
|
||||
// No token is issued, so nothing is generated and nothing can be spent.
|
||||
Functions\expect('wp_generate_password')->never();
|
||||
|
||||
RegistrationStatus::hold(7);
|
||||
}
|
||||
|
||||
public function testIsAutoApproveReadsMeta(): void
|
||||
{
|
||||
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {
|
||||
|
||||
@@ -155,4 +155,48 @@ class RoleManagerTest extends TestCase
|
||||
|
||||
(new RoleManager())->createRoles();
|
||||
}
|
||||
|
||||
/**
|
||||
* The predicate every staff-side "register this person" path shares. It is
|
||||
* deliberately the role and not `book_lesson`, so the two accounts that have
|
||||
* that capability withheld — a guardian's child and an unapproved signup — are
|
||||
* still people the studio can act for.
|
||||
*/
|
||||
public function testIsStudentAcceptsAnyHolderOfTheStudentRole(): void
|
||||
{
|
||||
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::STUDENT]));
|
||||
|
||||
self::assertTrue(RoleManager::isStudent(5));
|
||||
}
|
||||
|
||||
public function testIsStudentRejectsSomeoneWhoIsNotAStudent(): void
|
||||
{
|
||||
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::INSTRUCTOR]));
|
||||
|
||||
self::assertFalse(RoleManager::isStudent(5));
|
||||
}
|
||||
|
||||
public function testIsStudentRejectsAnAccountThatNoLongerExists(): void
|
||||
{
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
|
||||
self::assertFalse(RoleManager::isStudent(5));
|
||||
}
|
||||
|
||||
public function testIsStudentRejectsNoOneChosenWithoutLookingAnyoneUp(): void
|
||||
{
|
||||
Functions\expect('get_userdata')->never();
|
||||
|
||||
self::assertFalse(RoleManager::isStudent(0));
|
||||
}
|
||||
|
||||
/** @param list<string> $roles */
|
||||
private function userWithRoles(array $roles): \WP_User
|
||||
{
|
||||
$user = \Mockery::mock(\WP_User::class);
|
||||
$user->ID = 5;
|
||||
$user->roles = $roles;
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersResolveQuestionLabels(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', 1),
|
||||
new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findById')->with(4)
|
||||
->andReturn(new Question(1, 'Experience level', id: 4));
|
||||
@@ -120,7 +120,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersFallBackWhenQuestionIsGone(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, null, 1),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, null, id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findById')->with(4)->andReturn(null);
|
||||
|
||||
@@ -133,8 +133,8 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersExcludeAccountScopeAnswers(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', 2),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', 1),
|
||||
new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', id: 2),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', id: 1),
|
||||
]);
|
||||
// Only the booking-scoped answer is resolved; the account answer is dropped.
|
||||
$this->questions->shouldReceive('findById')->with(4)
|
||||
@@ -150,7 +150,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testRegistrationInfoPairsAccountQuestionsWithAnswers(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_ACCOUNT, 5)->andReturn([
|
||||
new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', 1),
|
||||
new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findByScope')->once()->with(Question::SCOPE_ACCOUNT)->andReturn([
|
||||
new Question(null, 'Consent to email', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 4),
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\AdminBooking;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
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\Tests\Unit\TestCase;
|
||||
|
||||
class AdminBookingTest extends TestCase
|
||||
{
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private BookingRepository&Mockery\MockInterface $bookings;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private PaymentService&Mockery\MockInterface $payments;
|
||||
private GuardianService&Mockery\MockInterface $guardians;
|
||||
private AdminBooking $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
|
||||
Functions\when('mysql2date')->alias(
|
||||
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
|
||||
);
|
||||
// The picker offers holders of the student role, and that is what the guard
|
||||
// accepts; it is exercised on its own below.
|
||||
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe'));
|
||||
Functions\when('get_users')->justReturn([]);
|
||||
// The staff member doing the booking; stamped on the lesson as booked_by.
|
||||
Functions\when('get_current_user_id')->justReturn(3);
|
||||
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
// A charge raised at booking has the payer's credit applied before it
|
||||
// settles. The default holds no balance and re-reads the same payment.
|
||||
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
|
||||
$this->payments->shouldReceive('findPayment')->andReturnUsing(
|
||||
static fn (int $id): ?Payment => null
|
||||
)->byDefault();
|
||||
$this->guardians = Mockery::mock(GuardianService::class);
|
||||
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
|
||||
|
||||
// The real booker over mocked repositories: an admin booking must go
|
||||
// through exactly the machinery a student's own booking does.
|
||||
$this->admin = new AdminBooking(
|
||||
$this->availability,
|
||||
$this->offerings,
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians)
|
||||
);
|
||||
}
|
||||
|
||||
public function testBooksASingleLessonAndRaisesAPendingPayment(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
|
||||
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Lesson $l): bool => 7 === $l->slotId
|
||||
&& 42 === $l->studentId
|
||||
&& 9 === $l->instructorId
|
||||
&& 3 === $l->offeringId
|
||||
&& Lesson::RECURRENCE_SINGLE === $l->recurrence
|
||||
&& 'Booked by phone' === $l->notes
|
||||
// Stamped with who booked it, which is what later lets the studio
|
||||
// record the intake it never had a chance to collect.
|
||||
&& 3 === $l->bookedBy
|
||||
))->andReturn(100);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 100, 42, 9, 40.0, 'CAD', null, null, null, 42)
|
||||
->andReturn($this->pendingPayment());
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, 'Booked by phone');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('30 min piano', $notice);
|
||||
self::assertStringContainsString('Jul 1, 2026 10:00 AM', $notice);
|
||||
self::assertStringContainsString('pending payment', $notice);
|
||||
}
|
||||
|
||||
public function testNoChargeSkipsThePaymentAndConfirmsTheLesson(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(100);
|
||||
|
||||
// The whole point of the no-charge tick: a priced offering raises nothing.
|
||||
$this->payments->shouldReceive('createForRegistration')->never();
|
||||
$this->bookings->shouldReceive('updateStatus')->once()->with(100, Lesson::STATUS_CONFIRMED)->andReturn(true);
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, true, '');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('Nothing is owed', $notice);
|
||||
}
|
||||
|
||||
public function testWeeklyReservesEveryRemainingOccurrenceAndBillsForAllOfThem(): void
|
||||
{
|
||||
$slot = $this->slot(recurrenceGroup: 55);
|
||||
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('findUnbookedInGroup')->once()->with(55)->andReturn([
|
||||
$slot,
|
||||
$this->slot(id: 8, startDt: '2026-07-08 10:00:00', recurrenceGroup: 55),
|
||||
$this->slot(id: 9, startDt: '2026-07-15 10:00:00', recurrenceGroup: 55),
|
||||
]);
|
||||
$this->availability->shouldReceive('claim')->times(3)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insertSeries')->once()
|
||||
->with(Mockery::type(Lesson::class), [7, 8, 9])
|
||||
->andReturn([100, 101, 102]);
|
||||
|
||||
// A per-lesson (one_time) price is owed once per occurrence claimed.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 100, 42, 9, 120.0, 'CAD', null, null, null, 42)
|
||||
->andReturn($this->pendingPayment());
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, '');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('3 weekly lessons', $notice);
|
||||
}
|
||||
|
||||
public function testWeeklyIsRefusedOnATimeThatDoesNotRepeat(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
|
||||
// Nothing is claimed or written: the staff member asked for a term and is
|
||||
// told they cannot have one, rather than silently getting one lesson.
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
$this->bookings->shouldReceive('insert')->never();
|
||||
$this->bookings->shouldReceive('insertSeries')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('not_weekly', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testInstructorScopeRefusesAnotherInstructorsTime(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
// Slot belongs to instructor 9; My Lessons is scoped to instructor 4.
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '', 4);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_slot', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testAnAlreadyBookedTimeIsRefused(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(isBooked: true));
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('slot_taken', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testSomeoneWhoIsNotAStudentIsRefused(): void
|
||||
{
|
||||
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe', [RoleManager::INSTRUCTOR]));
|
||||
$this->availability->shouldReceive('findById')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_student', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testAnAccountThatNoLongerExistsIsRefused(): void
|
||||
{
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
$this->availability->shouldReceive('findById')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_student', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testNoStudentChosenIsRefusedWithoutLookingAnyoneUp(): void
|
||||
{
|
||||
Functions\expect('get_userdata')->never();
|
||||
$this->availability->shouldReceive('findById')->never();
|
||||
|
||||
$result = $this->admin->book(0, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_student', $result->get_error_code());
|
||||
}
|
||||
|
||||
/**
|
||||
* A child holds the student role but never `book_lesson` — withheld so the
|
||||
* account cannot book in its own name. The studio booking for them is the only
|
||||
* route a child has to a lesson, so it must not be blocked by that.
|
||||
*/
|
||||
public function testBooksForAGuardiansChildWhoCannotBookThemselves(): void
|
||||
{
|
||||
Functions\when('user_can')->justReturn(false);
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(100);
|
||||
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('30 min piano', $notice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same for a self-signup the studio has not approved yet: the front desk can
|
||||
* still get them onto the calendar while the paperwork catches up.
|
||||
*/
|
||||
public function testBooksForAStudentStillAwaitingApproval(): void
|
||||
{
|
||||
Functions\when('user_can')->justReturn(false);
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Lesson $l): bool => 42 === $l->studentId
|
||||
))->andReturn(100);
|
||||
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('pending payment', $notice);
|
||||
}
|
||||
|
||||
public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 4, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('offering_mismatch', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testATiedTimeBooksAsItsOwnLessonTypeWhenNoneIsChosen(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Lesson $l): bool => 3 === $l->offeringId
|
||||
))->andReturn(100);
|
||||
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
||||
|
||||
self::assertIsString($this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, ''));
|
||||
}
|
||||
|
||||
public function testAGeneralTimeNeedsALessonTypeChosen(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('offering_required', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testFormDataScopesTimesAndTypesToOneInstructorAndLeavesTheirNameOff(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findAvailable')
|
||||
->once()
|
||||
->with(9, 0, 0, '', '2026-07-27 10:00:00')
|
||||
->andReturn([$this->slot(recurrenceGroup: 55)]);
|
||||
$this->offerings->shouldReceive('findAll')
|
||||
->once()
|
||||
->with(9, Offering::KIND_PRIVATE_LESSON, true)
|
||||
->andReturn([$this->offering()]);
|
||||
|
||||
$data = $this->admin->formData(9);
|
||||
|
||||
self::assertSame([['id' => 3, 'label' => '30 min piano (30 min)']], $data['offerings']);
|
||||
self::assertSame(1, count($data['slots']));
|
||||
self::assertTrue($data['slots'][0]['weekly']);
|
||||
self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $data['slots'][0]['label']);
|
||||
self::assertStringContainsString('repeats weekly', $data['slots'][0]['label']);
|
||||
}
|
||||
|
||||
public function testStudioWideFormDataNamesTheInstructorAndTheTimesTiedLessonType(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findAvailable')->once()->andReturn([$this->slot(offeringId: 3)]);
|
||||
$this->offerings->shouldReceive('findAll')->once()->with(0, Offering::KIND_PRIVATE_LESSON, true)->andReturn([]);
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
Functions\when('get_userdata')->justReturn($this->user(9, 'Jane Doe'));
|
||||
|
||||
$data = $this->admin->formData(0);
|
||||
|
||||
self::assertStringContainsString('Jane Doe', $data['slots'][0]['label']);
|
||||
self::assertStringContainsString('30 min piano', $data['slots'][0]['label']);
|
||||
}
|
||||
|
||||
private function slot(
|
||||
int $id = 7,
|
||||
string $startDt = '2026-07-01 10:00:00',
|
||||
bool $isBooked = false,
|
||||
?int $offeringId = null,
|
||||
?int $recurrenceGroup = null
|
||||
): AvailabilitySlot {
|
||||
return new AvailabilitySlot(
|
||||
instructorId: 9,
|
||||
startDt: $startDt,
|
||||
endDt: date('Y-m-d H:i:s', (int) strtotime($startDt) + 1800),
|
||||
durationMinutes: 30,
|
||||
offeringId: $offeringId,
|
||||
isBooked: $isBooked,
|
||||
recurrenceGroup: $recurrenceGroup,
|
||||
id: $id,
|
||||
);
|
||||
}
|
||||
|
||||
private function offering(): Offering
|
||||
{
|
||||
return new Offering(
|
||||
instructorId: 9,
|
||||
kind: Offering::KIND_PRIVATE_LESSON,
|
||||
title: '30 min piano',
|
||||
price: 40.0,
|
||||
durationMinutes: 30,
|
||||
isActive: true,
|
||||
id: 3,
|
||||
);
|
||||
}
|
||||
|
||||
private function pendingPayment(): Payment
|
||||
{
|
||||
return new Payment(
|
||||
studentId: 42,
|
||||
instructorId: 9,
|
||||
registrationType: Payment::REG_LESSON,
|
||||
registrationId: 100,
|
||||
amount: 40.0,
|
||||
status: Payment::STATUS_PENDING,
|
||||
id: 500,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<string> $roles */
|
||||
private function user(int $id, string $name, array $roles = [RoleManager::STUDENT]): \WP_User
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->ID = $id;
|
||||
$user->roles = $roles;
|
||||
$user->first_name = '';
|
||||
$user->last_name = '';
|
||||
$user->nickname = $name;
|
||||
$user->display_name = $name;
|
||||
$user->user_login = 'jane';
|
||||
$user->user_email = '[email protected]';
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
@@ -56,6 +57,13 @@ class BookingEndpointTest extends TestCase
|
||||
// Crediting a cancelled paid lesson is exercised in dedicated tests; other
|
||||
// cancellation paths simply allow the call.
|
||||
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
|
||||
// A charge raised at booking applies the payer's credit before it settles.
|
||||
// The default holds no balance and re-reads the same payment; the
|
||||
// same-month-rebook test overrides both.
|
||||
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
|
||||
$this->payments->shouldReceive('findPayment')->andReturnUsing(
|
||||
static fn (int $id): ?Payment => null
|
||||
)->byDefault();
|
||||
|
||||
$this->guardians = Mockery::mock(GuardianService::class);
|
||||
// The default account books only for itself: no guardian link anywhere.
|
||||
@@ -76,6 +84,10 @@ class BookingEndpointTest extends TestCase
|
||||
$this->offerings,
|
||||
$this->gate,
|
||||
$this->payments,
|
||||
// The real booker over the same mocked repositories: these tests are
|
||||
// about what a booking does end to end, and the booker is where most
|
||||
// of that now lives.
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians),
|
||||
new CancellationPolicy($this->settings),
|
||||
$this->guardians,
|
||||
$this->sessions,
|
||||
@@ -458,6 +470,42 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertNotNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testMonthlyRebookInBilledMonthAppliesAccountCredit(): void
|
||||
{
|
||||
// Rebooking a cancelled monthly lesson inside an already-billed month is
|
||||
// charged at booking. The payer holds a cancellation credit that must be
|
||||
// applied to that charge — otherwise the family is billed twice for the same
|
||||
// slot. A credit that fully covers it settles the payment and confirms the
|
||||
// lesson, so the front end runs no payment step.
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn(
|
||||
new AvailabilitySlot(instructorId: 3, startDt: '2026-06-20 10:00:00', endDt: '2026-06-20 11:00:00', offeringId: null, id: 10)
|
||||
);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
|
||||
$pending = new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12);
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null, null, null, 5)
|
||||
->andReturn($pending);
|
||||
|
||||
// Credit is applied against the fresh charge for the same payer.
|
||||
$this->payments->shouldReceive('applyCredits')->once()->with(5, [$pending])->andReturn([12 => 45.0]);
|
||||
// applyCredits settled the row; the re-read reflects it as paid.
|
||||
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, creditApplied: 45.0, id: 12);
|
||||
$this->payments->shouldReceive('findPayment')->with(12)->andReturn($paid);
|
||||
|
||||
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
}
|
||||
|
||||
public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
|
||||
{
|
||||
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,
|
||||
@@ -563,9 +611,11 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
|
||||
}
|
||||
|
||||
public function testCancelByAnotherStudentIsForbidden(): void
|
||||
public function testCancelByAnotherStudentAnswersExactlyLikeAnUnknownLesson(): void
|
||||
{
|
||||
// Lesson belongs to student 9; current user is 5.
|
||||
// Lesson belongs to student 9; current user is 5. The refusal must be
|
||||
// indistinguishable from testCancelUnknownLessonReturns404 below, or the
|
||||
// pair of answers tells a student which lesson ids exist.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 3, status: Lesson::STATUS_PENDING, id: 77);
|
||||
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
|
||||
$this->bookings->shouldNotReceive('updateStatus');
|
||||
@@ -574,7 +624,8 @@ class BookingEndpointTest extends TestCase
|
||||
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('forbidden', $result->get_error_code());
|
||||
self::assertSame('not_found', $result->get_error_code());
|
||||
self::assertSame(404, $result->error_data['not_found']['status']);
|
||||
}
|
||||
|
||||
public function testCancelUnknownLessonReturns404(): void
|
||||
@@ -585,6 +636,7 @@ class BookingEndpointTest extends TestCase
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('not_found', $result->get_error_code());
|
||||
self::assertSame(404, $result->error_data['not_found']['status']);
|
||||
}
|
||||
|
||||
public function testCancelAlreadyCancelledLessonIsIdempotent(): void
|
||||
|
||||
@@ -36,9 +36,11 @@ class BookingRepositoryTest extends TestCase
|
||||
&& $data['student_id'] === 5
|
||||
&& $data['offering_id'] === 7
|
||||
&& $data['recurrence'] === Lesson::RECURRENCE_SINGLE
|
||||
&& $data['status'] === Lesson::STATUS_PENDING;
|
||||
&& $data['status'] === Lesson::STATUS_PENDING
|
||||
// Booked through the student-facing flow: no staff booker.
|
||||
&& $data['booked_by'] === 0;
|
||||
}),
|
||||
['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s']
|
||||
['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s']
|
||||
);
|
||||
|
||||
$this->db->insert_id = 77;
|
||||
|
||||
@@ -9,8 +9,11 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\Booking\AdminBooking;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
@@ -21,7 +24,9 @@ class LessonControllerTest extends TestCase
|
||||
private PaymentRepository&Mockery\MockInterface $payments;
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private LessonDetail&Mockery\MockInterface $detail;
|
||||
private IntakeAudit&Mockery\MockInterface $detail;
|
||||
private AdminBooking&Mockery\MockInterface $adminBooking;
|
||||
private IntakeRecording&Mockery\MockInterface $intake;
|
||||
private LessonController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -32,8 +37,17 @@ class LessonControllerTest extends TestCase
|
||||
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->detail = Mockery::mock(LessonDetail::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail);
|
||||
$this->detail = Mockery::mock(IntakeAudit::class);
|
||||
$this->adminBooking = Mockery::mock(AdminBooking::class);
|
||||
// The book-for-a-student panel has its own tests; here it is an empty form.
|
||||
$this->adminBooking->shouldReceive('formData')
|
||||
->andReturn(['students' => [], 'offerings' => [], 'slots' => []])->byDefault();
|
||||
$this->intake = Mockery::mock(IntakeRecording::class);
|
||||
// Most lessons here were booked by the student, so nothing is recordable;
|
||||
// the intake tests set up their own staff-booked lesson.
|
||||
$this->intake->shouldReceive('pending')
|
||||
->andReturn(['questions' => [], 'policies' => []])->byDefault();
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail, $this->adminBooking, $this->intake);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
@@ -50,6 +64,17 @@ class LessonControllerTest extends TestCase
|
||||
Functions\when('current_time')->justReturn('2026-07-06');
|
||||
Functions\when('admin_url')->alias(static fn (string $path) => 'https://example.test/wp-admin/' . $path);
|
||||
Functions\when('add_query_arg')->alias(static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// The form-post tests fill $_POST; left behind it makes every later test
|
||||
// in the suite look like a form submission.
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAdminDashboardShowsSlotDateTimeInsteadOfSlotId(): void
|
||||
@@ -236,10 +261,10 @@ class LessonControllerTest extends TestCase
|
||||
// The lesson itself is handed over, so the presenter can follow a series
|
||||
// occurrence back to the anchor its answers and acceptances hang off.
|
||||
$this->detail->shouldReceive('answers')->once()->with($lesson)->andReturn([
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'],
|
||||
]);
|
||||
$this->detail->shouldReceive('acceptances')->once()->with($lesson)->andReturn([
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4', 'source' => 'Given online when booking'],
|
||||
]);
|
||||
|
||||
// The list of lessons must never be queried when routing to a detail view.
|
||||
@@ -274,6 +299,273 @@ class LessonControllerTest extends TestCase
|
||||
self::assertStringNotContainsString('Skill level', $html);
|
||||
}
|
||||
|
||||
public function testTheBookForAStudentPanelOffersTheOpenTimesAndStudents(): void
|
||||
{
|
||||
$this->adminBooking->shouldReceive('formData')->once()->with(0)->andReturn([
|
||||
'students' => [['id' => 42, 'name' => 'Ada Lovelace']],
|
||||
'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']],
|
||||
'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]],
|
||||
]);
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Book a lesson for a student', $html);
|
||||
self::assertStringContainsString('Ada Lovelace', $html);
|
||||
self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $html);
|
||||
self::assertStringContainsString('name="usc_action" value="book_for_student"', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal used to clear all five fields, so one mistake meant retyping the
|
||||
* whole form — and the panel is only ever reopened *because* something was
|
||||
* refused.
|
||||
*/
|
||||
public function testARefusedBookingComesBackWithEveryFieldStillFilledIn(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
$this->offerPanel();
|
||||
|
||||
$this->adminBooking->shouldReceive('book')->once()->andReturn(
|
||||
new \WP_Error('slot_taken', 'That time has already been booked.')
|
||||
);
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('That time has already been booked.', $html);
|
||||
// Attribute spacing in the template is not what is under test here.
|
||||
$tags = (string) preg_replace('/\s+/', ' ', $html);
|
||||
|
||||
// The panel is reopened, showing the student, time and lesson type as posted.
|
||||
self::assertStringContainsString(' open>', $html);
|
||||
self::assertStringContainsString('value="42" selected=\'selected\'', $tags);
|
||||
self::assertStringContainsString('value="7" selected=\'selected\'', $tags);
|
||||
self::assertStringContainsString('value="3" selected=\'selected\'', $tags);
|
||||
// Both ticks and the note survive too.
|
||||
self::assertSame(2, substr_count($html, "checked='checked'"));
|
||||
self::assertStringContainsString('value="Make-up lesson"', $html);
|
||||
}
|
||||
|
||||
public function testASuccessfulBookingLeavesAnEmptyFormForTheNextOne(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
$this->offerPanel();
|
||||
|
||||
$this->adminBooking->shouldReceive('book')->once()->andReturn('Booked.');
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Booked.', $html);
|
||||
// Nothing carried over, or the next booking silently inherits this one's.
|
||||
self::assertStringNotContainsString("selected='selected'", $html);
|
||||
self::assertStringNotContainsString("checked='checked'", $html);
|
||||
self::assertStringContainsString('value=""', $html);
|
||||
}
|
||||
|
||||
public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
|
||||
// Scope 0: the studio Scheduler may book any instructor's open time.
|
||||
$this->adminBooking->shouldReceive('book')
|
||||
->once()
|
||||
->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 0)
|
||||
->andReturn('Booked Ada Lovelace into 30 min piano.');
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Booked Ada Lovelace into 30 min piano.', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
self::assertStringNotContainsString(' open>', $html);
|
||||
}
|
||||
|
||||
public function testAnInstructorBooksOnlyAgainstTheirOwnTimes(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
Functions\when('get_current_user_id')->justReturn(9);
|
||||
|
||||
// Scope 9: My Lessons must not reach another instructor's schedule.
|
||||
$this->adminBooking->shouldReceive('book')
|
||||
->once()
|
||||
->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 9)
|
||||
->andReturn('Booked.');
|
||||
$this->adminBooking->shouldReceive('formData')->once()->with(9)->andReturn(
|
||||
['students' => [], 'offerings' => [], 'slots' => []]
|
||||
);
|
||||
$this->bookings->shouldReceive('findUpcomingForInstructor')->once()->with(9)->andReturn([]);
|
||||
|
||||
ob_start();
|
||||
$this->controller->renderInstructorLessons();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('Booked.', $html);
|
||||
}
|
||||
|
||||
public function testARefusedBookingShowsWhyAndReopensTheForm(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
|
||||
$this->adminBooking->shouldReceive('book')->once()
|
||||
->andReturn(new \WP_Error('slot_taken', 'That time has already been booked.'));
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('That time has already been booked.', $html);
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
// The panel is a collapsed <details>; an error opens it so the message is
|
||||
// not hidden behind the summary.
|
||||
self::assertStringContainsString(' open>', $html);
|
||||
}
|
||||
|
||||
/** Fill $_POST as the book-for-a-student form does. */
|
||||
private function postBooking(): void
|
||||
{
|
||||
$_POST = [
|
||||
'usc_action' => 'book_for_student',
|
||||
'student_id' => '42',
|
||||
'slot_id' => '7',
|
||||
'offering_id' => '3',
|
||||
'recurrence_weekly' => '1',
|
||||
'no_charge' => '1',
|
||||
'notes' => 'Make-up lesson',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
}
|
||||
|
||||
/** The panel with one of each choice, so a re-selected value has somewhere to land. */
|
||||
private function offerPanel(): void
|
||||
{
|
||||
$this->adminBooking->shouldReceive('formData')->once()->andReturn([
|
||||
'students' => [['id' => 42, 'name' => 'Ada Lovelace']],
|
||||
'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']],
|
||||
'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
|
||||
// booked_by 7: the studio booked this one, so its intake can be recorded.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
|
||||
$this->intake->shouldReceive('pending')->once()->with($lesson)->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => true]],
|
||||
'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']],
|
||||
]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Record intake collected elsewhere', $html);
|
||||
self::assertStringContainsString('Anything we should know?', $html);
|
||||
self::assertStringContainsString('Cancellation', $html);
|
||||
self::assertStringContainsString('How were these collected?', $html);
|
||||
self::assertStringContainsString('On a signed paper form', $html);
|
||||
}
|
||||
|
||||
public function testALessonTheStudentBookedOffersNoRecordingForm(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
|
||||
// booked_by 0: the student booked it and gave their own answers.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
|
||||
// Not even asked what is outstanding — the form is not on offer at all.
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $this->render());
|
||||
}
|
||||
|
||||
public function testSubmittedIntakeIsRecordedAndReported(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'accepted_policy_version_ids' => ['6'],
|
||||
'collected_via' => 'paper',
|
||||
'collected_note' => 'Filed in the studio binder',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
$this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]);
|
||||
|
||||
$this->intake->shouldReceive('record')
|
||||
->once()
|
||||
->with($lesson, [9 => 'Nut allergy'], [6], 'paper', 'Filed in the studio binder', 7)
|
||||
->andReturn('Recorded 1 answer and 1 policy acceptance, collected: On a signed paper form');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
// Nothing left outstanding, so the form gives way to a plain statement.
|
||||
self::assertStringContainsString('Everything has been recorded for this booking.', $html);
|
||||
}
|
||||
|
||||
public function testARefusedRecordingSaysWhy(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'collected_via' => 'other',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
$this->intake->shouldReceive('pending')->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]],
|
||||
'policies' => [],
|
||||
]);
|
||||
$this->intake->shouldReceive('record')->once()
|
||||
->andReturn(new \WP_Error('collection_note_required', 'Say how these were collected.'));
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Say how these were collected.', $html);
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
}
|
||||
|
||||
/** The lookups the detail view makes for one lesson, with an empty audit trail. */
|
||||
private function expectDetail(Lesson $lesson): void
|
||||
{
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(null);
|
||||
$this->detail->shouldReceive('answers')->with($lesson)->andReturn([]);
|
||||
$this->detail->shouldReceive('acceptances')->with($lesson)->andReturn([]);
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class LessonTest extends TestCase
|
||||
@@ -76,4 +77,21 @@ class LessonTest extends TestCase
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesSharesOneIntakeRegistration(): void
|
||||
{
|
||||
// Occurrence 12 of a series anchored on lesson 7: answered for once.
|
||||
$occurrence = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, seriesId: 7, id: 12);
|
||||
|
||||
self::assertSame(Answer::REG_LESSON, $occurrence->intakeRegistrationType());
|
||||
self::assertSame(7, $occurrence->intakeRegistrationId());
|
||||
// A single lesson is its own registration.
|
||||
self::assertSame(12, (new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 12))->intakeRegistrationId());
|
||||
}
|
||||
|
||||
public function testOnlyAStudioBookedLessonIsStaffRegistered(): void
|
||||
{
|
||||
self::assertFalse((new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 12))->isStaffRegistered());
|
||||
self::assertTrue((new Lesson(slotId: 10, studentId: 5, instructorId: 3, bookedBy: 9, id: 12))->isStaffRegistered());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,17 +224,19 @@ class EnrollmentEndpointTest extends TestCase
|
||||
self::assertSame(403, $result->error_data['withdrawal_closed']['status']);
|
||||
}
|
||||
|
||||
public function testWithdrawRejectsAnotherStudentsEnrolment(): void
|
||||
public function testWithdrawAnswersAnotherStudentsEnrolmentExactlyLikeAnUnknownOne(): void
|
||||
{
|
||||
// Enrolment belongs to student 9, but the caller is student 5.
|
||||
// Enrolment belongs to student 9, but the caller is student 5. The refusal
|
||||
// must match testWithdrawReturnsNotFoundForUnknownEnrolment below exactly,
|
||||
// or the two answers together enumerate the studio's enrolments.
|
||||
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 9, 3, Enrollment::STATUS_ACTIVE, 41, 3));
|
||||
$this->enrollments->shouldReceive('updateStatus')->never();
|
||||
|
||||
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('forbidden', $result->get_error_code());
|
||||
self::assertSame(403, $result->error_data['forbidden']['status']);
|
||||
self::assertSame('not_found', $result->get_error_code());
|
||||
self::assertSame(404, $result->error_data['not_found']['status']);
|
||||
}
|
||||
|
||||
public function testWithdrawReturnsNotFoundForUnknownEnrolment(): void
|
||||
|
||||
@@ -35,9 +35,11 @@ class EnrollmentRepositoryTest extends TestCase
|
||||
return $d['offering_id'] === 7
|
||||
&& $d['student_id'] === 5
|
||||
&& $d['instructor_id'] === 3
|
||||
&& $d['status'] === Enrollment::STATUS_ACTIVE;
|
||||
&& $d['status'] === Enrollment::STATUS_ACTIVE
|
||||
// Enrolled through the student-facing flow: no staff enroller.
|
||||
&& $d['enrolled_by'] === 0;
|
||||
}),
|
||||
['%d', '%d', '%d', '%s', '%d', '%s']
|
||||
['%d', '%d', '%d', '%s', '%d', '%d', '%s']
|
||||
);
|
||||
$this->db->insert_id = 12;
|
||||
|
||||
@@ -108,6 +110,35 @@ class EnrollmentRepositoryTest extends TestCase
|
||||
self::assertInstanceOf(Enrollment::class, $all[0]);
|
||||
}
|
||||
|
||||
public function testFindActiveByOfferingReturnsActiveEnrolmentsOldestFirst(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::pattern('/offering_id = %d AND status = %s ORDER BY id ASC/'),
|
||||
'wp_us_group_enrollments',
|
||||
9,
|
||||
Enrollment::STATUS_ACTIVE
|
||||
)
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$this->db->shouldReceive('get_results')->andReturn([
|
||||
(object) [
|
||||
'id' => '7',
|
||||
'offering_id' => '9',
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'status' => Enrollment::STATUS_ACTIVE,
|
||||
'payment_id' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$found = $this->repo->findActiveByOffering(9);
|
||||
|
||||
self::assertCount(1, $found);
|
||||
self::assertSame(7, $found[0]->id);
|
||||
}
|
||||
|
||||
public function testFindActiveByBillingModesJoinsOfferingAndFiltersModes(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class EnrollmentTest extends TestCase
|
||||
@@ -45,8 +46,25 @@ class EnrollmentTest extends TestCase
|
||||
{
|
||||
$arr = (new Enrollment(7, 5, 3, id: 12))->toArray();
|
||||
|
||||
foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id'] as $key) {
|
||||
foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id', 'enrolled_by'] as $key) {
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnEnrolmentIsItsOwnIntakeRegistration(): void
|
||||
{
|
||||
$enrollment = new Enrollment(7, 5, 3, id: 12);
|
||||
|
||||
self::assertSame(Answer::REG_ENROLLMENT, $enrollment->intakeRegistrationType());
|
||||
// No series anchor to follow: a term of classes is one enrolment.
|
||||
self::assertSame(12, $enrollment->intakeRegistrationId());
|
||||
self::assertSame(7, $enrollment->intakeOfferingId());
|
||||
self::assertSame(5, $enrollment->intakeStudentId());
|
||||
}
|
||||
|
||||
public function testOnlyAStudioMadeEnrolmentIsStaffRegistered(): void
|
||||
{
|
||||
self::assertFalse((new Enrollment(7, 5, 3, id: 12))->isStaffRegistered());
|
||||
self::assertTrue((new Enrollment(7, 5, 3, enrolledBy: 9, id: 12))->isStaffRegistered());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
@@ -16,6 +17,8 @@ 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\IntakeRecording;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class GroupClassControllerTest extends TestCase
|
||||
@@ -27,6 +30,8 @@ class GroupClassControllerTest extends TestCase
|
||||
private PaymentService&Mockery\MockInterface $paymentService;
|
||||
private InviteRepository&Mockery\MockInterface $invites;
|
||||
private RegistrationMailer&Mockery\MockInterface $mailer;
|
||||
private IntakeAudit&Mockery\MockInterface $audit;
|
||||
private IntakeRecording&Mockery\MockInterface $intake;
|
||||
private GroupClassController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -40,6 +45,8 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->paymentService = Mockery::mock(PaymentService::class);
|
||||
$this->invites = Mockery::mock(InviteRepository::class);
|
||||
$this->mailer = Mockery::mock(RegistrationMailer::class);
|
||||
$this->audit = Mockery::mock(IntakeAudit::class);
|
||||
$this->intake = Mockery::mock(IntakeRecording::class);
|
||||
$this->controller = new GroupClassController(
|
||||
$this->enrollments,
|
||||
$this->offerings,
|
||||
@@ -48,6 +55,8 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->paymentService,
|
||||
$this->invites,
|
||||
$this->mailer,
|
||||
$this->audit,
|
||||
$this->intake,
|
||||
);
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
@@ -79,7 +88,8 @@ class GroupClassControllerTest extends TestCase
|
||||
{
|
||||
[$first, $last] = array_pad(explode(' ', $full, 2), 2, '');
|
||||
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->roles = [RoleManager::STUDENT];
|
||||
$user->first_name = $first;
|
||||
$user->last_name = $last;
|
||||
$user->nickname = $full;
|
||||
@@ -398,6 +408,8 @@ class GroupClassControllerTest extends TestCase
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('sanitize_email')->returnArg();
|
||||
Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
|
||||
// Every posted id is vetted as a student before it is enrolled or granted.
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
|
||||
// Render tail: no classes/enrolments to draw so the assertion targets the notice.
|
||||
$this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]);
|
||||
@@ -411,7 +423,11 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
|
||||
// Stamped with the staff member who added them (user 3), which is what
|
||||
// later lets the studio record the intake it never had a chance to ask for.
|
||||
$this->enrollments->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Enrollment $e): bool => 3 === $e->enrolledBy && $e->isStaffRegistered()
|
||||
))->andReturn(44);
|
||||
|
||||
$payment = new Payment(
|
||||
studentId: 5,
|
||||
@@ -435,6 +451,161 @@ class GroupClassControllerTest extends TestCase
|
||||
self::assertStringContainsString('1 student(s) added to the class.', $html);
|
||||
}
|
||||
|
||||
public function testEnrollmentIdOpensTheIntakeDetailView(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
|
||||
// enrolled_by 3: the studio added this student, so intake can be recorded.
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
|
||||
$this->intake->shouldReceive('pending')->once()->with($enrollment)->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]],
|
||||
'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']],
|
||||
]);
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('Enrolment details', $html);
|
||||
self::assertStringContainsString('Ada Lovelace', $html);
|
||||
self::assertStringContainsString('Record intake collected elsewhere', $html);
|
||||
self::assertStringContainsString('Anything we should know?', $html);
|
||||
self::assertStringContainsString('How were these collected?', $html);
|
||||
}
|
||||
|
||||
public function testAnEnrolmentTheStudentMadeOffersNoRecordingForm(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
|
||||
// enrolled_by 0: the student enrolled themselves and gave their own answers.
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $this->renderInstructor());
|
||||
}
|
||||
|
||||
public function testAnInstructorCannotOpenAnotherInstructorsEnrolment(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
|
||||
// Enrolment belongs to instructor 9; the current user is 3.
|
||||
$this->enrollments->shouldReceive('findById')->once()->with(44)
|
||||
->andReturn(new Enrollment(offeringId: 8, studentId: 5, instructorId: 9, enrolledBy: 9, id: 44));
|
||||
$this->audit->shouldNotReceive('answers');
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('This enrolment could not be found.', $html);
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $html);
|
||||
}
|
||||
|
||||
public function testSubmittedEnrolmentIntakeIsRecordedAndReported(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'accepted_policy_version_ids' => ['6'],
|
||||
'collected_via' => 'phone',
|
||||
'collected_note' => 'Called the parent',
|
||||
];
|
||||
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
$this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]);
|
||||
|
||||
$this->intake->shouldReceive('record')
|
||||
->once()
|
||||
->with($enrollment, [9 => 'Nut allergy'], [6], 'phone', 'Called the parent', 3)
|
||||
->andReturn('Recorded 1 answer and 1 policy acceptance, collected: Over the phone');
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
// The generic class-form handler must not also run and report a missing class.
|
||||
self::assertStringNotContainsString('That group class was not found.', $html);
|
||||
}
|
||||
|
||||
/** The lookups the enrolment detail view makes, with an empty audit trail. */
|
||||
private function expectEnrollmentDetail(Enrollment $enrollment): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('findById')->once()->with(44)->andReturn($enrollment);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(8, 'Choir', 10));
|
||||
$this->audit->shouldReceive('answers')->with($enrollment)->andReturn([]);
|
||||
$this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The multi-select is built from the studio's students, but a posted id is just
|
||||
* a number: it could name an instructor, an administrator, or an account
|
||||
* deleted since the page was drawn. Enrolling one would put a non-student on
|
||||
* the roster and raise a payment against them.
|
||||
*/
|
||||
public function testAddDirectIgnoresAnIdThatIsNotAStudent(): void
|
||||
{
|
||||
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
|
||||
$this->stubActionContext();
|
||||
|
||||
$instructor = Mockery::mock(\WP_User::class);
|
||||
$instructor->roles = [RoleManager::INSTRUCTOR];
|
||||
Functions\when('get_userdata')->justReturn($instructor);
|
||||
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
|
||||
$this->enrollments->shouldReceive('insert')->never();
|
||||
$this->paymentService->shouldReceive('createForRegistration')->never();
|
||||
$this->access->shouldReceive('markEnrolled')->never();
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('0 student(s) added to the class.', $html);
|
||||
}
|
||||
|
||||
public function testAddDirectIgnoresAnAccountThatNoLongerExists(): void
|
||||
{
|
||||
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
|
||||
$this->stubActionContext();
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
|
||||
$this->enrollments->shouldReceive('insert')->never();
|
||||
$this->paymentService->shouldReceive('createForRegistration')->never();
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('0 student(s) added to the class.', $html);
|
||||
}
|
||||
|
||||
public function testGrantAccessIgnoresAnIdThatIsNotAStudent(): void
|
||||
{
|
||||
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
|
||||
$this->stubActionContext();
|
||||
|
||||
$instructor = Mockery::mock(\WP_User::class);
|
||||
$instructor->roles = [RoleManager::INSTRUCTOR];
|
||||
Functions\when('get_userdata')->justReturn($instructor);
|
||||
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
|
||||
$this->access->shouldReceive('insert')->never();
|
||||
$this->mailer->shouldReceive('sendClassAccessGranted')->never();
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('0 student(s) granted access.', $html);
|
||||
}
|
||||
|
||||
public function testGrantAccessCreatesGrantAndEmailsStudent(): void
|
||||
{
|
||||
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
|
||||
@@ -445,7 +616,8 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false);
|
||||
$this->access->shouldReceive('insert')->once()->andReturn(1);
|
||||
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->roles = [RoleManager::STUDENT];
|
||||
$user->user_email = '[email protected]';
|
||||
Functions\when('get_userdata')->justReturn($user);
|
||||
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Guardian;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianLink;
|
||||
@@ -96,6 +97,33 @@ class GuardianServiceTest extends TestCase
|
||||
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A child is a student account created by someone who is not staff, so the
|
||||
* registration gate holds it on `user_register` exactly as it holds an
|
||||
* anonymous signup. There is nothing here to approve — the account is never
|
||||
* signed in to, and the guardian in front of us is the approval — so the
|
||||
* hold has to come off, or every child a family adds lands in the studio's
|
||||
* review queue.
|
||||
*/
|
||||
public function testCreateChildClearsTheHoldTheRegistrationGatePutsOnIt(): void
|
||||
{
|
||||
// Standing in for the user_register hook, which has already run by the
|
||||
// time wp_insert_user() returns.
|
||||
$meta = &$this->meta;
|
||||
Functions\when('wp_insert_user')->alias(
|
||||
static function (array $args) use (&$meta): int {
|
||||
$meta[42][RegistrationStatus::META_AWAITING_APPROVAL] = '1';
|
||||
|
||||
return 42;
|
||||
}
|
||||
);
|
||||
|
||||
$this->guardians->shouldReceive('insert')->once()->andReturn(7);
|
||||
|
||||
self::assertSame(42, $this->service->createChild(5, 'Ada', '2015'));
|
||||
self::assertArrayNotHasKey(RegistrationStatus::META_AWAITING_APPROVAL, $this->meta[42]);
|
||||
}
|
||||
|
||||
public function testCreateChildRejectsABlankName(): void
|
||||
{
|
||||
Functions\expect('wp_insert_user')->never();
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Offering;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\BillingModeReconciler;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class BillingModeReconcilerTest extends TestCase
|
||||
{
|
||||
private EnrollmentRepository&Mockery\MockInterface $enrollments;
|
||||
private PaymentRepository&Mockery\MockInterface $payments;
|
||||
private BillingModeReconciler $reconciler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Functions\when('current_time')->justReturn('2026-09-15 09:00:00');
|
||||
|
||||
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||
$this->reconciler = new BillingModeReconciler($this->enrollments, $this->payments);
|
||||
}
|
||||
|
||||
private function offering(string $billingMode, ?int $id = 9): Offering
|
||||
{
|
||||
return new Offering(
|
||||
instructorId: 3,
|
||||
kind: Offering::KIND_GROUP_CLASS,
|
||||
title: 'Ensemble',
|
||||
price: 150.0,
|
||||
billingMode: $billingMode,
|
||||
id: $id,
|
||||
);
|
||||
}
|
||||
|
||||
private function enrollment(int $id, int $studentId = 5): Enrollment
|
||||
{
|
||||
return new Enrollment(offeringId: 9, studentId: $studentId, instructorId: 3, id: $id);
|
||||
}
|
||||
|
||||
public function testAdoptsUpfrontChargeIntoCurrentMonthWhenClassBecomesMonthly(): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]);
|
||||
|
||||
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true);
|
||||
$this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(false);
|
||||
|
||||
// The up-front charge is stamped into 2026-09, due on the 1st, so the scan
|
||||
// treats September as already billed for this enrolment.
|
||||
$this->payments->shouldReceive('claimPeriodForUnscheduled')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01')
|
||||
->andReturn(1);
|
||||
|
||||
self::assertSame(1, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY)));
|
||||
}
|
||||
|
||||
public function testDoesNothingWhenBillingModeDidNotChangeIntoMonthly(): void
|
||||
{
|
||||
$this->enrollments->shouldNotReceive('findActiveByOffering');
|
||||
$this->payments->shouldNotReceive('claimPeriodForUnscheduled');
|
||||
|
||||
// Already monthly -> monthly (e.g. an unrelated title edit): no transition.
|
||||
self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_MONTHLY), $this->offering(Offering::BILLING_MONTHLY)));
|
||||
}
|
||||
|
||||
public function testDoesNothingWhenSwitchingToWeekly(): void
|
||||
{
|
||||
$this->enrollments->shouldNotReceive('findActiveByOffering');
|
||||
$this->payments->shouldNotReceive('claimPeriodForUnscheduled');
|
||||
|
||||
// Weekly bills per session as sessions come due; there is no single upfront
|
||||
// period an enrolment charge maps onto, so it is left alone.
|
||||
self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_WEEKLY)));
|
||||
}
|
||||
|
||||
public function testSkipsEnrolmentWithoutAnUpfrontCharge(): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]);
|
||||
|
||||
// Enrolled after the class was already scheduled, or never charged upfront:
|
||||
// nothing to adopt.
|
||||
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(false);
|
||||
$this->payments->shouldNotReceive('claimPeriodForUnscheduled');
|
||||
|
||||
self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY)));
|
||||
}
|
||||
|
||||
public function testSkipsEnrolmentAlreadyBilledForThisMonthByTheScan(): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]);
|
||||
|
||||
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true);
|
||||
// The scan already made a 2026-09 charge: adopting the upfront one too would
|
||||
// leave two charges for the month — the very thing we are preventing.
|
||||
$this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(true);
|
||||
$this->payments->shouldNotReceive('claimPeriodForUnscheduled');
|
||||
|
||||
self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY)));
|
||||
}
|
||||
|
||||
public function testReconcilesEveryActiveEnrolment(): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('findActiveByOffering')->with(9)
|
||||
->andReturn([$this->enrollment(7, 5), $this->enrollment(8, 6)]);
|
||||
|
||||
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true);
|
||||
$this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(false);
|
||||
$this->payments->shouldReceive('claimPeriodForUnscheduled')->with(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01')->andReturn(1);
|
||||
|
||||
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 8)->andReturn(true);
|
||||
$this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 8, '2026-09')->andReturn(false);
|
||||
$this->payments->shouldReceive('claimPeriodForUnscheduled')->with(Payment::REG_ENROLLMENT, 8, '2026-09', '2026-09-01')->andReturn(1);
|
||||
|
||||
self::assertSame(2, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY)));
|
||||
}
|
||||
|
||||
public function testIgnoresNonGroupClassOfferings(): void
|
||||
{
|
||||
$before = new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano', billingMode: Offering::BILLING_ONE_TIME, id: 9);
|
||||
$after = new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano', billingMode: Offering::BILLING_MONTHLY, id: 9);
|
||||
|
||||
$this->enrollments->shouldNotReceive('findActiveByOffering');
|
||||
|
||||
self::assertSame(0, $this->reconciler->reconcile($before, $after));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Offering;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Offering\BillingModeReconciler;
|
||||
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingController;
|
||||
@@ -15,6 +16,7 @@ class OfferingControllerTest extends TestCase
|
||||
{
|
||||
private OfferingRepository&Mockery\MockInterface $repository;
|
||||
private ClassSlotReconciler&Mockery\MockInterface $reconciler;
|
||||
private BillingModeReconciler&Mockery\MockInterface $billingModeReconciler;
|
||||
private OfferingController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -24,7 +26,9 @@ class OfferingControllerTest extends TestCase
|
||||
$this->repository = Mockery::mock(OfferingRepository::class);
|
||||
$this->reconciler = Mockery::mock(ClassSlotReconciler::class);
|
||||
$this->reconciler->shouldReceive('reconcile')->andReturn(['removed' => 0, 'conflicts' => []])->byDefault();
|
||||
$this->controller = new OfferingController($this->repository, $this->reconciler);
|
||||
$this->billingModeReconciler = Mockery::mock(BillingModeReconciler::class);
|
||||
$this->billingModeReconciler->shouldReceive('reconcile')->andReturn(0)->byDefault();
|
||||
$this->controller = new OfferingController($this->repository, $this->reconciler, $this->billingModeReconciler);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
|
||||
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Offering\BillingModeReconciler;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
@@ -16,6 +17,7 @@ class OfferingEndpointTest extends TestCase
|
||||
{
|
||||
private OfferingRepository&Mockery\MockInterface $repository;
|
||||
private GroupAccessRepository&Mockery\MockInterface $access;
|
||||
private BillingModeReconciler&Mockery\MockInterface $billingModeReconciler;
|
||||
private OfferingEndpoint $endpoint;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -27,7 +29,9 @@ class OfferingEndpointTest extends TestCase
|
||||
|
||||
$this->repository = Mockery::mock(OfferingRepository::class);
|
||||
$this->access = Mockery::mock(GroupAccessRepository::class);
|
||||
$this->endpoint = new OfferingEndpoint($this->repository, $this->access);
|
||||
$this->billingModeReconciler = Mockery::mock(BillingModeReconciler::class);
|
||||
$this->billingModeReconciler->shouldReceive('reconcile')->andReturn(0)->byDefault();
|
||||
$this->endpoint = new OfferingEndpoint($this->repository, $this->access, $this->billingModeReconciler);
|
||||
}
|
||||
|
||||
private function group(int $id, string $access): Offering
|
||||
|
||||
@@ -22,13 +22,13 @@ class BillingMethodResolverTest extends TestCase
|
||||
self::assertSame(Payment::METHOD_COMP, $resolver->resolve(5));
|
||||
}
|
||||
|
||||
public function testDefaultsToCardWhenStripeConfigured(): void
|
||||
public function testDefaultsToCardWhenStripeConfiguredAndCardChosen(): void
|
||||
{
|
||||
Functions\when('get_user_meta')->justReturn('');
|
||||
|
||||
$settings = Mockery::mock(StudioSettings::class);
|
||||
$settings->shouldReceive('isStripeConfigured')->andReturn(true);
|
||||
$resolver = new BillingMethodResolver($settings);
|
||||
$resolver = new BillingMethodResolver(
|
||||
$this->settings(Payment::METHOD_CARD, true)
|
||||
);
|
||||
|
||||
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
|
||||
}
|
||||
@@ -37,22 +37,56 @@ class BillingMethodResolverTest extends TestCase
|
||||
{
|
||||
Functions\when('get_user_meta')->justReturn('');
|
||||
|
||||
$settings = Mockery::mock(StudioSettings::class);
|
||||
$settings->shouldReceive('isStripeConfigured')->andReturn(false);
|
||||
$resolver = new BillingMethodResolver($settings);
|
||||
$resolver = new BillingMethodResolver(
|
||||
$this->settings(Payment::METHOD_CARD, false)
|
||||
);
|
||||
|
||||
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->resolve(5));
|
||||
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->defaultMethod());
|
||||
}
|
||||
|
||||
public function testEtransferDefaultHoldsEvenWhenStripeIsLive(): void
|
||||
{
|
||||
Functions\when('get_user_meta')->justReturn('');
|
||||
|
||||
$resolver = new BillingMethodResolver(
|
||||
$this->settings(Payment::METHOD_ETRANSFER, true)
|
||||
);
|
||||
|
||||
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->resolve(5));
|
||||
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->defaultMethod());
|
||||
}
|
||||
|
||||
public function testPerStudentCardOverrideStillWinsUnderAnEtransferDefault(): void
|
||||
{
|
||||
// The trial path: the studio bills by e-transfer, one student is moved to
|
||||
// card to prove Stripe end to end.
|
||||
Functions\when('get_user_meta')->justReturn(Payment::METHOD_CARD);
|
||||
|
||||
$resolver = new BillingMethodResolver(
|
||||
$this->settings(Payment::METHOD_ETRANSFER, true)
|
||||
);
|
||||
|
||||
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
|
||||
}
|
||||
|
||||
public function testInvalidOverrideFallsBackToDefault(): void
|
||||
{
|
||||
Functions\when('get_user_meta')->justReturn('bogus');
|
||||
|
||||
$settings = Mockery::mock(StudioSettings::class);
|
||||
$settings->shouldReceive('isStripeConfigured')->andReturn(true);
|
||||
$resolver = new BillingMethodResolver($settings);
|
||||
$resolver = new BillingMethodResolver(
|
||||
$this->settings(Payment::METHOD_CARD, true)
|
||||
);
|
||||
|
||||
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
|
||||
}
|
||||
|
||||
private function settings(string $default, bool $stripeConfigured): StudioSettings
|
||||
{
|
||||
$settings = Mockery::mock(StudioSettings::class);
|
||||
$settings->shouldReceive('defaultPaymentMethod')->andReturn($default);
|
||||
$settings->shouldReceive('isStripeConfigured')->andReturn($stripeConfigured);
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueEmailTemplate;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentDueEmailTemplateTest extends TestCase
|
||||
{
|
||||
public function testFallsBackToDefaultsWhenUnset(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static fn (string $name, $default = '') => '');
|
||||
|
||||
$template = new PaymentDueEmailTemplate();
|
||||
|
||||
self::assertSame(PaymentDueEmailTemplate::defaultSubject(), $template->subject());
|
||||
self::assertSame(PaymentDueEmailTemplate::defaultBody(), $template->body());
|
||||
self::assertSame(PaymentDueEmailTemplate::defaultItemLine(), $template->itemLine());
|
||||
}
|
||||
|
||||
public function testReadsStoredValues(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static function (string $name) {
|
||||
return match ($name) {
|
||||
PaymentDueEmailTemplate::OPT_SUBJECT => 'Custom subject',
|
||||
PaymentDueEmailTemplate::OPT_BODY => 'Custom body {items}',
|
||||
PaymentDueEmailTemplate::OPT_ITEM_LINE => '{label}: {amount}',
|
||||
default => '',
|
||||
};
|
||||
});
|
||||
|
||||
$template = new PaymentDueEmailTemplate();
|
||||
|
||||
self::assertSame('Custom subject', $template->subject());
|
||||
self::assertSame('Custom body {items}', $template->body());
|
||||
self::assertSame('{label}: {amount}', $template->itemLine());
|
||||
}
|
||||
|
||||
public function testRenderSubstitutesTokens(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static function (string $name) {
|
||||
return match ($name) {
|
||||
PaymentDueEmailTemplate::OPT_SUBJECT => 'Hi {student_name}',
|
||||
PaymentDueEmailTemplate::OPT_BODY => 'Total: {total_due}',
|
||||
default => '',
|
||||
};
|
||||
});
|
||||
|
||||
$template = new PaymentDueEmailTemplate();
|
||||
|
||||
self::assertSame('Hi Sam', $template->renderSubject(['{student_name}' => 'Sam']));
|
||||
self::assertSame('Total: CAD 10.00', $template->renderBody(['{total_due}' => 'CAD 10.00']));
|
||||
}
|
||||
|
||||
public function testSaveWritesOptions(): void
|
||||
{
|
||||
Functions\expect('update_option')->once()->with(PaymentDueEmailTemplate::OPT_SUBJECT, 'S');
|
||||
Functions\expect('update_option')->once()->with(PaymentDueEmailTemplate::OPT_BODY, 'B');
|
||||
Functions\expect('update_option')->once()->with(PaymentDueEmailTemplate::OPT_ITEM_LINE, 'I');
|
||||
|
||||
$template = new PaymentDueEmailTemplate();
|
||||
$template->saveSubject('S');
|
||||
$template->saveBody('B');
|
||||
$template->saveItemLine('I');
|
||||
}
|
||||
|
||||
public function testDefaultBodyCarriesEveryBlockToken(): void
|
||||
{
|
||||
$body = PaymentDueEmailTemplate::defaultBody();
|
||||
|
||||
foreach (['{items}', '{credit}', '{total_due}', '{etransfer}', '{reference}'] as $token) {
|
||||
self::assertStringContainsString($token, $body);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,27 @@ namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueEmailTemplate;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentDueMailerTest extends TestCase
|
||||
{
|
||||
private function student(string $email): \WP_User
|
||||
protected function setUp(): void
|
||||
{
|
||||
$student = Mockery::mock(\WP_User::class);
|
||||
$student->user_email = $email;
|
||||
parent::setUp();
|
||||
|
||||
// The mailer renders from PaymentDueEmailTemplate, which reads its
|
||||
// subject/body/item-line from options. An empty stored value means the
|
||||
// built-in default template is used — the behaviour these tests assert.
|
||||
Functions\when('get_option')->alias(static fn (string $name, $default = '') => '');
|
||||
}
|
||||
|
||||
private function student(string $email, string $name = 'Alex Student'): \WP_User
|
||||
{
|
||||
$student = Mockery::mock(\WP_User::class);
|
||||
$student->user_email = $email;
|
||||
$student->display_name = $name;
|
||||
|
||||
return $student;
|
||||
}
|
||||
@@ -129,4 +141,39 @@ class PaymentDueMailerTest extends TestCase
|
||||
|
||||
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items));
|
||||
}
|
||||
|
||||
public function testRendersCustomTemplateWithTokens(): void
|
||||
{
|
||||
// A stored template overrides the default; tokens are substituted with
|
||||
// the real values gathered from the items and student.
|
||||
Functions\when('get_option')->alias(static function (string $name) {
|
||||
if ($name === PaymentDueEmailTemplate::OPT_SUBJECT) {
|
||||
return 'Hi {student_name} — {total_due}';
|
||||
}
|
||||
if ($name === PaymentDueEmailTemplate::OPT_BODY) {
|
||||
return "Dear {student_name},\n{items}\nOwing: {total_due}";
|
||||
}
|
||||
if ($name === PaymentDueEmailTemplate::OPT_ITEM_LINE) {
|
||||
return '* {label} = {currency} {amount}';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
'Hi Jordan — CAD 35.00',
|
||||
Mockery::on(static function (string $body): bool {
|
||||
return str_contains($body, 'Dear Jordan,')
|
||||
&& str_contains($body, '* Piano = CAD 35.00')
|
||||
&& str_contains($body, 'Owing: CAD 35.00');
|
||||
})
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
$items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => null ]];
|
||||
|
||||
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]', 'Jordan'), $items));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueEmailTemplate;
|
||||
use Unsupervised\Schedular\Payment\PaymentEmailController;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentEmailControllerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('get_option')->alias(static fn (string $name, $default = '') => '');
|
||||
}
|
||||
|
||||
public function testRenderSampleUsesDefaultTemplateWhenDraftBlank(): void
|
||||
{
|
||||
$stored = new PaymentDueEmailTemplate();
|
||||
|
||||
$rendered = PaymentEmailController::renderSample($stored, '', '', '');
|
||||
|
||||
self::assertSame(PaymentDueEmailTemplate::defaultSubject(), $rendered['subject']);
|
||||
// Default body lists both sample items with a grand total and the sample
|
||||
// reference/credit blocks resolved.
|
||||
self::assertStringContainsString('Piano lesson', $rendered['body']);
|
||||
self::assertStringContainsString('Guitar lesson', $rendered['body']);
|
||||
self::assertStringContainsString('Jul 15, 2026', $rendered['body']);
|
||||
self::assertStringContainsString('Total due: CAD 55.00', $rendered['body']); // 75 - 20 credit
|
||||
self::assertStringContainsString('REF12345', $rendered['body']);
|
||||
self::assertStringContainsString('[email protected]', $rendered['body']);
|
||||
}
|
||||
|
||||
public function testRenderSampleUsesDraftOverStored(): void
|
||||
{
|
||||
$stored = new PaymentDueEmailTemplate();
|
||||
|
||||
$rendered = PaymentEmailController::renderSample(
|
||||
$stored,
|
||||
'Draft: {total_due}',
|
||||
"Hello {student_name}\n{items}",
|
||||
'> {label} {amount}'
|
||||
);
|
||||
|
||||
self::assertSame('Draft: CAD 55.00', $rendered['subject']);
|
||||
self::assertStringContainsString('Hello ' . PaymentEmailController::sampleStudentName(), $rendered['body']);
|
||||
self::assertStringContainsString('> Piano lesson 35.00', $rendered['body']);
|
||||
}
|
||||
|
||||
public function testSampleItemsAreTwoLessons(): void
|
||||
{
|
||||
$items = PaymentEmailController::sampleItems();
|
||||
|
||||
self::assertCount(2, $items);
|
||||
self::assertSame(35.0, $items[0]['amount']);
|
||||
self::assertSame(40.0, $items[1]['amount']);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user