Compare commits

...
19 Commits
Author SHA1 Message Date
thatguygriff 694366a6c8 Merge pull request 'Add editable, previewable payment-due email template' (#203) from feature/editable-payment-due-email into main
CI / Coding Standards (push) Successful in 15s
CI / No Debug Code (push) Successful in 4s
CI / Tests (PHP 8.5) (push) Successful in 23s
CI / Tests (PHP 8.2) (push) Successful in 29s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / Tests (PHP 8.3) (push) Successful in 52s
CI / Static Analysis (push) Successful in 1m2s
CI / Build Plugin Zip (push) Successful in 12s
Release / Build and Publish Release (push) Successful in 22s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #203
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-17 20:40:36 +00:00
Kydoimosandanthropic/claude-opus-4-8 a2c609803e Add editable, previewable payment-due email template
CI / Coding Standards (pull_request) Successful in 29s
CI / Tests (PHP 8.2) (pull_request) Successful in 31s
CI / Tests (PHP 8.3) (pull_request) Successful in 31s
CI / Tests (PHP 8.5) (pull_request) Successful in 31s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Static Analysis (pull_request) Successful in 51s
CI / Build Plugin Zip (pull_request) Skipped
The "Payment due" notice the daily billing scan sends was fixed wording
baked into PaymentDueMailer. Studio admins can now view, edit and preview
its subject and body under Studio Settings -> Payment Due Email.

- PaymentDueEmailTemplate stores subject/body/item-line as us_payment_due_email_*
  options, each falling back to the built-in default when blank, and renders
  via {token} substitution.
- PaymentDueMailer now builds a token map from the same consolidated
  items/credit/e-transfer/reference data and renders the stored template; the
  default template reproduces the previous wording exactly.
- PaymentEmailController serves the view/edit/save/reset admin page
  (manage_billing, nonce-checked) with a server-rendered sample preview.
- PaymentEmailPreviewEndpoint (POST us-scheduler/v1/payment-email/preview,
  manage_billing) + payment-email-admin.js drive a live preview of unsaved edits.
- Submenu wired under Studio Settings; endpoint wired in RestRegistrar.
- Bump to 1.6.0 and add CHANGELOG entry. New options default gracefully, so
  existing sites need no migration and send the identical notice until edited.

Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 17:33:23 -03:00
thatguygriff ad4e4ae357 Merge pull request 'Apply account credit on same-month rebook; show total credit on student page' (#202) from feature/student-credit-display-and-rebook-fix into main
CI / Coding Standards (push) Successful in 28s
CI / Tests (PHP 8.2) (push) Successful in 31s
CI / Tests (PHP 8.5) (push) Successful in 32s
CI / No Debug Code (push) Successful in 9s
CI / Static Analysis (push) Successful in 37s
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.3) (push) Successful in 44s
CI / Build Plugin Zip (push) Successful in 13s
Reviewed-on: #202
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-17 18:50:43 +00:00
Kydoimosandanthropic/claude-opus-4-8 1a166eb4e3 Add changelog entries for the credit fix and student-page display
CI / Coding Standards (pull_request) Successful in 30s
CI / Tests (PHP 8.2) (pull_request) Successful in 33s
CI / Tests (PHP 8.5) (pull_request) Successful in 33s
CI / Static Analysis (pull_request) Successful in 38s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 41s
CI / Build Plugin Zip (pull_request) Skipped
Recorded under the current 1.5.8 section; no version bump.

Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 15:49:16 -03:00
Kydoimosandanthropic/claude-opus-4-8 362980d008 Apply available credit to any at-booking charge, not just scheduled
CI / Coding Standards (pull_request) Successful in 31s
CI / Tests (PHP 8.2) (pull_request) Successful in 30s
CI / Tests (PHP 8.3) (pull_request) Successful in 31s
CI / No Debug Code (pull_request) Successful in 5s
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / Tests (PHP 8.5) (pull_request) Successful in 42s
CI / Static Analysis (pull_request) Successful in 56s
CI / Build Plugin Zip (pull_request) Skipped
A payer's account credit should offset any charge raised at booking, so
drop the scheduled-only guard. Covers the one-time pay-now flow too.

Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 15:46:11 -03:00
Kydoimosandanthropic/claude-opus-4-8 e3ab7973d6 Clarify credit-at-booking comment scopes to scheduled charges
CI / Coding Standards (pull_request) Successful in 30s
CI / Tests (PHP 8.2) (pull_request) Successful in 32s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Static Analysis (pull_request) Successful in 38s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Tests (PHP 8.5) (pull_request) Successful in 45s
CI / Build Plugin Zip (pull_request) Skipped
Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 15:44:41 -03:00
Kydoimosandanthropic/claude-opus-4-8 1758c253a1 Trim credit-at-booking comment to one line
CI / Coding Standards (pull_request) Successful in 23s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.2) (pull_request) Successful in 33s
CI / Tests (PHP 8.5) (pull_request) Successful in 34s
CI / Static Analysis (pull_request) Successful in 39s
CI / Tests (PHP 8.3) (pull_request) Successful in 38s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Build Plugin Zip (pull_request) Skipped
Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 15:43:57 -03:00
Kydoimosandanthropic/claude-opus-4-8 67017409d7 Drop stale comment about not applying credit at booking
CI / Coding Standards (pull_request) Successful in 29s
CI / Tests (PHP 8.1) (pull_request) Successful in 36s
CI / No Debug Code (pull_request) Successful in 11s
CI / Tests (PHP 8.5) (pull_request) Successful in 36s
CI / Static Analysis (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 43s
CI / Tests (PHP 8.3) (pull_request) Successful in 43s
CI / Build Plugin Zip (pull_request) Skipped
This change applies credit at booking for scheduled rebooks, so the note
saying the pay-now flow deliberately does not was contradictory.

Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 15:42:38 -03:00
Kydoimosandanthropic/claude-opus-4-8 e7de627752 Apply account credit on same-month rebook, surface credit on student page
CI / Coding Standards (pull_request) Successful in 27s
CI / Tests (PHP 8.2) (pull_request) Successful in 28s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.5) (pull_request) Successful in 35s
CI / Tests (PHP 8.1) (pull_request) Successful in 37s
CI / Tests (PHP 8.3) (pull_request) Successful in 43s
CI / Static Analysis (pull_request) Successful in 51s
CI / Build Plugin Zip (pull_request) Skipped
A monthly/weekly lesson booked into a month whose billing date has
already passed is charged at booking time by LessonBooker::settle,
bypassing the daily scan where account credit is otherwise applied. So
rebooking a cancelled paid lesson within the same month charged the
family in full while their cancellation credit sat unused — billed twice
for the same slot.

Apply the payer's credit to that charge-at-booking payment for scheduled
offerings, mirroring the daily scan: a payment fully covered by credit
settles and confirms its lesson. Add PaymentService::findPayment so
settle can re-read the row after applyCredits writes to it.

Also surface the student's total account credit at the top of their
detail page when they hold a balance, so the studio sees it at a glance.

Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 15:39:53 -03:00
thatguygriff d8d842b1ef Merge pull request 'Fix two duplicate-charge bugs in scheduled billing' (#201) from fix/duplicate-payment-due-notice into main
CI / Coding Standards (push) Successful in 16s
CI / No Debug Code (push) Successful in 5s
CI / Tests (PHP 8.5) (push) Successful in 24s
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Tests (PHP 8.3) (push) Successful in 46s
CI / Static Analysis (push) Successful in 1m2s
CI / Tests (PHP 8.2) (push) Successful in 1m1s
CI / Build Plugin Zip (push) Successful in 15s
Reviewed-on: #201
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-17 18:25:18 +00:00
Kydoimosandanthropic/claude-opus-4-8 acda3cda0f Reconcile up-front charges when a class switches to monthly billing
CI / Coding Standards (pull_request) Successful in 25s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Successful in 36s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.3) (pull_request) Successful in 51s
CI / Tests (PHP 8.5) (pull_request) Successful in 59s
CI / Static Analysis (pull_request) Successful in 1m3s
CI / Build Plugin Zip (pull_request) Skipped
A student who enrols while a group class is pay-now is charged once at
enrolment, and that charge carries no period_key. When the class is
later switched to monthly, the daily scan — which dedups scheduled
charges by period_key — does not see the up-front charge and bills the
enrolment again for the current month, double-charging students who
had already paid. The differing payer between the two rows (student vs
guardian) was a side effect of guardian links created between the two
charge dates, not the cause.

Switching a group class into monthly now adopts each active enrolment's
up-front charge into the current month (stamping period_key and
due_date) so the scan treats that month as billed and charges from the
next month on. Enrolments with no up-front charge, or already billed
for the month, are left alone; weekly and non-group offerings are not
touched. Wired into both offering-update paths (admin form and REST).

Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 15:07:22 -03:00
Kydoimosandanthropic/claude-opus-4-8 e389e40843 Send each scheduled payment's due notice exactly once
CI / Coding Standards (pull_request) Successful in 28s
CI / Tests (PHP 8.1) (pull_request) Successful in 35s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.3) (pull_request) Successful in 44s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / Tests (PHP 8.5) (pull_request) Successful in 48s
CI / Static Analysis (pull_request) Successful in 52s
CI / Build Plugin Zip (pull_request) Skipped
The daily billing scan runs on request via WP-Cron and can overlap
itself under concurrent traffic. Each run emailed the payments it
created with no record that a notice had gone out, so two overlapping
runs could send a payer two identical "Payment due" emails for one
charge — read by families as being billed twice, though only one row
exists.

Stamp us_payments.notice_sent_at atomically before emailing: the scan
now claims each payment with a conditional UPDATE ... WHERE
notice_sent_at IS NULL and only notices, credits and batches the rows
it won. A competing run finds them claimed and stays quiet, so exactly
one notice is sent regardless of how the scan is triggered. A one-time
backfill stamps existing scheduled rows on upgrade so already-noticed
charges are not re-emailed.

Co-authored-by: anthropic/claude-opus-4-8
2026-09-17 13:19:25 -03:00
thatguygriff 6c92fc35fd Delete directory '.claude'
CI / Coding Standards (push) Successful in 17s
CI / No Debug Code (push) Successful in 5s
CI / Tests (PHP 8.5) (push) Successful in 24s
CI / Tests (PHP 8.2) (push) Successful in 35s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / Tests (PHP 8.3) (push) Successful in 52s
CI / Static Analysis (push) Successful in 1m2s
CI / Build Plugin Zip (push) Successful in 16s
2026-09-17 00:10:58 +00:00
thatguygriff cd86f6bdeb Merge pull request 'Add AGENTS.md, point CLAUDE.md files at it' (#200) from docs/agent-instructions into main
CI / Coding Standards (push) Successful in 24s
CI / No Debug Code (push) Successful in 8s
CI / Tests (PHP 8.5) (push) Successful in 33s
CI / Static Analysis (push) Successful in 38s
CI / Tests (PHP 8.2) (push) Successful in 36s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.3) (push) Successful in 47s
CI / Build Plugin Zip (push) Successful in 12s
Reviewed-on: #200
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-16 15:29:58 +00:00
Kydoimosandopencode/muse-spark-1.3-contributor-free 63ee248695 Add AGENTS.md, point CLAUDE.md files at it
CI / Coding Standards (pull_request) Successful in 14s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Successful in 34s
CI / Tests (PHP 8.3) (pull_request) Successful in 43s
CI / Tests (PHP 8.5) (pull_request) Successful in 45s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / Static Analysis (pull_request) Successful in 1m2s
CI / Build Plugin Zip (pull_request) Skipped
Co-authored-by: opencode/muse-spark-1.3-contributor-free
2026-09-16 12:21:55 -03:00
thatguygriff 91eb30b222 Merge pull request 'Sign the automated version bump commit in CI' (#199) from ci/sign-version-bump-commit into main
CI / Coding Standards (push) Successful in 30s
CI / Tests (PHP 8.5) (push) Successful in 27s
CI / Tests (PHP 8.3) (push) Successful in 34s
CI / No Debug Code (push) Successful in 6s
CI / Static Analysis (push) Successful in 38s
CI / Tests (PHP 8.1) (push) Successful in 41s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Build Plugin Zip (push) Successful in 23s
Reviewed-on: #199
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-05 23:29:51 +00:00
KydoimosandClaude Opus 5 c4b2b5ccff Sign the automated version bump commit in CI
CI / Coding Standards (pull_request) Successful in 23s
CI / Tests (PHP 8.5) (pull_request) Successful in 27s
CI / Tests (PHP 8.2) (pull_request) Successful in 35s
CI / Static Analysis (pull_request) Successful in 39s
CI / Tests (PHP 8.3) (pull_request) Successful in 40s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 35s
CI / Build Plugin Zip (pull_request) Skipped
main now requires signed commits, and Gitea refuses to merge a pull request
carrying an unsigned one, so the post-release bump PR could not be merged
without disabling the protection first.

The runner now signs that commit with a dedicated release-bot SSH key held as
an organisation Actions secret, trusted by the instance through
[repository.signing] TRUSTED_SSH_KEYS. The key Gitea signs merge commits with
lives on the server and no runner can reach it.

The step fails fast when the secret is missing, unparseable or passphrase
protected, and the commit is re-read before pushing so an unsigned one is
caught here rather than days later at merge time.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 20:25:58 -03:00
thatguygriff a4f694a966 Merge pull request 'Bump version to 1.5.7' (#198) from release/bump-1.5.7 into main
CI / Coding Standards (push) Successful in 21s
CI / Tests (PHP 8.1) (push) Successful in 30s
CI / Static Analysis (push) Successful in 36s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Tests (PHP 8.5) (push) Successful in 25s
CI / Tests (PHP 8.3) (push) Successful in 36s
CI / No Debug Code (push) Successful in 37s
CI / Build Plugin Zip (push) Successful in 22s
Reviewed-on: #198
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-05 19:45:41 -03:00
Release Bot b9f7c96ce6 Bump version to 1.5.7 and open changelog section 2026-09-05 15:04:47 +00:00
41 changed files with 1739 additions and 106 deletions
-12
View File
@@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(composer test:*)",
"Bash(composer lint *)",
"Bash(tea actions:*)",
"Bash(tea issue *)",
"Bash(tea label *)",
"Bash(composer cs *)"
]
}
}
+50 -2
View File
@@ -149,6 +149,50 @@ jobs:
{ print } { print }
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md ' 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 - name: Open pull request
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -157,10 +201,14 @@ jobs:
branch="release/bump-${next}" branch="release/bump-${next}"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" 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 checkout -b "${branch}"
git commit -am "Bump version to ${next} and open changelog section" 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}" git push origin "${branch}"
curl -fsS -X POST "${api}/pulls" \ curl -fsS -X POST "${api}/pulls" \
+47
View File
@@ -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).
+17
View File
@@ -11,6 +11,23 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work. each change under the current top section as you work.
## [1.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] ## [1.5.6]
### Security ### Security
+1 -29
View File
@@ -1,31 +1,3 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. See `AGENTS.md` — it is the single source of truth for working in this repo.
## 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.
+60
View File
@@ -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);
});
})();
+56
View File
@@ -43,3 +43,59 @@ The Composer download cache lives at `/composer/cache` — `COMPOSER_HOME` is
The image has to exist first. Add the version to the `php` matrix in 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` `ci-php`'s `.gitea/workflows/publish.yml` and merge, then add it to the `test`
matrix in `.gitea/workflows/ci.yml` here. 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.
+35 -6
View File
@@ -25,12 +25,14 @@ use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\GroupClass\SessionSchedule; use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\ClassSlotReconciler; use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository; use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\PaymentController; use Unsupervised\Schedular\Payment\PaymentController;
use Unsupervised\Schedular\Payment\PaymentEmailController;
use Unsupervised\Schedular\Payment\PaymentReportController; use Unsupervised\Schedular\Payment\PaymentReportController;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
@@ -55,6 +57,12 @@ class AdminMenu {
*/ */
private string $availabilityHook = ''; 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 AvailabilityController $availabilityController;
private LessonController $lessonController; private LessonController $lessonController;
private OfferingController $offeringController; private OfferingController $offeringController;
@@ -68,9 +76,10 @@ class AdminMenu {
private StudioSettings $settings; private StudioSettings $settings;
private AccessSettings $accessSettings; private AccessSettings $accessSettings;
private PaymentController $paymentController; private PaymentController $paymentController;
private PaymentEmailController $paymentEmailController;
private PaymentReportController $paymentReportController; private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians, LessonBooker $booker, RegistrationGate $gate ) { 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 // One audit presenter and one recorder, shared by the lesson and enrolment
// detail views: intake is the same thing whichever registration it hangs off. // detail views: intake is the same thing whichever registration it hangs off.
$intakeAudit = new IntakeAudit( $answers, $questions, $acceptances, $policies, $policyVersions ); $intakeAudit = new IntakeAudit( $answers, $questions, $acceptances, $policies, $policyVersions );
@@ -78,7 +87,7 @@ class AdminMenu {
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) ); $this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording ); $this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) ); $this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ), $billingModeReconciler );
$this->questionController = new QuestionController( $questions, $offerings ); $this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites ); $this->registrationController = new RegistrationController( $invites );
@@ -89,6 +98,7 @@ class AdminMenu {
$this->settings = $settings; $this->settings = $settings;
$this->accessSettings = new AccessSettings(); $this->accessSettings = new AccessSettings();
$this->paymentController = new PaymentController( $payments, $paymentService ); $this->paymentController = new PaymentController( $payments, $paymentService );
$this->paymentEmailController = new PaymentEmailController();
$this->paymentReportController = new PaymentReportController( $payments ); $this->paymentReportController = new PaymentReportController( $payments );
} }
@@ -104,10 +114,7 @@ class AdminMenu {
* @param string $hookSuffix Screen the enqueue is running for. * @param string $hookSuffix Screen the enqueue is running for.
*/ */
public function enqueueAssets( string $hookSuffix ): void { public function enqueueAssets( string $hookSuffix ): void {
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) { if ( '' !== $this->availabilityHook && $hookSuffix === $this->availabilityHook ) {
return;
}
wp_enqueue_script( wp_enqueue_script(
'us-scheduler-availability-admin', 'us-scheduler-availability-admin',
USC_PLUGIN_URL . 'assets/js/availability-admin.js', USC_PLUGIN_URL . 'assets/js/availability-admin.js',
@@ -115,6 +122,18 @@ class AdminMenu {
USC_VERSION, USC_VERSION,
true true
); );
return;
}
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 { public function addPages(): void {
@@ -262,6 +281,16 @@ class AdminMenu {
30 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. // Site owner: whether WordPress administrators are studio admins / instructors.
// Gated on the core manage_options capability — never the plugin's own grants — // 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. // so an administrator can always reach it to re-enable a disabled grant.
+8 -1
View File
@@ -184,6 +184,7 @@ class LessonBooker {
? $offering->price ? $offering->price
: $offering->price * count( $ids ); : $offering->price * count( $ids );
$payerId = $this->guardians->payerFor( $studentId );
$payment = $this->payments->createForRegistration( $payment = $this->payments->createForRegistration(
Payment::REG_LESSON, Payment::REG_LESSON,
$anchorId, $anchorId,
@@ -192,9 +193,15 @@ class LessonBooker {
$amount, $amount,
$offering->currency, $offering->currency,
$offering->etransferEmail, $offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId ) 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 [ return [
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING, 'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
'payment' => $payment, 'payment' => $payment,
+18
View File
@@ -82,6 +82,24 @@ class EnrollmentRepository {
return $count > 0; 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. * A student's enrolments, newest first.
* *
+85
View File
@@ -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' );
}
}
+6
View File
@@ -12,6 +12,7 @@ class OfferingController {
public function __construct( public function __construct(
private OfferingRepository $repository, private OfferingRepository $repository,
private ClassSlotReconciler $reconciler, private ClassSlotReconciler $reconciler,
private BillingModeReconciler $billingModeReconciler,
private AccessSettings $access = new AccessSettings(), private AccessSettings $access = new AccessSettings(),
) {} ) {}
@@ -81,6 +82,11 @@ class OfferingController {
if ( null !== $offering ) { if ( null !== $offering ) {
$this->repository->update( $offeringId, $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 ); return $this->reconcileNotice( $offering );
} }
} }
+6
View File
@@ -13,6 +13,7 @@ class OfferingEndpoint {
public function __construct( public function __construct(
private OfferingRepository $repository, private OfferingRepository $repository,
private GroupAccessRepository $access, private GroupAccessRepository $access,
private BillingModeReconciler $billingModeReconciler,
) {} ) {}
/** /**
@@ -237,6 +238,11 @@ class OfferingEndpoint {
$this->repository->update( $id, $offering ); $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 ); return new \WP_REST_Response( $offering->toArray(), 200 );
} }
+8
View File
@@ -59,6 +59,13 @@ class Payment {
public readonly ?string $stripePaymentIntentId = null, public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null, public readonly ?string $receiptNumber = null,
public readonly ?string $receiptSentAt = 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 $paidAt = null,
public readonly ?string $createdAt = null, public readonly ?string $createdAt = null,
public readonly ?int $id = null, public readonly ?int $id = null,
@@ -85,6 +92,7 @@ class Payment {
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ), stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
receiptNumber: Val::stringOrNull( $row->receipt_number ), receiptNumber: Val::stringOrNull( $row->receipt_number ),
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ), receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
noticeSentAt: Val::stringOrNull( $row->notice_sent_at ?? null ),
paidAt: Val::stringOrNull( $row->paid_at ), paidAt: Val::stringOrNull( $row->paid_at ),
createdAt: Val::stringOrNull( $row->created_at ), createdAt: Val::stringOrNull( $row->created_at ),
id: Val::int( $row->id ), id: Val::int( $row->id ),
+130
View File
@@ -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 );
}
}
+51 -21
View File
@@ -8,9 +8,16 @@ namespace Unsupervised\Schedular\Payment;
* scan generated for them in one run, so a student billed for several lessons on * 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 * the same day receives one email with a line per item and a grand total never
* one email per lesson. * 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 { class PaymentDueMailer {
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
/** /**
* Send one student their consolidated due-payment notice for the current scan. * 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 * The optional `$reference` is the shared notice-batch code the student can quote
@@ -25,6 +32,29 @@ class PaymentDueMailer {
return false; 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']; $currency = (string) $items[0]['currency'];
$total = 0.0; $total = 0.0;
$lines = []; $lines = [];
@@ -34,13 +64,13 @@ class PaymentDueMailer {
$amount = (float) $item['amount']; $amount = (float) $item['amount'];
$total += $amount; $total += $amount;
$lines[] = sprintf( $lines[] = $this->template->renderItemLine(
/* translators: 1: item description, 2: due date, 3: currency, 4: amount */ [
__( '- %1$s (due %2$s): %3$s %4$s', 'unsupervised-schedular' ), '{label}' => (string) $item['label'],
(string) $item['label'], '{due_date}' => $this->formatDate( $item['due_date'] ?? null ),
$this->formatDate( $item['due_date'] ?? null ), '{currency}' => $currency,
$currency, '{amount}' => number_format( $amount, 2 ),
number_format( $amount, 2 ) ]
); );
$etransfer = (string) ( $item['etransfer_email'] ?? '' ); $etransfer = (string) ( $item['etransfer_email'] ?? '' );
@@ -53,11 +83,9 @@ class PaymentDueMailer {
$creditApplied = round( min( $creditApplied, $total ), 2 ); $creditApplied = round( min( $creditApplied, $total ), 2 );
$dueTotal = round( $total - $creditApplied, 2 ); $dueTotal = round( $total - $creditApplied, 2 );
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n" $credit = '';
. implode( "\n", $lines );
if ( $creditApplied > 0.0 ) { if ( $creditApplied > 0.0 ) {
$body .= "\n\n" . sprintf( $credit = "\n\n" . sprintf(
/* translators: 1: currency, 2: credit amount */ /* translators: 1: currency, 2: credit amount */
__( 'Account credit applied: -%1$s %2$s', 'unsupervised-schedular' ), __( 'Account credit applied: -%1$s %2$s', 'unsupervised-schedular' ),
$currency, $currency,
@@ -65,30 +93,32 @@ class PaymentDueMailer {
); );
} }
$body .= "\n\n" . sprintf( $etransfer = '';
/* translators: 1: currency, 2: total amount */
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $dueTotal, 2 )
);
if ( $dueTotal > 0.0 && [] !== $emails ) { if ( $dueTotal > 0.0 && [] !== $emails ) {
$body .= "\n\n" . sprintf( $etransfer = "\n\n" . sprintf(
/* translators: %s: e-transfer destination email address(es) */ /* translators: %s: e-transfer destination email address(es) */
__( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ), __( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ),
implode( ', ', array_keys( $emails ) ) implode( ', ', array_keys( $emails ) )
); );
} }
$referenceLine = '';
if ( '' !== $reference ) { if ( '' !== $reference ) {
$body .= "\n\n" . sprintf( $referenceLine = "\n\n" . sprintf(
/* translators: %s: payment reference code */ /* translators: %s: payment reference code */
__( 'Please include this reference with your payment: %s', 'unsupervised-schedular' ), __( 'Please include this reference with your payment: %s', 'unsupervised-schedular' ),
$reference $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,
];
} }
/** /**
+144
View File
@@ -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 );
}
}
+117 -1
View File
@@ -34,10 +34,11 @@ class PaymentRepository {
'stripe_payment_intent_id' => $payment->stripePaymentIntentId, 'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
'receipt_number' => $payment->receiptNumber, 'receipt_number' => $payment->receiptNumber,
'receipt_sent_at' => $payment->receiptSentAt, 'receipt_sent_at' => $payment->receiptSentAt,
'notice_sent_at' => $payment->noticeSentAt,
'paid_at' => $payment->paidAt, 'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ), '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; 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 { public function updateStatus( int $id, string $status ): bool {
if ( ! in_array( $status, Payment::VALID_STATUSES, true ) ) { if ( ! in_array( $status, Payment::VALID_STATUSES, true ) ) {
return false; return false;
+19
View File
@@ -106,6 +106,25 @@ class PaymentService {
$this->payments->assignNoticeBatch( $ids, $batch ); $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. * Studio-admin confirmation that a pending payment (e-transfer) was received.
* Marks it paid, confirms the registration, and emails the receipt. * Marks it paid, confirms the registration, and emails the receipt.
+18
View File
@@ -352,6 +352,24 @@ class ScheduledBillingRunner {
*/ */
private function sendNotices( array $buckets ): void { private function sendNotices( array $buckets ): void {
foreach ( $buckets as $payerId => $entries ) { 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 ); $payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
$applied = $this->payments->applyCredits( $payerId, $payments ); $applied = $this->payments->applyCredits( $payerId, $payments );
+20 -2
View File
@@ -24,6 +24,7 @@ use Unsupervised\Schedular\Guardian\ChildLoginGate;
use Unsupervised\Schedular\Guardian\FamilyPage; use Unsupervised\Schedular\Guardian\FamilyPage;
use Unsupervised\Schedular\Guardian\GuardianRepository; use Unsupervised\Schedular\Guardian\GuardianRepository;
use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository; use Unsupervised\Schedular\Payment\CreditRepository;
@@ -96,6 +97,18 @@ class Plugin {
$guardians = new GuardianService( $guardianRepo, $bookings, $enrollments ); $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 ); $creditRepo = new CreditRepository( $wpdb );
$settings = new StudioSettings(); $settings = new StudioSettings();
$resolver = new BillingMethodResolver( $settings ); $resolver = new BillingMethodResolver( $settings );
@@ -117,6 +130,11 @@ class Plugin {
$familyPage = new FamilyPage( $guardians, $questions, $answers ); $familyPage = new FamilyPage( $guardians, $questions, $answers );
$accountPage = new AccountPage(); $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 ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
( new UpdateChecker() )->register(); ( new UpdateChecker() )->register();
@@ -126,8 +144,8 @@ class Plugin {
( new StudentAdminGuard() )->register(); ( new StudentAdminGuard() )->register();
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register(); ( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians, $lessonBooker, $registrationGate ) )->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 ) )->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 ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register(); ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
} }
+7 -2
View File
@@ -15,8 +15,10 @@ use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\SessionSchedule; use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentEmailPreviewEndpoint;
use Unsupervised\Schedular\Payment\PaymentEndpoint; use Unsupervised\Schedular\Payment\PaymentEndpoint;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
@@ -39,15 +41,17 @@ class RestRegistrar {
private PolicyEndpoint $policyEndpoint; private PolicyEndpoint $policyEndpoint;
private EnrollmentEndpoint $enrollmentEndpoint; private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint; 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, LessonBooker $booker ) { 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->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->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess ); $this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess, $billingModeReconciler );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians ); $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
$this->paymentEndpoint = new PaymentEndpoint( $paymentService ); $this->paymentEndpoint = new PaymentEndpoint( $paymentService );
$this->paymentEmailPreviewEndpoint = new PaymentEmailPreviewEndpoint();
} }
public function register(): void { public function register(): void {
@@ -62,5 +66,6 @@ class RestRegistrar {
$this->policyEndpoint->registerRoutes( self::NAMESPACE ); $this->policyEndpoint->registerRoutes( self::NAMESPACE );
$this->enrollmentEndpoint->registerRoutes( self::NAMESPACE ); $this->enrollmentEndpoint->registerRoutes( self::NAMESPACE );
$this->paymentEndpoint->registerRoutes( self::NAMESPACE ); $this->paymentEndpoint->registerRoutes( self::NAMESPACE );
$this->paymentEmailPreviewEndpoint->registerRoutes( self::NAMESPACE );
} }
} }
+1
View File
@@ -207,6 +207,7 @@ class Schema {
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
receipt_number VARCHAR(50) DEFAULT NULL, receipt_number VARCHAR(50) DEFAULT NULL,
receipt_sent_at DATETIME DEFAULT NULL, receipt_sent_at DATETIME DEFAULT NULL,
notice_sent_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
paid_at DATETIME DEFAULT NULL, paid_at DATETIME DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
+97
View File
@@ -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>
+33
View File
@@ -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> <div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
<?php endif; ?> <?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 %ss 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')); ?> <?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> <h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
+1 -12
View File
@@ -1,14 +1,3 @@
# Writing tests # 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. See `../../AGENTS.md` (Tests section) — it is the single source of truth for working in this repo.
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
+6
View File
@@ -48,6 +48,12 @@ class AdminBookingTest extends TestCase
$this->bookings = Mockery::mock(BookingRepository::class); $this->bookings = Mockery::mock(BookingRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class);
$this->payments = Mockery::mock(PaymentService::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 = Mockery::mock(GuardianService::class);
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault(); $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
@@ -57,6 +57,13 @@ class BookingEndpointTest extends TestCase
// Crediting a cancelled paid lesson is exercised in dedicated tests; other // Crediting a cancelled paid lesson is exercised in dedicated tests; other
// cancellation paths simply allow the call. // cancellation paths simply allow the call.
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault(); $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); $this->guardians = Mockery::mock(GuardianService::class);
// The default account books only for itself: no guardian link anywhere. // The default account books only for itself: no guardian link anywhere.
@@ -463,6 +470,42 @@ class BookingEndpointTest extends TestCase
self::assertNotNull($result->get_data()['payment']); 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 public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
{ {
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st, // "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,
@@ -110,6 +110,35 @@ class EnrollmentRepositoryTest extends TestCase
self::assertInstanceOf(Enrollment::class, $all[0]); 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 public function testFindActiveByBillingModesJoinsOfferingAndFiltersModes(): void
{ {
$this->db->shouldReceive('prepare') $this->db->shouldReceive('prepare')
@@ -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 Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\ClassSlotReconciler; use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
@@ -15,6 +16,7 @@ class OfferingControllerTest extends TestCase
{ {
private OfferingRepository&Mockery\MockInterface $repository; private OfferingRepository&Mockery\MockInterface $repository;
private ClassSlotReconciler&Mockery\MockInterface $reconciler; private ClassSlotReconciler&Mockery\MockInterface $reconciler;
private BillingModeReconciler&Mockery\MockInterface $billingModeReconciler;
private OfferingController $controller; private OfferingController $controller;
protected function setUp(): void protected function setUp(): void
@@ -24,7 +26,9 @@ class OfferingControllerTest extends TestCase
$this->repository = Mockery::mock(OfferingRepository::class); $this->repository = Mockery::mock(OfferingRepository::class);
$this->reconciler = Mockery::mock(ClassSlotReconciler::class); $this->reconciler = Mockery::mock(ClassSlotReconciler::class);
$this->reconciler->shouldReceive('reconcile')->andReturn(['removed' => 0, 'conflicts' => []])->byDefault(); $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 = []; $_POST = [];
$_GET = []; $_GET = [];
+5 -1
View File
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -16,6 +17,7 @@ class OfferingEndpointTest extends TestCase
{ {
private OfferingRepository&Mockery\MockInterface $repository; private OfferingRepository&Mockery\MockInterface $repository;
private GroupAccessRepository&Mockery\MockInterface $access; private GroupAccessRepository&Mockery\MockInterface $access;
private BillingModeReconciler&Mockery\MockInterface $billingModeReconciler;
private OfferingEndpoint $endpoint; private OfferingEndpoint $endpoint;
protected function setUp(): void protected function setUp(): void
@@ -27,7 +29,9 @@ class OfferingEndpointTest extends TestCase
$this->repository = Mockery::mock(OfferingRepository::class); $this->repository = Mockery::mock(OfferingRepository::class);
$this->access = Mockery::mock(GroupAccessRepository::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 private function group(int $id, string $access): Offering
@@ -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);
}
}
}
+48 -1
View File
@@ -5,15 +5,27 @@ namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Payment\PaymentDueEmailTemplate;
use Unsupervised\Schedular\Payment\PaymentDueMailer; use Unsupervised\Schedular\Payment\PaymentDueMailer;
use Unsupervised\Schedular\Tests\Unit\TestCase; use Unsupervised\Schedular\Tests\Unit\TestCase;
class PaymentDueMailerTest extends TestCase class PaymentDueMailerTest extends TestCase
{ {
private function student(string $email): \WP_User protected function setUp(): void
{
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 = Mockery::mock(\WP_User::class);
$student->user_email = $email; $student->user_email = $email;
$student->display_name = $name;
return $student; return $student;
} }
@@ -129,4 +141,39 @@ class PaymentDueMailerTest extends TestCase
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items)); 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']);
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Payment\PaymentEmailPreviewEndpoint;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class PaymentEmailPreviewEndpointTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('get_option')->alias(static fn (string $name, $default = '') => '');
}
public function testPreviewRendersDraftAgainstSampleValues(): void
{
$endpoint = new PaymentEmailPreviewEndpoint();
$request = new \WP_REST_Request([
'subject' => 'Draft {total_due}',
'body' => "Hi {student_name}\n{items}",
'item_line' => '- {label} {amount}',
]);
$response = $endpoint->preview($request);
self::assertInstanceOf(\WP_REST_Response::class, $response);
$data = $response->get_data();
self::assertSame('Draft CAD 55.00', $data['subject']);
self::assertStringContainsString('Piano lesson', $data['body']);
self::assertStringContainsString('- Piano lesson 35.00', $data['body']);
}
public function testCanManageRequiresBillingCapability(): void
{
$endpoint = new PaymentEmailPreviewEndpoint();
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('current_user_can')->alias(
static fn (string $cap): bool => $cap === RoleManager::CAP_MANAGE_BILLING
);
self::assertTrue($endpoint->canManage());
}
public function testCanManageDeniesWithoutCapability(): void
{
$endpoint = new PaymentEmailPreviewEndpoint();
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('current_user_can')->justReturn(false);
self::assertFalse($endpoint->canManage());
}
}
@@ -124,6 +124,107 @@ class PaymentRepositoryTest extends TestCase
self::assertTrue($this->repo->markPaid(50, 'USC-50')); self::assertTrue($this->repo->markPaid(50, 'USC-50'));
} }
public function testMarkNoticedStampsOnlyUnnoticedRowAndReportsTheWin(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/SET notice_sent_at = %s WHERE id = %d AND notice_sent_at IS NULL/'), 'wp_us_payments', '2026-06-08 12:00:00', 50)
->andReturn('UPDATE ...');
// One row updated -> this call won the claim.
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
self::assertTrue($this->repo->markNoticed(50));
}
public function testMarkNoticedReturnsFalseWhenAlreadyClaimed(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
$this->db->shouldReceive('prepare')->once()->andReturn('UPDATE ...');
// Zero rows updated -> another run already stamped notice_sent_at.
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(0);
self::assertFalse($this->repo->markNoticed(50));
}
public function testBackfillNoticeSentStampsExistingScheduledRowsWhenColumnPresent(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/SHOW COLUMNS FROM %i LIKE %s/'), 'wp_us_payments', 'notice_sent_at')
->andReturn('SHOW ...');
$this->db->shouldReceive('get_var')->once()->with('SHOW ...')->andReturn('notice_sent_at');
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/SET notice_sent_at = created_at WHERE notice_sent_at IS NULL AND period_key IS NOT NULL/'), 'wp_us_payments')
->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(3);
self::assertTrue($this->repo->backfillNoticeSent());
}
public function testBackfillNoticeSentIsNoopWhenColumnAbsent(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('SHOW ...');
$this->db->shouldReceive('get_var')->once()->with('SHOW ...')->andReturn(null);
// Column not there yet: do not attempt the UPDATE, and report not-done so
// the caller does not set its one-time flag before dbDelta has run.
$this->db->shouldNotReceive('query');
self::assertFalse($this->repo->backfillNoticeSent());
}
public function testHasUnscheduledChargeTrueWhenUpfrontChargeExists(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/period_key IS NULL AND status != %s/'), 'wp_us_payments', Payment::REG_ENROLLMENT, 7, Payment::STATUS_FAILED)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('3');
self::assertTrue($this->repo->hasUnscheduledCharge(Payment::REG_ENROLLMENT, 7));
}
public function testHasUnscheduledChargeFalseWhenNoneOrOnlyVoided(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->andReturn(null);
self::assertFalse($this->repo->hasUnscheduledCharge(Payment::REG_ENROLLMENT, 7));
}
public function testClaimPeriodForUnscheduledStampsPeriodAndDueDate(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/SET period_key = %s, due_date = %s.*period_key IS NULL AND status != %s/s'),
'wp_us_payments',
'2026-09',
'2026-09-01',
Payment::REG_ENROLLMENT,
7,
Payment::STATUS_FAILED
)
->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
self::assertSame(1, $this->repo->claimPeriodForUnscheduled(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01'));
}
public function testClaimPeriodForUnscheduledAdoptsNothingWhenNoMatch(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(0);
self::assertSame(0, $this->repo->claimPeriodForUnscheduled(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01'));
}
public function testUpdateTaxRecomputesAmountFromRate(): void public function testUpdateTaxRecomputesAmountFromRate(): void
{ {
$this->db->shouldReceive('prepare') $this->db->shouldReceive('prepare')
+2
View File
@@ -45,6 +45,7 @@ class PaymentTest extends TestCase
'stripe_payment_intent_id' => null, 'stripe_payment_intent_id' => null,
'receipt_number' => 'USC-7', 'receipt_number' => 'USC-7',
'receipt_sent_at' => null, 'receipt_sent_at' => null,
'notice_sent_at' => '2026-06-07 08:00:00',
'paid_at' => '2026-06-08 10:00:00', 'paid_at' => '2026-06-08 10:00:00',
'created_at' => '2026-06-08 09:00:00', 'created_at' => '2026-06-08 09:00:00',
]); ]);
@@ -56,6 +57,7 @@ class PaymentTest extends TestCase
self::assertSame(Payment::METHOD_COMP, $payment->method); self::assertSame(Payment::METHOD_COMP, $payment->method);
self::assertTrue($payment->isPaid()); self::assertTrue($payment->isPaid());
self::assertSame('USC-7', $payment->receiptNumber); self::assertSame('USC-7', $payment->receiptNumber);
self::assertSame('2026-06-07 08:00:00', $payment->noticeSentAt);
self::assertSame('2026-06-08 09:00:00', $payment->createdAt); self::assertSame('2026-06-08 09:00:00', $payment->createdAt);
} }
@@ -44,6 +44,9 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('assignNoticeBatch')->byDefault(); $this->payments->shouldReceive('assignNoticeBatch')->byDefault();
// No account credit unless a test says otherwise. // No account credit unless a test says otherwise.
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault(); $this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
// Every payment wins its notice claim unless a test simulates an
// overlapping run that already claimed it.
$this->payments->shouldReceive('markNoticed')->andReturn(true)->byDefault();
Functions\when('wp_generate_uuid4')->justReturn('abcdef12-3456-7890-abcd-ef1234567890'); Functions\when('wp_generate_uuid4')->justReturn('abcdef12-3456-7890-abcd-ef1234567890');
@@ -324,6 +327,62 @@ class ScheduledBillingRunnerTest extends TestCase
$this->runner->run(); $this->runner->run();
} }
/**
* A payment is emailed exactly once. WP-Cron fires on request and can run the
* scan twice concurrently; the second run reaching the send step for a
* payment already claimed by the first (markNoticed returns false) must not
* email it again, nor re-batch it. This is the double-notice regression.
*/
public function testDoesNotEmailAPaymentWhoseNoticeWasAlreadyClaimed(): void
{
$this->now('2026-07-15 09:00:00');
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pending(500, '2026-07-14'));
// The competing run already stamped notice_sent_at, so the claim loses.
$this->payments->shouldReceive('markNoticed')->with(500)->once()->andReturn(false);
// No credit, no batch, no email for an already-noticed payment.
$this->payments->shouldNotReceive('applyCredits');
$this->payments->shouldNotReceive('assignNoticeBatch');
$this->mailer->shouldNotReceive('send');
$this->runner->run();
}
/**
* When only some of a payer's charges are already claimed, the run notices
* the rest the still-unclaimed payment is emailed and batched on its own.
*/
public function testEmailsOnlyTheStillUnclaimedPaymentsInABucket(): void
{
$this->now('2026-07-15 09:00:00');
$one = $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0);
$two = $this->lessonRow(102, Offering::BILLING_WEEKLY, '2026-07-15 19:00:00', 35.0);
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([$one, $two]);
$this->payments->shouldReceive('createForRegistration')
->andReturn($this->pending(500, '2026-07-14'), $this->pending(501, '2026-07-14'));
// 500 already claimed by an overlapping run; 501 is this run's to send.
$this->payments->shouldReceive('markNoticed')->with(500)->once()->andReturn(false);
$this->payments->shouldReceive('markNoticed')->with(501)->once()->andReturn(true);
$this->payments->shouldReceive('applyCredits')
->once()
->with(5, Mockery::on(static fn (array $p): bool => count($p) === 1 && (int) $p[0]->id === 501))
->andReturn([]);
$this->payments->shouldReceive('assignNoticeBatch')->once()->with([501], Mockery::type('string'));
$this->mailer->shouldReceive('send')
->once()
->with(Mockery::type(\WP_User::class), Mockery::on(static fn (array $items): bool => count($items) === 1), Mockery::type('string'), 0.0);
$this->runner->run();
}
private function groupOffering(string $mode, string $termStart, string $termEnd): Offering private function groupOffering(string $mode, string $termStart, string $termEnd): Offering
{ {
return new Offering( return new Offering(
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.5.6 * Version: 1.6.0
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.5.6'); define('USC_VERSION', '1.6.0');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));