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
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
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
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
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]>
Both were mine, and both were in code the earlier commit could not run.
The four test failures shared one cause: UninstallerTest stubbed get_option
with an arrow function, which captures by value, so every read answered from
a snapshot of the options taken at setUp — before the test set any and before
the run wrote any. Every assertion that depended on reading back what had
just been written therefore saw an empty store. The file's other stubs
already use by-reference closures; this one now does too.
The phpcs error is WordPress.DB.PreparedSQL.NotPrepared on the table drop.
The sniff cannot follow $sql across the null guard that PHPStan requires
(prepare() is nullable), and unlike the repositories — which call through a
typed $this->db property the sniff does not track at all — the uninstaller
calls the global $wpdb, so the sniff sees it. Silenced explicitly, with the
reason.
composer test (996 tests, 2871 assertions), composer lint and composer cs all
pass locally on PHP 8.4.
Co-Authored-By: Claude Opus 5 <[email protected]>
The assessment looked for three things: whether students can reach each
other's bookings, whether payment settings can be dodged, and whether the
plugin opens a way into the rest of the install. The student-isolation and
payment paths held up. These are what did not.
- The front-end login form told WordPress not to work out whether the site
was secure, so on HTTPS every student's session cookie was issued without
the Secure flag. wp_signon() only derives it from is_ssl() when the second
argument is left at its default; an explicit false reads like "no
preference" and is not.
- The update check took whatever download URL the release API returned and
handed it to core, which unpacks it over the installed plugin. The package
must now be https on git.unsupervised.ca exactly, compared on the parsed
host so a lookalike name cannot pass.
- Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook
signing key in wp_options. Removal is now a choice made in advance on
Access -> Plugin removal: records are kept unless the owner opts in (with a
typed confirmation), while credentials and the borrowed core registration
settings go every time.
- Open registration switches on the site-wide users_can_register and makes
Student the default role, arming any other signup form on the site to mint
students who could book and be billed immediately. The pending state is now
decided once, on user_register, rather than by whichever form created the
account.
- Cancel and withdraw answered "not yours" differently from "does not exist",
which let a signed-in student enumerate the studio's bookings. Both now
give the same 404.
Co-Authored-By: Claude Opus 5 <[email protected]>
"the current Stripe library" is only true until Stripe ships the next major.
A changelog is read long after the release it describes, so name the
versions — 17 to 21, API version 2026-07-29.dahlia — and let those date the
entry.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
Four majors behind on the plugin's only production dependency. No site is
using Stripe yet and the integration has not been verified end to end, so
the pinned API version can move now at close to zero cost — later it would
mean coordinating with live payment traffic.
The library surface this plugin uses is four symbols, all in StripeGateway:
StripeClient, paymentIntents->create, Webhook::constructEvent and Event.
Checked each against the installed v21 by reflection; all present with
compatible signatures. Most of the breakage flagged across 18 to 21 is in
the V2 Events API, which this does not use — webhooks go through the V1
Webhook::constructEvent path.
The pinned Stripe API version moves to 2026-07-29.dahlia.
Verified beyond the suite, which mocks the client: a real HMAC-signed
payload round-trips through Webhook::constructEvent and returns a
Stripe\Event with the expected type and data, and a forged signature still
raises SignatureVerificationException. The 21.0.0 ErrorObject retyping to
null|string produces no new PHPStan findings.
Part of #194
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
Routine catch-up on the test and analysis tooling. Every package here is
already allowed by the existing constraints, so composer.json does not move
and no production dependency is touched.
mockery/mockery 1.6.12 -> 1.6.15
phpstan/phpstan 2.2.2 -> 2.2.9
phpunit/phpunit 10.5.63 -> 10.5.64
hamcrest/hamcrest-php v2.1.1 -> v3.0.0 (transitive, via mockery)
myclabs/deep-copy 1.13.4 -> 1.14.0
nikic/php-parser v5.7.0 -> v5.8.0
sebastian/recursion-context 5.0.1 -> 5.0.2
Seven patch releases of PHPStan is the one worth watching, since a static
analyser routinely gains inferences between them. It reports no new errors
here, and PHPCS and the suite are unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
composer audit reported three advisories against the PHPCS stack, two rated
high:
squizlabs/php_codesniffer 3.13.5 -> 3.13.6 CVE-2026-67434, OS command injection
wp-coding-standards/wpcs 3.3.0 -> 3.4.1 CVE-2026-45293, arbitrary code execution
phpcsstandards/phpcsutils 1.2.2 -> 1.2.3 CVE-2026-65954, arbitrary code execution
All three are dev-only and none ship in the plugin — bin/build-zip.sh installs
--no-dev — but they execute against repository content on every CI run.
The existing ^3.7 and ^3.0 constraints already allowed the fixed releases, so
composer.json is unchanged. phpcsextra and the codesniffer installer come
along as transitive dependencies. Five packages move, none are added or
removed, and the updated standards report no new violations.
composer audit is now clean.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
composer.json declares "php": ">=8.1", but resolution ran against whatever
PHP the machine doing the update happened to have. Running composer update
on 8.5 could therefore produce a lock containing a package that requires 8.2
or newer, which installs fine locally and fails on the 8.1 leg of the test
matrix — a lock that is only discovered to be wrong in CI.
config.platform.php makes resolution always target the declared minimum, so
a lock that resolves at all is a lock that installs everywhere the plugin
claims to run.
No package versions change: nothing currently locked requires a PHP newer
than 8.1, so this only records platform-overrides and the new content hash.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
The image definition and its publishing workflow were only here because this
was the first project to need them. They are project-agnostic, so they now
live in Unsupervised/ci-php and any PHP project can consume the same tags.
The registry path is unchanged — Gitea container images are named
owner/image, not after the repository that pushes them — so nothing here
needs repointing.
Split the combined quality job into separate phpcs and phpstan jobs. They
were folded together to halve the number of toolchain installs per run; with
the toolchain in the image that saves nothing, and separating them means a
coding-standards failure no longer hides the static analysis result.
Rewrite the workflow comments to describe what the jobs do rather than the
setup step they replaced, and cut docs/ci.md down to what is specific to this
project — the image contents and publishing are documented in ci-php.
Closes#187
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
docs/ci.md and the workflow comment both described secrets.GITHUB_TOKEN as
the working credential with REGISTRY_TOKEN as a fallback. That is backwards:
the task token is rejected by Gitea's container registry (go-gitea/gitea#23642)
and the first publish attempt failed on exactly that. REGISTRY_TOKEN is
required.
Part of #187
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
composer.lock was gitignored, so `composer install` resolved the graph afresh
on every job. CI could quietly start testing different dependency versions
than it did the day before, and two builds of the same tag could ship
different vendor trees — the run that produces the zip is the same run that
picks the versions.
Track the lock, and make the two places that consume it use it:
- the Composer cache keys hash composer.lock rather than composer.json, since
the lock is now what actually fingerprints the dependency set;
- bin/build-zip.sh stages the lock alongside composer.json, so the --no-dev
production install resolves to the locked versions. It already deleted both
from the staging directory before zipping, so the shipped plugin is
unchanged.
The current lock installs cleanly on 8.1: nothing in it requires a PHP newer
than the >=8.1 composer.json declares.
Closes#187
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
Replaces setup-php with `container:` in every job that needs PHP. The images
published by ci-images.yml already carry PHP, Composer, the intl and zip
extensions and the CLI tools the workflow scripts shell out to, so nothing
installs PHP at job time any more.
That removes the apt path #178 was about: a ~145s floor on 8.3 against ~35s
on 8.1/8.2, with a tail that twice ran past the step timeout and failed the
run. All three copies of the `Keep downloaded .debs` / `Cache apt packages`
workaround go with it.
no-debug and bump-version stay on the runner image — neither needs PHP, and
no-debug wants the runner's GNU `grep --include`.
The Composer cache moves to /composer/cache, which is COMPOSER_HOME in the
image, and release.yml gains the cache step it never had.
Also corrects the image size in docs/ci.md to the 67MB measured from the
published manifest; the ~120MB in the original was an estimate.
Closes#187
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
actions/cache shells out to `tar --posix -P`. Alpine's busybox tar rejects
both flags, so the cache step would fail in every job that runs inside these
images — which is all of them once ci.yml switches over.
coreutils does not cover this: tar is its own Alpine package. Add it, add
zstd (which actions/cache prefers over gzip when present), and assert GNU
tar in the image's smoke test so a future base-image change cannot quietly
drop it again.
Part of #187
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
setup-php installs PHP 8.3+ from apt on these arm64 runners: a ~145s floor
against ~35s for 8.1/8.2, with a tail that twice ran past the step timeout
and failed the run (#178). Caching the .debs softened it without removing
the apt step, and 8.5 has the same problem.
Add a per-version CI image built on php:<version>-cli-alpine and a workflow
that publishes it to git.unsupervised.ca/unsupervised/ci-php:<version>. The
org is public, so the packages pull anonymously.
The image carries bash and nodejs because act_runner runs JavaScript actions
inside the job container, GNU coreutils/grep/sed because the workflow scripts
use `tac` and `grep --include`, and curl/jq/git/zip for release.yml and
bin/build-zip.sh. Composer 2 and the intl and zip extensions round it out.
Nothing consumes the images yet — ci.yml and release.yml switch over in a
follow-up, because a job cannot run in an image that has not been published.
Part of #187
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
The Book a lesson for a student panel built its picker from the us_student
role but vetted the submission with the book_lesson capability. ChildLoginGate
and RegistrationLoginGate withhold that capability from accounts that keep the
role, so the panel offered every guardian-managed child and every unapproved
signup and then refused them — with a message claiming no student had been
chosen, and a form cleared of all five fields.
Withholding book_lesson stops those accounts registering in their own name. It
was never meant to stop the studio acting for them, which is what the panel is
for, and for a child is the only route to a lesson besides their guardian.
Guard the student role instead, via a new RoleManager::isStudent() shared with
every picker and guard on the staff side so the two cannot drift apart again.
Group enrolment gets the same predicate: addDirect() and grantAccess() vetted
their posted ids not at all, and would enrol an instructor, an administrator,
or an account deleted since the page was drawn — raising a real payment against
them for a priced class.
Keep a refused booking's fields as submitted, reading the form through one
LessonController::submittedBooking() so what gets booked and what is shown
again cannot disagree about a field name. A booking that succeeds still leaves
an empty form, so the next one does not inherit it.
Closes#185
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
Two related gaps, closed together because the second is created by the first.
A private lesson could only be booked by the student or their guardian, so a
booking taken over the phone had no way in — where group classes have had "Add
students directly" all along. "Book a lesson for a student" is now a panel on
Scheduler and My Lessons: student, open time, lesson type, with weekly term
reservations and a no-charge option for make-up lessons. The booking core is
extracted to Booking\LessonBooker and shared with POST /bookings, so the two
paths cannot drift on offering rules, slot claiming, or billing.
That leaves a registration with no intake answers and no policy acceptances,
because nobody was at a keyboard to give them — already true of every directly
added group-class student. Ticking the boxes on a student's behalf would be an
audit trail that says something untrue, so instead the answers are collected
another way and recorded afterwards, from a lesson's or an enrolment's detail
page. Every recording must say how it was collected, which is stamped on each
row along with who typed it and shown in a new "How it was given" column: a
policy ticked online and one transcribed from paper must never look alike.
Only staff-made registrations qualify (us_lessons.booked_by,
us_group_enrollments.enrolled_by) — one the student made already holds their
own answers. Only what is still missing can be recorded, re-checked at write
time, so a stale or double-posted form cannot duplicate or overwrite. No IP is
stored for a transcription, and accepted_by stays the student while recorded_by
names the staff member.
Intake is now generic over Registration\IntakeSubject, which Lesson and
Enrollment both implement; LessonDetail became Registration\IntakeAudit and is
shared by both detail views rather than duplicated.
Closes#182
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
I misread the instruction to hold 8.5 as "take it out until the cache
lands" and removed it in f6481d4, so #179 merged without it. It was meant
to stay where it was. Restoring it.
8.5 takes the same php-builder path as 8.3, so the apt cache in this
branch is exactly what it needs, and the per-version key means it caches
its own ~70 -dev packages rather than sharing with the ondrej-path jobs.
composer test passes on 8.5.9 locally: 915 tests, 2592 assertions.
Refs #178.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
Run 536 proved the cache works -- ACTIONS_CACHE_URL is now populated, no
GHES warning, "Cache saved with key: apt-php-builder-deps-ARM64-v1" -- and
in doing so showed the key was wrong. The jobs that saved it were 8.1 and
8.2.
They take the ondrej PPA path, a handful of runtime packages, while 8.3
takes php-builder and its ~70 -dev packages. One shared key therefore lets
whichever job finishes first decide what every other job restores, and 8.1
is always first, at ~30s against 8.3's several minutes. 8.3 would have
restored a few runtime .debs it has no use for and then downloaded all 70
anyway. My "the dep list is the same for every PHP version" comment was
true only among the builder versions.
Keyed per version now, so each path caches what it actually installs. That
also picks up a small win on 8.1 and 8.2 rather than only avoiding harm.
Both temporary steps are gone. Report restored .debs was there to show the
cache URL arriving and the restore landing; both are established, so it
goes. Keep downloaded .debs stays -- it is not instrumentation, it disables
Ubuntu's docker-clean, without which there are no .debs left to cache.
Refs #178.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
thatguygriff/infra#1 turned the runner cache server on, so actions/cache
has an endpoint for the first time. v4 rather than v3 because v4.2+ can
speak the cache service v2 API, which is what the runner serves.
The instrumentation step now also prints ACTIONS_CACHE_URL, so a single
run shows the endpoint arriving and the cache being read in one log
rather than needing a separate probe.
Refs #178.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
Experiment for #178. On self-hosted runners setup-php installs PHP 8.3+
through php-builder, whose install.sh apt-installs ~70 -dev packages
before unpacking the build. The build tarball itself is only 19MB, so
that apt work is the whole cost, not the download.
Ubuntu's image drops the .debs after install, so every job fetches them
from the archive again. This keeps them and restores them through the
cache server, which lives in the cluster, so a WAN download becomes a
local one. The dependency list does not vary by PHP version, so a single
key serves 8.3 and 8.5 and the quality and build jobs alike.
Only the .debs are cached. /var/lib/apt/lists is deliberately left alone,
since a stale index is how apt starts 404ing mid-install, and reducing
flakiness is the entire point.
The Report restored .debs step is temporary instrumentation to show
whether the cache is actually being read. Baseline to beat, from run 526:
8.1 30s, 8.2 53s, 8.3 454s, quality 151s, 8.5 733s and a hard failure.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks