26 Commits
Author SHA1 Message Date
thatguygriff 28046e0fd1 Merge pull request 'Return to a bookable calendar after a booking is confirmed' (#158) from fix/143-return-to-bookable into main
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / No Debug Code (push) Successful in 1s
CI / Build Plugin Zip (push) Successful in 2m49s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m59s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
Reviewed-on: #158
2026-07-30 02:02:51 +00:00
thatguygriffandClaude Opus 5 699e479805 Return to a bookable calendar after a booking is confirmed
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m46s
showConfirmation() hid the slot list and put the confirmation in its place,
which is a dead end: a student wanting a second lesson had nothing to click
and no way back short of reloading the page. Enrolling in a group class did
the same thing.

The confirmation is now a dismissible notice above the calendar. The
calendar is reloaded first — so the slot just taken is already gone and the
upcoming-lessons panel is current — and the notice is shown over it, which
is why loadSlots() had to start returning its promise. "It worked" and "book
another" are the same screen.

The notice clears when dismissed, when another slot's form is opened, and on
any reload of the calendar. group-classes.js gets the identical treatment.

It is built from DOM nodes rather than innerHTML because the message can
carry a studio's e-transfer address, and it is toggled with the `hidden`
attribute rather than an inline display — an inline style would outrank the
stylesheet's display:flex and stack the notice's parts. `hidden` needs the
!important guard for the same reason the upcoming-lessons panel does: the
div{display:block} theme reset outranks the UA sheet.

The slotList/list `display = 'block'` lines went with it. Nothing hides
those any more, so restoring them each load only implied otherwise.

Verified in a headless browser against a stubbed REST API: booking twice in
a row without a reload, the booked slot leaving the calendar, the upcoming
panel updating, dismissal, the notice clearing when the next form opens, and
the notice staying hidden under div{display:block}.

Closes #143

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:57:16 -03:00
thatguygriff 1b42d20541 Merge pull request 'Add an account block showing who is signed in' (#156) from feature/142-account-block into main
CI / Build Plugin Zip (push) Successful in 2m48s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / PHPStan (push) Successful in 2m53s
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m44s
CI / No Debug Code (push) Successful in 2s
Reviewed-on: #156
2026-07-30 01:53:31 +00:00
thatguygriffandClaude Opus 5 ab5212282d Show only the name and email, not who the account books for
CI / Tests (PHP 8.2) (pull_request) Successful in 41s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
The block reports who is signed in and nothing more. Dropping the "Booking
for …" line takes GuardianService with it — it was the only reason the page
had a dependency at all, so AccountPage now constructs with no arguments.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:42:25 -03:00
thatguygriffandClaude Opus 5 6e3affb1cb Add an account block showing who is signed in
[us_account], or the Account block: the signed-in visitor's name, their
email, a Sign out link, and — only when the account books for someone
besides itself — the students it books for. A parent's first question on
seeing "signed in as Grace" is whether this is the account their children's
lessons are on.

Two decisions worth naming.

Signed out with no login page chosen, the block renders nothing. Its whole
subject is the person signed in, which a stranger is not, and a bare "you
are not signed in" in a site header is noise with no way to act on it. With
a login page chosen it offers a Sign in link instead. The editor preview is
populated regardless, so the block is never an invisible box to the person
placing it.

Signing out returns to the chosen login page, or to the current page when
there is none. A block meant for a header should not also navigate someone
somewhere when they use it; the login page wins when configured, because the
page they were on may well be members-only.

The name comes from UserName::format(), so the block never exposes a
username the way display_name can.

Also brings docs/features/editor-blocks.md back in step: it still described
"four shortcodes" and had never listed the family block.

Closes #142

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:42:25 -03:00
thatguygriff 7875cb1bf7 Merge pull request 'Fix main: two signup fixtures use a now-rejected password' (#157) from fix/green-main into main
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.1) (push) Successful in 57s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m51s
CI / Tests (PHP 8.3) (push) Successful in 2m46s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #157
2026-07-30 01:41:29 +00:00
thatguygriffandClaude Opus 5 04cba9702c Fix main: two signup fixtures use a now-rejected password
CI / Tests (PHP 8.2) (pull_request) Successful in 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m46s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m49s
CI / Build Plugin Zip (pull_request) Skipped
#154 and #155 each passed on their own branch and broke on landing
together. #154 added two guardian-signup tests using 'password123' as
their fixture; #155 then added PasswordPolicy, which rejects exactly
that. Neither branch ever saw the other's change, because #155 was cut
from main before #154 merged.

Both tests now use the same policy-clearing fixture as the rest of the
file. The deliberate 'password123' in the rejected-passwords provider
stays — that one is the point.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:40:51 -03:00
thatguygriff d554e35d80 Merge pull request 'Validate signup email and password strength' (#155) from feature/150-signup-credential-validation into main
CI / Tests (PHP 8.2) (push) Failing after 43s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Failing after 2m44s
CI / Build Plugin Zip (push) Skipped
CI / Tests (PHP 8.1) (push) Failing after 50s
Reviewed-on: #155
2026-07-30 01:27:05 +00:00
thatguygriffandClaude Opus 5 b5b9a7ac54 Validate signup email and password strength
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
The password was only ever checked for length. It is now checked on both
sides, with each side doing the job it can actually do.

The browser scores it with zxcvbn, through WordPress's own
password-strength-meter script rather than a second opinion of our own, and
refuses to submit below "medium". That is the nuanced test — it knows
Tr0ub4dor&3 is weaker than it looks — but it is advice a client can decline
to take.

Auth\PasswordPolicy runs on the server and is the rule that holds. It does
not try to reproduce a strength score in PHP; it rejects the categorically
bad, which is what a server can check without shipping a dictionary: too
short, a well-known leaked password, fewer than four distinct characters, or
the user's own name or email inside it. No composition rules — NIST advises
against them, and they mostly produce predictable substitutions.

Both thresholds come from the same two constants, handed to JavaScript by
wp_localize_script, so the sides cannot drift into disagreeing about what
was accepted.

The verdict is attached to the field with setCustomValidity() rather than by
disabling a button. The form has up to three submits plus a "Next" that
already gates on checkValidity(), and an invalid field stops all of them
without any of them needing to know why.

Email validation moved ahead of the password check, since the password is
now checked against the email. A blank form therefore reports the email
first, which also matches the order the fields appear in.

Verified the browser half against a controllable scorer: each score band
blocks or allows as intended, the identity list reaches the meter, and the
gate stays open while zxcvbn's dictionary is still loading — the server
covers that window.

Closes #150

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:13:31 -03:00
thatguygriff f0149042cc Merge pull request 'Require a name and birth year for every student' (#154) from feature/148-required-name-and-birth-year into main
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m53s
CI / Coding Standards (push) Successful in 2m59s
CI / Tests (PHP 8.3) (push) Successful in 2m45s
CI / Build Plugin Zip (push) Successful in 2m45s
Reviewed-on: #154
2026-07-30 00:04:51 +00:00
thatguygriffandClaude Opus 5 1d2f95d388 Require a name and birth year for every student
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Coding Standards (pull_request) Successful in 2m56s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
Both fields are marked in their labels the same way a required registration
question is, and enforced on the server whichever form they arrive from:
GuardianService::createChild() and updateChild() now refuse a blank name or
an unusable birth year, and the signup form checks the same rule up front,
before it creates a single user, so a bad block never leaves a
half-registered family behind. normaliseBirthYear() became public and static
so both paths share one definition of what a usable year is.

The signup form cannot lean on the browser here. Its child blocks are hidden
until the parent/guardian box is ticked, and a `required` field inside a
hidden container makes the whole form unsubmittable with no control the user
can reach to fix — the same trap the guardian's own question panel already
sidesteps by disabling rather than hiding. So register.js puts `required` on
and takes it off along with the block itself, and the server is what makes
the rule hold with JavaScript off. The profile screen has no such problem:
its forms are always visible, so the attribute is static there.

One behaviour change beyond the requirement: a child block with anything
typed into it is now reported back instead of dropped. Previously any block
without a name was silently discarded, which would now mean losing a birth
year the guardian had filled in. A wholly untouched spare block — the one
the form always renders for "add another" — is still ignored.

Verified the required-toggling in a headless browser: unticked submits,
ticked blocks an empty block, a cloned block inherits the requirement, and
re-unticking leaves nothing behind to block a non-guardian signup.

Closes #148

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:00:45 -03:00
thatguygriff 2878beb221 Merge pull request 'Collect a birth year instead of a full date of birth' (#153) from feature/147-birth-year into main
CI / Tests (PHP 8.2) (push) Successful in 50s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m47s
CI / Build Plugin Zip (push) Successful in 2m35s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m44s
Reviewed-on: #153
2026-07-29 23:53:37 +00:00
thatguygriffandClaude Opus 5 7e2bba79fe Collect a birth year instead of a full date of birth
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Signup and the profile page now ask for a four-digit year between 1900 and
the current year. Anything else — a short year, a full date, a year in the
future — is discarded rather than stored, so a typo cannot leave a nonsense
age on the record.

The year lives in a new us_birth_year user meta rather than reusing
us_date_of_birth, which would have left one key holding two formats. The old
key is not migrated in bulk. Instead GuardianService handles it in two
halves: birthYear() falls back to the year of the old date when the new key
is absent, so a student added before this change still shows one, and
setBirthYear() deletes the old date on every save.

That deletion is what makes the fallback safe rather than merely tidy.
Without it, clearing the birth year on a student who predates the change
would leave the old date behind for the fallback to read straight back, and
the year could never be cleared at all.

Stored in user meta, so no Schema.php change and no USC_VERSION bump.

Closes #147

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:47:56 -03:00
thatguygriff 3a83decc82 Merge pull request 'Say "student" and "profile" in the UI, not "child" and "family"' (#152) from refactor/144-student-profile-copy into main
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / PHPStan (push) Successful in 2m47s
CI / Coding Standards (push) Successful in 2m54s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #152
2026-07-29 23:41:55 +00:00
thatguygriffandClaude Opus 5 76caf178f0 Say "student" and "profile" in the UI, not "child" and "family"
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 40s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
Sweep the translatable strings across the frontend templates, the admin
screens, the editor previews and the block inserter entry. Nothing else
moves: the database columns, request parameters, form field names, CSS
classes, the us_family shortcode and the us-scheduler/family block name are
contracts with existing installs and with post content people have already
saved, so renaming them would break sites for no user-visible gain.

Two strings are reworded rather than swapped, because the direct
substitution reads wrong:

- The students list said "Child of Jane" and now says "Managed by Jane".
  "Student of Jane" would read as a teacher's pupil, which is exactly the
  wrong idea in a music studio.
- A managed account is now "a managed student account" rather than "a
  student account", which would not distinguish it from the account holder.

The guardian feature doc gains a short section on the split, so the next
person to work on it does not read the mismatch as drift and "fix" it.

Closes #144

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:34:55 -03:00
thatguygriff 8013d05d68 Merge pull request 'Stop upcoming lesson rows rendering on top of each other' (#151) from fix/149-upcoming-lesson-overlap into main
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / PHPStan (push) Successful in 2m50s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m51s
Reviewed-on: #151
2026-07-29 23:29:18 +00:00
thatguygriffandClaude Opus 5 6b29c0e78e Stop upcoming lesson rows rendering on top of each other
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m48s
CI / Build Plugin Zip (pull_request) Skipped
The panel's row and its two columns are divs with explicit flex rules, but
the text itself still sits in inline elements. A theme is free to take those
out of normal flow, and when it does the date and time land on the lesson
title and the status pill lands on the Cancel button. Pin position, float
and margin on the leaf elements at the same id-level specificity the rest of
the panel already uses, so a theme rule cannot lift them out of the column.

The rows behind "Show all" had the same shape of problem from the other
direction: `[hidden]` is only a UA-stylesheet rule, so the `div {
display: block }` reset that many themes still ship outranks it and the
collapsed rows render anyway. An author `!important` is the only way to win
that particular cascade.

Verified with a headless-browser harness rendering the exact markup
booking.js emits against twelve theme CSS patterns at two widths: before,
five patterns overlapped text or revealed the hidden rows; after, all pass.

Closes #149

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:28:25 -03:00
thatguygriff 7ea6616ba0 Merge pull request 'Trim CLAUDE.md to what the codebase can't tell you' (#141) from docs/trim-claude-md into main
CI / Tests (PHP 8.1) (push) Successful in 50s
CI / Coding Standards (push) Successful in 2m53s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 42s
CI / PHPStan (push) Successful in 2m46s
CI / Tests (PHP 8.3) (push) Successful in 2m49s
CI / Build Plugin Zip (push) Successful in 2m45s
Reviewed-on: #141
2026-07-29 19:41:35 +00:00
thatguygriffandClaude Opus 5 7fdf97b073 Trim CLAUDE.md to what the codebase can't tell you
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / PHPStan (pull_request) Successful in 3m0s
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Skipped
Most of this file described the repo as it was around v1.0: three domain
packages, two database tables, twenty-one classes. There are now eleven
packages, fifteen tables, and well over a hundred classes, so those
sections were not just redundant with `ls` and Schema.php — they were
teaching the wrong shape of the codebase. Same for the CI section, which
had drifted past the build job.

Cut the command list (composer.json has the scripts), the bootstrap
description, the directory tree, the table list, the Key Classes table,
and the CI job summary. Kept every rule the code can't explain on its
own: package-by-domain, no $wpdb outside repositories, capability checks
rather than role names, and the Schema.php version-bump gotcha.

Moved the Brain\Monkey and Mockery gotchas to tests/CLAUDE.md, which
loads only when working under tests/ instead of in every session.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 16:39:43 -03:00
thatguygriff d3843186c0 Merge pull request 'Bump version to 1.3.1' (#140) from release/bump-1.3.1 into main
CI / Tests (PHP 8.2) (push) Successful in 48s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m45s
CI / Tests (PHP 8.1) (push) Successful in 43s
Reviewed-on: #140
2026-07-29 19:28:42 +00:00
Release Bot e44972abe9 Bump version to 1.3.1 and open changelog section 2026-07-29 19:22:36 +00:00
thatguygriff 3c41d1119d Merge pull request 'Let parents register once and book for their children' (#139) from feature/parent-guardian-accounts into main
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Successful in 2m45s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / Coding Standards (push) Successful in 3m1s
Release / Build and Publish Release (push) Successful in 3m1s
Release / Open next-version bump PR (push) Successful in 3s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #139
2026-07-29 19:17:05 +00:00
thatguygriffandClaude Opus 5 8122c158cf Keep the guardian service within the PHP the plugin supports
CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
`true` as a return type is PHP 8.2, but the plugin advertises 8.1, so the
family screen's two service calls fataled on the 8.1 test job while every
other job passed. They now return `?\WP_Error` — null on success — which
matches RegistrationGate::validate() and works on 8.1.

PHPStan was analysing against whatever PHP happened to be running (8.3 in
CI, newer locally), so `composer lint` was green on syntax the plugin
promises not to use. It is now pinned to the supported 8.1-8.3 range, which
reproduces this failure at lint time instead of three jobs later.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 16:07:52 -03:00
thatguygriffandClaude Opus 5 b772e1811e Let parents register once and book for their children
A parent registers once and manages lessons for one or more children, who
need no login of their own. A child is a real wp_users row with the student
role but no usable login — so student_id keeps meaning "a WordPress user"
on every table, and booking, credits, policies and enrolments work unchanged.
A us_guardians link table maps guardian to child.

The signup form gains a parent/guardian tick that reveals a block per child,
with the account-signup questions asked per child rather than per guardian
— they describe the student, not the account holder. Signup policies are
recorded once per child with the guardian as the acceptor, which is the
record that actually means something. A family that half-creates is rolled
back entirely rather than leaving a guardian who cannot re-register.

The booking and enrolment forms gain a "Who is this for?" picker listing
children first, so the default selection is never the parent — booking for
the wrong child is correctable, quietly billing a parent for their kid's
lesson is not. POST /bookings and POST /enrollments take an optional
student_id honoured only for that child's guardian; anything else is a 403.
That check is the authorisation boundary of the feature.

Payments and credits gain a payer: the charge names the child it was for and
the guardian who owes it, so per-child reporting is unchanged while notices,
receipts and the payment step reach the parent. Credit is held by the payer,
so one child's cancellation can settle a sibling's charge, and the daily
billing scan sends a guardian one notice covering every child.

Closes #132

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 16:07:52 -03:00
thatguygriff c25260a367 Merge pull request 'Bump version to 1.2.5' (#138) from release/bump-1.2.5 into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m51s
CI / PHPStan (push) Successful in 3m0s
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #138
2026-07-29 02:29:57 +00:00
Release Bot 96aaeff79c Bump version to 1.2.5 and open changelog section 2026-07-29 02:29:47 +00:00
81 changed files with 5661 additions and 324 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"Bash(composer lint *)",
"Bash(tea actions:*)",
"Bash(tea issue *)",
"Bash(tea label *)"
"Bash(tea label *)",
"Bash(composer cs *)"
]
}
}
+29
View File
@@ -11,6 +11,35 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work.
## [1.3.1]
### Added
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
### Security
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
### Changed
- A student's **name and birth year are now required**, marked in the form the same way a required registration question is and enforced on the server whichever way they were submitted. On signup the requirement applies only once the parent/guardian box is ticked, so registering for yourself is unaffected. A student block you have started filling in is now reported back to you rather than silently dropped when the name is missing — only a completely untouched spare block is still ignored.
- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
### Fixed
- **Booking a lesson no longer dead-ends on the confirmation.** The confirmation used to replace the calendar entirely, leaving a student who wanted a second lesson with nothing to click and no way back short of reloading the page. It is now a dismissible notice sitting above a freshly loaded calendar — the slot just taken already gone from it, the upcoming-lessons panel already updated — so "it worked" and "book another" are the same screen. Enrolling in a group class did the same thing and is fixed the same way.
- Upcoming lesson rows no longer render on top of each other. The row's text sits in inline elements that a theme can pull out of normal flow, which dropped the date and time onto the lesson title and the status pill onto the Cancel button; those elements are now pinned into flow alongside the rest of the panel's theme-proofing. The rows held behind **Show all** also stayed visible under the `div { display: block }` reset that many themes still carry, since `[hidden]` is only a browser default — they are now hidden for real.
## [1.3.0]
### Added
- **Parent and guardian accounts.** A parent registers once and manages lessons for one or more children, who need no login of their own. The signup form gains an **"I'm registering as a parent or guardian"** tick that reveals a block per child — name, date of birth, and the studio's account-signup questions asked **per child**, since those describe the student rather than the account holder. Signup policies are recorded once per child with the guardian named as the person who agreed, which is the record that actually means something: "this guardian accepted version N on behalf of this child, at this time, from this address." A guardian can also be a student themselves and book their own lessons from the same account.
- A **"Who is this for?"** picker on the booking and group-class forms, listing **children first** and the account holder last — so the default selection is never the parent, and a lesson meant for a child is not quietly booked and billed in the parent's name. An account with only itself on the list sees no picker and behaves exactly as before. A guardian's upcoming-lessons panel covers the whole household, each row naming whose lesson it is, and they can cancel or withdraw for any of their children.
- A **Family** page for guardians (`[us_family]`, or the **Family** block) to add, edit and remove children after signup. Removing a child is refused once they have lessons or enrolments on record — that history belongs to them, and the studio unpicks it by hand rather than the page orphaning it.
- **One family, one bill.** Payments record the child the lesson was for *and* the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step all go to the parent. Account credit is held by the payer, so a credit from one child's cancelled lesson can settle a sibling's next charge, and the billing-method override (comp / card / e-transfer) is one setting on the guardian rather than one per child. The daily billing scan sends a guardian **one** notice covering every child, with each line naming whose lesson it is.
- **Students** in wp-admin gains a **Family** column linking a child to their guardian and a guardian to their children, and the student screen gains a **Family** panel. A child's row shows the guardian's email — a child's own address is a placeholder that can never receive mail — and their credit balance is labelled with whose account actually holds it.
### Changed
- Child accounts **cannot be signed in to**. They hold the student role so every existing lookup keeps working, but authentication is refused outright and the booking capability is withheld, so the only route to a lesson in a child's name is their guardian's authorised booking.
## [1.2.4]
### Fixed
+4 -88
View File
@@ -4,100 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands
```bash
composer install # Install all dependencies
composer test # Run the full test suite (required after every change)
composer lint # PHPStan static analysis
composer cs # PHPCS coding standards check
composer cs:fix # Auto-fix coding standards
# Run a single test file
./vendor/bin/phpunit tests/Unit/Availability/AvailabilityRepositoryTest.php
# Run a single test by name
./vendor/bin/phpunit --filter testInsertCallsWpdbInsertAndReturnsId
```
**Run `composer test` after every code change before considering a task complete.**
## Architecture
### Plugin Bootstrap
`unsupervised-schedular.php` defines constants (`USC_VERSION`, `USC_PLUGIN_DIR`, `USC_PLUGIN_URL`), registers activation/deactivation hooks, then calls `Plugin::boot()` on `plugins_loaded`. No logic lives in the root file.
### Directory Structure
```
src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedular\)
Availability/ — Availability slots: value object, repository, controller, REST endpoint
Booking/ — Lessons/bookings: value object, repository, controller, REST endpoint, shortcode page
Auth/ — Roles, capabilities, login page
Plugin.php — Wires all components together on plugins_loaded
Installer.php — Creates DB tables and roles on activation
Schema.php — CREATE TABLE SQL for dbDelta
AdminMenu.php — Registers wp-admin menu pages
RestRegistrar.php — Registers all REST routes under us-scheduler/v1
ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes
BlockRegistrar.php — Registers Gutenberg dynamic-block wrappers for the shortcodes
BlockPreview.php — Static editor-preview markup for the blocks
templates/ — PHP view files included by controllers/shortcodes
assets/ — CSS and JS (vanilla JS, no build step)
tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\)
Availability/ — Tests for src/Availability/
Booking/ — Tests for src/Booking/
Auth/ — Tests for src/Auth/
docs/features/ — One markdown file per feature describing data model, API, and test locations
```
**Code is organised package-by-domain** (Availability, Booking, Auth). Each domain package contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
### Code organisation
**Code is organised package-by-domain.** Each domain package under `src/<Domain>/` contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
### Data Storage
Two custom database tables (created via `dbDelta` on activation):
- `{prefix}us_availability` — instructor availability windows
- `{prefix}us_lessons` — booked lessons
Custom database tables are created via `dbDelta` on activation; `Schema.php` holds the SQL.
All database access goes through repository classes within their domain package. No direct `$wpdb` calls outside repositories.
### Key Classes
| Class | Responsibility |
|---|---|
| `Plugin` | Wires all components together on `plugins_loaded` |
| `Installer` | Creates DB tables and roles on activation |
| `Schema` | CREATE TABLE SQL strings for dbDelta |
| `AdminMenu` | Registers wp-admin menu pages |
| `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
| `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
| `BlockRegistrar` | Registers Gutenberg dynamic-block wrappers for the shortcodes |
| `BlockPreview` | Static editor-preview markup for the blocks |
| `Val` | Runtime coercion of untyped WP boundary values (wpdb rows, REST params, superglobals) |
| `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
| `Auth\LoginPage` | Renders front-end student login form |
| `Availability\AvailabilitySlot` | Immutable value object for a slot row |
| `Availability\AvailabilityRepository` | CRUD for availability slots |
| `Availability\AvailabilityController` | Instructor availability management page |
| `Availability\AvailabilityEndpoint` | REST handlers for availability CRUD |
| `Booking\Lesson` | Immutable value object for a lesson row |
| `Booking\BookingRepository` | CRUD for lesson bookings |
| `Booking\BookingEndpoint` | REST handlers for booking and status updates |
| `Booking\BookingPage` | Renders student booking UI shell (JS takes over) |
| `Booking\LessonController` | Admin and instructor lesson list pages |
### REST API Namespace
All endpoints live under `/wp-json/us-scheduler/v1/`. Permissions are enforced via `permission_callback` using capability checks (`manage_availability`, `book_lesson`), never role name checks.
### Testing Approach
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to stub WordPress functions without a full WP installation, and Mockery to mock `$wpdb` and other dependencies.
All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()` / `Monkey\tearDown()` and stubs all WP translation/escape functions automatically.
**Brain\Monkey API notes:**
- `Functions\when('fn')->alias(fn() => ...)` — stub with a closure (NOT `returnUsing()`)
- `Functions\when('fn')->justReturn($val)` — stub returning a fixed value
- `Functions\expect('fn')->once()->with(...)` — assert call count and arguments
- Use `Functions\when()` (not `Functions\expect()`) when you need argument-routing (e.g. `get_role` returning different values per argument) to avoid chaining ambiguity
- Mockery matchers (e.g. `\Mockery::type()`) inside plain PHP arrays do not work with `with()` — use `\Mockery::on(fn($arr) => ...)` or `\Mockery::any()` instead
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
Tests stub WordPress with Brain\Monkey rather than booting a real WP install. The setup and the Brain\Monkey/Mockery API gotchas are in `tests/CLAUDE.md`.
### Adding a Feature
0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently.
@@ -106,10 +29,3 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
3. Add template(s) under `templates/` if needed.
4. Write unit tests under `tests/Unit/<Domain>/` mirroring the `src/<Domain>/` structure.
5. Run `composer test` — all tests must pass before the feature is complete.
### CI
Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request:
- **lint** — PHPCS WordPress coding standards
- **static-analysis** — PHPStan level 10
- **test** — PHPUnit on PHP 8.1, 8.2, 8.3
- **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/`
+227
View File
@@ -99,6 +99,36 @@
align-items: center;
}
/*
* Theme-proofing for the leaf text. The row and its two columns are divs with
* explicit flex rules above, but the text itself still sits in inline elements
* a theme is free to take out of normal flow — an absolutely positioned,
* floated or negatively offset span drops the date/time on top of the title and
* the status pill on top of the Cancel button. Pinning the three properties
* that would have to change keeps the leaves in flow, at the same id-level
* specificity the rules above rely on.
*/
#us-booking-app .us-my-lesson-title,
#us-booking-app .us-my-lesson-when,
#us-booking-app .us-my-lesson-duration,
#us-booking-app .us-my-lesson-who,
#us-booking-app .us-lesson-status {
position: static;
float: none;
margin: 0;
}
/*
* The rows the "Show all" button reveals. `[hidden]` is only a UA-stylesheet
* rule, so any author rule setting a display on div beats it — the html5-reset
* `div { display: block }` is still widespread in themes — and the rows the
* button is meant to gate render anyway. An author !important is the only way
* to win that cascade.
*/
#us-booking-app [hidden] {
display: none !important;
}
#us-booking-app .us-show-all-lessons {
background: transparent;
border: 1px solid #ccc;
@@ -388,6 +418,203 @@
}
}
/*
* "Who is this for?" picker — booking and enrolment. Present only on an account
* that books for more than one person, so it is styled as a normal field rather
* than a callout.
*/
.us-student-picker select {
max-width: 100%;
}
/*
* Whose lesson a row in the upcoming panel is — only shown on an account that
* books for more than one person. Scoped under #us-booking-app like the rest of
* the panel; as a bare class it was the one rule in the group a theme could
* outrank on a plain span.
*/
#us-booking-app .us-my-lesson-who {
font-weight: normal;
opacity: 0.75;
}
/* Parent/guardian signup: the child blocks revealed by the checkbox. */
.us-guardian {
margin: 16px 0;
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 4px;
}
.us-guardian legend {
padding: 0 6px;
font-weight: 600;
}
.us-children-intro {
margin-top: 0;
font-size: 0.9em;
opacity: 0.8;
}
/*
* Each child is a bordered group so a family of three does not read as one long
* undifferentiated column of fields.
*/
.us-child {
margin-bottom: 12px;
padding: 10px 12px;
border-left: 3px solid #ddd;
background: #fafafa;
}
.us-child > p:last-child {
margin-bottom: 0;
}
/* The guardian's manage-children screen ([us_family]). */
.us-family-list {
margin: 0 0 20px;
padding: 0;
list-style: none;
}
.us-family-child {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.us-family-child-name {
font-weight: 600;
}
.us-family-child-birth-year {
font-size: 0.9em;
opacity: 0.75;
}
/*
* The actions sit at the far end of the row. Remove is its own form (it posts),
* so it is forced inline rather than taking a block of its own.
*/
.us-family-child-actions {
display: flex;
gap: 10px;
align-items: baseline;
margin-left: auto;
}
.us-family-remove {
display: inline;
}
/* The editing row replaces the child's line, so it spans the whole width. */
.us-family-edit {
width: 100%;
}
@media (max-width: 640px) {
/* A name, a date and two actions do not fit one narrow line. */
.us-family-child-actions {
margin-left: 0;
width: 100%;
}
}
/*
* The live password verdict under the signup field. Colour is a reinforcement,
* not the message — the text says what is wrong on its own, so this still reads
* correctly to anyone who cannot separate the hues.
*/
.us-password-strength {
display: block;
margin-top: 4px;
font-size: 0.85em;
}
.us-password-strength.is-short,
.us-password-strength.is-weak {
color: #c00;
}
.us-password-strength.is-medium {
color: #7a5c00;
}
.us-password-strength.is-strong {
color: #1a7d2e;
}
/*
* The account panel: who is signed in, and the way out. Sized to sit in a
* header or sidebar, so the rules stay minimal and inherit the theme's type —
* a block that lands in a site header should look like it belongs there.
*/
.us-account p {
margin: 0 0 4px;
}
.us-account-name {
font-weight: 600;
}
.us-account-email {
display: block;
font-size: 0.9em;
opacity: 0.75;
}
.us-account-actions {
margin-top: 8px;
}
/*
* `[hidden]` is a UA-stylesheet rule, so the widespread `div { display: block }`
* theme reset outranks it — the same trap the upcoming-lessons panel hit. An
* author !important is the only way to win, and it has to sit before the
* display rule it guards against.
*/
.us-notice[hidden] {
display: none !important;
}
/*
* The "you're booked" / "you're enrolled" notice. It sits above the calendar
* or class list rather than replacing it, so it needs to read as a banner
* about something that just happened — not as the page's content.
*/
.us-notice {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 8px 16px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid #b7dfc0;
border-left-width: 4px;
border-radius: 4px;
background: #f2faf4;
color: #1a5c2a;
}
.us-notice p {
margin: 0;
}
.us-notice-dismiss {
background: transparent;
border: 1px solid currentColor;
border-radius: 4px;
padding: 4px 12px;
color: inherit;
cursor: pointer;
}
/* Shown only in block-editor previews (see BlockPreview). */
.us-editor-note {
font-size: 0.85em;
+47
View File
@@ -282,6 +282,53 @@
})
),
},
{
name: 'us-scheduler/family',
title: __('Profile', 'unsupervised-schedular'),
description: __('Lets a parent or guardian add, edit and remove the students they book lessons for.', 'unsupervised-schedular'),
icon: 'groups',
// 'family' and 'children' are kept as search terms only — they are
// never displayed, and the block answered to them before it was
// renamed, so anyone reaching for the old word still finds it.
keywords: ['profile', 'students', 'family', 'children', 'guardian', 'parent'],
shortcode: 'us_family',
attributes: {
loginPageId: { type: 'number', default: 0 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Login page', 'unsupervised-schedular'),
help: __('Where visitors who are not signed in are sent to log in.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
})
),
},
{
name: 'us-scheduler/account',
title: __('Account', 'unsupervised-schedular'),
description: __('Shows the name and email of whoever is signed in, with a sign out link. Renders nothing for signed-out visitors unless a login page is chosen.', 'unsupervised-schedular'),
icon: 'admin-users',
keywords: ['account', 'sign out', 'log out', 'signed in', 'profile'],
shortcode: 'us_account',
attributes: {
loginPageId: { type: 'number', default: 0 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Signing in and out', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Login page', 'unsupervised-schedular'),
help: __('Where signing out returns to, and where signed-out visitors are offered a link to sign in. Without one, signing out returns to the current page and signed-out visitors see nothing.', 'unsupervised-schedular'),
defaultLabel: __('Stay on the current page', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
})
),
},
];
blocks.forEach((def) => {
+69 -11
View File
@@ -16,6 +16,11 @@
const pinnedTypeId = Number(app.dataset.lessonType) || 0;
const filterEnabled = app.dataset.typeFilter !== '0';
// Who this account may book for — children first, the account holder last,
// so a guardian's default selection is a child rather than themselves. A
// single-student account has one entry and gets no picker.
const students = window.usGuardian.parseStudents(app.dataset.students);
function apiFetch(path, options = {}) {
return fetch(restUrl + path, {
...options,
@@ -330,7 +335,12 @@
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
if (slot) btn.addEventListener('click', () => openRegistration(slot));
if (slot) {
btn.addEventListener('click', () => {
hideConfirmation();
openRegistration(slot);
});
}
});
}
@@ -453,6 +463,7 @@
<div class="us-register">
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}${escHtml(timeOf(slot.end_dt))}</h3>
<form id="us-register-form">
${window.usGuardian.selectorHtml(students, 'us-booking-student')}
${offeringFieldHtml(tied, tiedId, choices)}
<div id="us-questions"></div>
${policies.map(policyField).join('')}
@@ -553,6 +564,7 @@
body: JSON.stringify({
slot_id: slot.id,
offering_id: offeringId,
student_id: window.usGuardian.selectedId('us-booking-student'),
recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single',
answers,
accepted_policy_version_ids: accepted,
@@ -564,8 +576,11 @@
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
: null))
.then((result) => {
loadMyLessons();
showConfirmation(window.usPayment.message(result));
const message = window.usPayment.message(result);
// Order matters: loadSlots() clears any standing notice, and it
// is what puts the calendar back with the booked slot gone.
return loadSlots().then(() => showConfirmation(message));
})
.catch((err) => showError(err.message));
}
@@ -579,6 +594,15 @@
// How many upcoming lessons to show before the "Show all" reveal.
const INITIAL_LESSON_COUNT = 5;
// Whose lesson this is. Only shown on an account that books for more than
// one person — on a single-student account the name is on every row and says
// nothing.
function lessonWhoHtml(l) {
if (students.length < 2 || !l.student_name) return '';
return ` <span class="us-my-lesson-who">— ${escHtml(String(l.student_name))}</span>`;
}
function lessonRowHtml(l) {
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
@@ -588,7 +612,7 @@
return `
<div class="us-my-lesson">
<div class="us-my-lesson-info">
<strong class="us-my-lesson-title">${title}${duration}</strong>
<strong class="us-my-lesson-title">${title}${duration}${lessonWhoHtml(l)}</strong>
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
</div>
<div class="us-my-lesson-actions">
@@ -652,10 +676,43 @@
.catch(() => { myLessons.innerHTML = ''; });
}
/**
* Report a completed booking without taking the calendar away.
*
* This used to hide the slot list and leave the confirmation as the whole
* page, which is a dead end: the student had nothing to click and no way
* back to booking short of reloading. The notice now sits above a freshly
* loaded calendar, so "it worked" and "you can book again" are the same
* screen.
*
* Built from nodes rather than innerHTML because the message can carry a
* studio's e-transfer address.
*/
function showConfirmation(message) {
confirm.textContent = message;
slotList.style.display = 'none';
confirm.style.display = 'block';
confirm.textContent = '';
const text = document.createElement('p');
text.textContent = message;
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'us-notice-dismiss';
dismiss.textContent = 'Dismiss';
dismiss.addEventListener('click', hideConfirmation);
confirm.appendChild(text);
confirm.appendChild(dismiss);
// The `hidden` attribute rather than an inline display, which would
// outrank the stylesheet's `display: flex` and stack the notice's
// parts instead of laying them out in a row.
confirm.hidden = false;
}
function hideConfirmation() {
if (!confirm) return;
confirm.hidden = true;
confirm.textContent = '';
}
// The private-lesson catalog drives both the filter and the registration
@@ -680,16 +737,17 @@
});
}
/** Returns the load, so a caller can act once the calendar is back. */
function loadSlots() {
clearError();
loadMyLessons();
// An upcoming-lessons-only embed has no calendar to fill.
if (!slotList) return;
if (!slotList) return Promise.resolve();
slotList.style.display = 'block';
confirm.style.display = 'none';
Promise.all([apiFetch('availability'), loadCatalog()])
hideConfirmation();
return Promise.all([apiFetch('availability'), loadCatalog()])
.then(([slots]) => {
allSlots = slots;
render();
+52 -8
View File
@@ -17,6 +17,10 @@
// enrolment controls.
const singleOfferingId = Number(app.dataset.offering || 0);
// Who this account may enrol — children first, the account holder last, so a
// guardian's default selection is a child. One entry means no picker.
const students = window.usGuardian.parseStudents(app.dataset.students);
function apiFetch(path, options = {}) {
return fetch(restUrl + path, {
...options,
@@ -169,7 +173,10 @@
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => openEnrolment(offering));
btn.addEventListener('click', () => {
hideConfirmation();
openEnrolment(offering);
});
});
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
@@ -202,6 +209,7 @@
<div class="us-register">
<h3>${escHtml(offering.title)}</h3>
<form id="us-enrol-form">
${window.usGuardian.selectorHtml(students, 'us-enrol-student')}
${questions.map(questionField).join('')}
${policies.map(policyField).join('')}
${window.usPricing.summaryHtml(offering)}
@@ -239,6 +247,7 @@
method: 'POST',
body: JSON.stringify({
offering_id: offering.id,
student_id: window.usGuardian.selectedId('us-enrol-student'),
answers,
accepted_policy_version_ids: accepted,
}),
@@ -248,25 +257,60 @@
.then((res) => (res.payment
? window.usPayment.collect('enrollment', res.id, list)
: null))
.then((result) => showConfirmation(window.usPayment.message(result)))
.then((result) => {
const message = window.usPayment.message(result);
// Order matters: loadClasses() clears any standing notice, and
// it is what puts the list back showing the new enrolment.
return loadClasses().then(() => showConfirmation(message));
})
.catch((err) => showError(err.message));
}
/**
* Report a completed enrolment without taking the class list away. Hiding
* the list left the student on a dead-end screen with no way back to
* browsing short of a reload; the notice now sits above a freshly loaded
* list instead. Mirrors booking.js.
*
* Built from nodes rather than innerHTML because the message can carry a
* studio's e-transfer address.
*/
function showConfirmation(message) {
confirm.textContent = message;
list.style.display = 'none';
confirm.style.display = 'block';
confirm.textContent = '';
const text = document.createElement('p');
text.textContent = message;
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'us-notice-dismiss';
dismiss.textContent = 'Dismiss';
dismiss.addEventListener('click', hideConfirmation);
confirm.appendChild(text);
confirm.appendChild(dismiss);
// The `hidden` attribute rather than an inline display, which would
// outrank the stylesheet's `display: flex` and stack the notice's
// parts instead of laying them out in a row.
confirm.hidden = false;
}
function hideConfirmation() {
confirm.hidden = true;
confirm.textContent = '';
}
/** Returns the load, so a caller can act once the list is back. */
function loadClasses() {
clearError();
list.style.display = 'block';
confirm.style.display = 'none';
hideConfirmation();
// The student's own enrolments are fetched alongside the catalog so a
// class they already have an active enrolment in shows its status
// instead of offering to enrol them again (the API would reject the
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
Promise.all([
return Promise.all([
apiFetch('offerings?kind=group_class'),
apiFetch('enrollments'),
])
+68
View File
@@ -0,0 +1,68 @@
/**
* "Who is this for?" picker, shared by the lesson-booking and group-class
* registration forms.
*
* The list arrives from the server already ordered children-first, with the
* account holder last, and this module preserves that order: a guardian's
* default selection is their first child, never themselves. Booking for the
* wrong child is a correctable mistake; quietly enrolling the parent in a class
* meant for their kid is not.
*/
(function () {
'use strict';
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Read the server-rendered student list off a `data-students` attribute.
* Anything unparseable degrades to an empty list, which renders no picker
* and books for the signed-in user — the pre-guardian behaviour.
*/
function parseStudents(raw) {
if (!raw) return [];
try {
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch (e) {
return [];
}
}
/**
* The picker's markup, or an empty string when there is nothing to choose:
* an account with only itself on the list never sees the question.
*/
function selectorHtml(students, id) {
if (!students || students.length < 2) return '';
const options = students.map((s) => {
// The account holder reads as "Myself" — their own name next to their
// children's is ambiguous about which row is the parent.
const label = s.is_self ? `Myself (${s.name})` : s.name;
return `<option value="${Number(s.id)}">${escHtml(label)}</option>`;
}).join('');
return `
<p class="us-student-picker">
<label for="${id}">Who is this for?<br>
<select id="${id}" required>${options}</select></label>
</p>`;
}
/**
* The chosen student id, or 0 when no picker was rendered — the server
* reads 0 as "the caller books for themselves".
*/
function selectedId(id) {
const el = document.getElementById(id);
return el ? Number(el.value) || 0 : 0;
}
window.usGuardian = { parseStudents, selectorHtml, selectedId };
}());
+230 -10
View File
@@ -1,23 +1,134 @@
/**
* Progressive enhancement for the two-step student registration form.
* Progressive enhancement for the student registration form.
*
* When account-signup questions are configured the form renders two panels
* (`[data-step="1"]` account details, `[data-step="2"]` the questions) inside a
* single form marked `data-steps="1"`. This script hides step two behind a
* "Next" button that only advances once step one passes native validation.
* Without JS both panels stay visible and the single submit still works.
* Two independent behaviours, both optional — without JS every panel stays
* visible and the single submit still works:
*
* 1. **Two steps.** When account-signup questions are configured the form
* renders two panels (`[data-step="1"]` account details, `[data-step="2"]`
* the questions) inside a form marked `data-steps="1"`. Step two is hidden
* behind a "Next" button that only advances once step one passes native
* validation.
* 2. **Parent/guardian.** The children section is hidden until the
* parent/guardian box is ticked, and "Add another child" clones the child
* block. Ticking the box also takes the guardian's *own* question panel out
* of play — in guardian mode the questions are asked per child, so the
* server ignores those answers and the browser must not demand them.
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's
* own `wp.passwordStrength`) and a weak one is refused. The server applies
* its own, coarser rule regardless — see `Auth\PasswordPolicy`.
*/
(function () {
'use strict';
function enhance(form) {
var PASSWORD = window.usSchedulerPassword || {};
/**
* Gate the form on password strength.
*
* The verdict is attached to the field with `setCustomValidity()` rather than
* by disabling the submit button: the form has up to three submits (the plain
* one, the guardian-mode early one, and step two's) plus a "Next" that
* already gates on `checkValidity()`, and an invalid field blocks all of them
* at once without any of them having to know why.
*/
function enhancePassword(form) {
var field = form.querySelector('#us-reg-pass');
var output = form.querySelector('#us-reg-pass-strength');
var strings = PASSWORD.strings || {};
if (!field || !PASSWORD.minScore) {
return;
}
// What the password must not simply repeat back. Mirrors the identity
// check PasswordPolicy makes server-side.
function identity() {
var out = [];
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
for (var i = 0; i < sources.length; i++) {
var value = (sources[i].value || '').trim();
if (value) {
out.push(value);
if (value.indexOf('@') > 0) {
out.push(value.split('@')[0]);
}
}
}
return out;
}
function assess() {
var value = field.value || '';
if (!value) {
report('', '');
return;
}
if (value.length < (PASSWORD.minLength || 8)) {
report(strings.short, 'short');
return;
}
// zxcvbn's dictionary is fetched after load, and wp.passwordStrength
// reports -1 until it arrives. Say nothing and allow the submit in that
// window — the server still checks, and the next keystroke re-runs this
// once the dictionary is in.
if (!window.wp || !window.wp.passwordStrength || typeof window.zxcvbn === 'undefined') {
report('', '');
return;
}
var score = window.wp.passwordStrength.meter(value, identity(), '');
if (score < 0) {
report('', '');
return;
}
if (score >= 3) {
report(strings.strong, 'strong');
} else if (score >= PASSWORD.minScore) {
report(strings.medium, 'medium');
} else {
report(score <= 0 ? strings.veryWeak : strings.weak, 'weak');
}
}
/** Show the verdict, and make it the field's validity at the same time. */
function report(message, level) {
var acceptable = '' === level || 'medium' === level || 'strong' === level;
if (output) {
output.textContent = message || '';
output.className = 'us-password-strength' + (level ? ' is-' + level : '');
}
field.setCustomValidity(acceptable ? '' : message || '');
}
field.addEventListener('input', assess);
field.addEventListener('blur', assess);
// The identity check depends on these, so a password typed first and an
// email typed second is still caught.
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
for (var i = 0; i < sources.length; i++) {
sources[i].addEventListener('change', assess);
}
}
function enhanceSteps(form) {
var step1 = form.querySelector('[data-step="1"]');
var step2 = form.querySelector('[data-step="2"]');
var next = form.querySelector('.us-reg-next');
var back = form.querySelector('.us-reg-back');
if (!step1 || !step2 || !next) {
return;
return null;
}
function show(step) {
@@ -45,13 +156,122 @@
show(1);
});
}
return {
step2: step2,
next: next,
earlySubmit: form.querySelector('.us-reg-submit-early'),
};
}
/**
* Rewrite a cloned child block's `children[0][…]` names and ids to the new
* index, and clear the values carried over from the block it was cloned from.
*/
function reindex(block, index) {
block.setAttribute('data-child-index', String(index));
var fields = block.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
if (field.name) {
field.name = field.name.replace(/^children\[\d+\]/, 'children[' + index + ']');
}
var oldId = field.id;
if (oldId) {
field.id = oldId.replace(/^us-child-\d+-/, 'us-child-' + index + '-');
var label = block.querySelector('label[for="' + oldId + '"]');
if (label) {
label.setAttribute('for', field.id);
}
}
if (field.type === 'checkbox' || field.type === 'radio') {
field.checked = false;
} else {
field.value = '';
}
}
}
function enhanceGuardian(form, steps) {
var toggle = form.querySelector('#us-is-guardian');
var children = form.querySelector('#us-children');
if (!toggle || !children) {
return;
}
var addButton = children.querySelector('.us-add-child');
var nextIndex = 1;
// The guardian's own question panel is only meaningful when they are
// registering for themselves. Disabling it (rather than hiding it) is what
// stops a `required` question the server will ignore from blocking submit.
function sync() {
children.hidden = !toggle.checked;
// Each student's name and birth year are required, but only once the
// block is in play: a `required` field inside a hidden container makes
// the form unsubmittable with no way to reach the offending control, so
// the attribute goes on and comes off with the block itself. The server
// enforces the same rule either way.
var required = children.querySelectorAll('[data-us-child-required]');
for (var r = 0; r < required.length; r++) {
required[r].required = toggle.checked;
}
if (!steps) {
return;
}
var fields = steps.step2.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
fields[i].disabled = toggle.checked;
}
// With the questions out of play there is no second step to advance to,
// so "Next" would be a dead end — swap it for the submit.
steps.next.hidden = toggle.checked;
if (steps.earlySubmit) {
steps.earlySubmit.hidden = !toggle.checked;
}
}
toggle.addEventListener('change', sync);
sync();
if (addButton) {
addButton.addEventListener('click', function () {
var blocks = children.querySelectorAll('.us-child');
var clone = blocks[blocks.length - 1].cloneNode(true);
reindex(clone, nextIndex);
nextIndex += 1;
children.insertBefore(clone, addButton.parentNode);
// The clone carries the data attribute but not necessarily the
// current required state, so settle it the same way as the rest.
sync();
});
}
}
document.addEventListener('DOMContentLoaded', function () {
var forms = document.querySelectorAll('.us-register-form form[data-steps="1"]');
var forms = document.querySelectorAll('.us-register-form form');
for (var i = 0; i < forms.length; i++) {
enhance(forms[i]);
var steps = forms[i].getAttribute('data-steps') === '1'
? enhanceSteps(forms[i])
: null;
enhanceGuardian(forms[i], steps);
enhancePassword(forms[i]);
}
});
})();
+43
View File
@@ -77,6 +77,40 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
## Email and password validation
Both are checked on the server on every signup path, and the browser is given a
matching but *stricter* job so a bad password is caught before submitting.
**Email**`type="email"` and `required` in the markup, `is_email()` on the
server, then `email_exists()` for "an account already exists for this email". A
personal invite fixes the address and the server always uses the invite's own
value, so a tampered field is ignored rather than validated.
**Password**`Auth\PasswordPolicy` is the authority. It deliberately does
*not* try to reproduce a strength score in PHP; it rejects the categorically
bad, which is what a server can check without shipping a dictionary:
- shorter than `PasswordPolicy::MIN_LENGTH` (8 — NIST SP 800-63B's floor;
composition rules like "must contain a symbol" are deliberately **not** used,
as they push people towards predictable substitutions),
- one of the well-known leaked passwords,
- built from fewer than four distinct characters (`aaaaaaaa`, `abababab`),
- containing the user's own display name, email, or the part before the `@`.
The nuance happens in the browser. `register.js` scores the password with
zxcvbn through WordPress's own `password-strength-meter` script and refuses to
submit below `PasswordPolicy::MIN_SCORE` (2 of 4 — "medium"; enough to stop a
guessable password without demanding a passphrase to book a piano lesson). The
thresholds reach JavaScript via `wp_localize_script()` from the same constants
the server enforces, so the two cannot drift apart.
The verdict is applied with `setCustomValidity()` on the password field rather
than by disabling a button: the form has up to three submits plus a "Next" that
already gates on `checkValidity()`, and an invalid field stops all of them
without any needing to know why. zxcvbn's dictionary loads asynchronously, so
the gate stays open until it arrives — the server is the check that always runs.
## Registration Questions (signup step two)
When the studio has configured **account-scope** registration questions
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
@@ -165,3 +199,12 @@ No-op when no registration page is set.
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
- `tests/Unit/Auth/RegistrationMailerTest.php`
- `tests/Unit/Payment/StudioSettingsTest.php`
## Parent/Guardian Signup
The registration form also offers **"I'm registering as a parent or guardian"**,
which reveals a repeatable child block (name, birth year, and the
account-scope questions asked **per child**). Each child becomes a login-less
`us_student` user linked to the guardian, and the signup policies are recorded
once per child with the guardian as the acceptor. Available on every signup path
— personal invite, group link, and self-approval. See
`parent-guardian-accounts.md`.
+9
View File
@@ -121,3 +121,12 @@ refund of a shared payment.
- `tests/Unit/Payment/PaymentTest.php` (`netDue`)
- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel)
- `tests/Unit/Auth/StudentHistoryTest.php` (`creditBalance`, `credits`)
## Family Balances
A credit records the student it was earned for (`student_id`) and the account
that **holds** it (`payer_id`). Balance lookups — `availableBalance()`,
`findAvailableByPayer()`, `consume()` — key on the payer, so a family shares one
balance and a credit from one child's cancelled lesson can settle a sibling's
next charge. A child's admin screen still lists the credits their own
cancellations produced, labelled with whose account holds the balance. See
`parent-guardian-accounts.md`.
+29 -4
View File
@@ -1,8 +1,8 @@
# Editor Blocks
Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes,
so the pages can be previewed and styled inside the block editor instead of
appearing as grey shortcode text.
Gutenberg dynamic-block wrappers for the plugin's front-end shortcodes, so the
pages can be previewed and styled inside the block editor instead of appearing
as grey shortcode text.
## Blocks
@@ -12,6 +12,8 @@ appearing as grey shortcode text.
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
| `us-scheduler/family` | `[us_family]` | `Guardian\FamilyPage::render()` |
| `us-scheduler/account` | `[us_account]` | `Auth\AccountPage::render()` |
The shortcodes remain registered for back-compat; blocks and shortcodes share
the same page objects (constructed once in `Plugin::boot()`), so front-end
@@ -21,7 +23,7 @@ transform.
## Block options
Four blocks have sidebar (inspector) options:
Most blocks have sidebar (inspector) options:
| Block | Attribute | Default | Effect |
|---|---|---|---|
@@ -34,6 +36,8 @@ Four blocks have sidebar (inspector) options:
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
| `us-scheduler/family` | `loginPageId` (number) | `0` | Where visitors who are not signed in are sent to log in. Shortcode equivalent: `[us_family login_page_id="…"]`. |
| `us-scheduler/account` | `loginPageId` (number) | `0` | Where signing out returns to, and where a signed-out visitor is offered a **Sign in** link. `0` = signing out returns to the current page, and a signed-out visitor sees **nothing at all** — see below. Shortcode equivalent: `[us_account login_page_id="…"]`. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
The page selects list all published pages; if a chosen page is later deleted,
@@ -105,6 +109,10 @@ placeholder content:
- **Login** — the real `templates/frontend/login-page.php` template (it has
no request-state dependencies).
- **Registration** — a disabled sample of the `.us-register-form` fields.
- **Account** — a populated sample panel. Deliberately populated whatever the
editor user's own state: on the published page a signed-out visitor may see
nothing at all, and an empty box tells the person placing the block nothing
about where it will sit.
Each preview starts with a `.us-editor-note` paragraph explaining what the
published page shows instead. The note class only appears in editor previews.
@@ -118,5 +126,22 @@ published page shows instead. The note class only appears in editor previews.
and fallbacks.
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
fallbacks.
- `tests/Unit/Auth/AccountPageTest.php` — what each visitor sees, the
sign-out redirect target, and the signed-out empty render.
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
classes/ids and includes the editor note.
## The account block's signed-out behaviour
`us-scheduler/account` is the one block that can render **nothing**. It is meant
for a header, sidebar or account page, and its whole subject is the person
signed in — which a stranger is not. A bare "you are not signed in" in a site
header is noise that cannot be acted on, so:
- **No login page chosen** → empty string for signed-out visitors.
- **Login page chosen** → a single **Sign in** link.
Signed in, it shows the display name (`Auth\UserName::format()`, so a username
is never exposed), the account email, and a **Sign out** link — deliberately
nothing else. Signing out returns to the chosen login page, or to the current page when there
is none, so a header sign-out does not also navigate the visitor somewhere.
+6
View File
@@ -194,3 +194,9 @@ class becomes enrollable for them — they choose whether to enrol.
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
- `tests/Unit/GroupClass/GroupClassPageTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
## Enrolling A Child
`POST /enrollments` accepts the same optional **`student_id`** as booking,
authorised through `Guardian\GuardianService::canActFor()`; `GET /enrollments`
covers the guardian's whole household, and a guardian may withdraw any of their
children. See `parent-guardian-accounts.md`.
+11 -1
View File
@@ -30,7 +30,8 @@ Students register for a private lesson by choosing an offering, picking a time (
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
11. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
11. The confirmation is a **dismissible notice above the calendar**, not a screen of its own. The calendar is reloaded first — so the slot just taken is gone and the upcoming-lessons panel is current — and the notice is shown over it. Booking again therefore needs no page reload. The notice clears when it is dismissed, when another slot's booking form is opened, and on any reload of the calendar. `group-classes.js` does the same for enrolments.
12. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
## Lesson-Type Filter
Not every open slot can be booked as every private-lesson type — a slot tied to
@@ -177,3 +178,12 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
- `tests/Unit/Booking/LessonControllerTest.php`
- `tests/Unit/Booking/LessonDetailTest.php`
- `tests/Unit/Booking/BookingEndpointTest.php`
## Booking For Someone Else
A guardian books for their children from their own account. `POST /bookings`
accepts an optional **`student_id`**, honoured only when
`Guardian\GuardianService::canActFor()` confirms the caller is that student's
guardian — anything else is a `403`. The booking form's "Who is this for?" picker
lists **children first**, so the default selection is never the parent.
`GET /bookings` returns the whole household, and a guardian may cancel any of
their children's lessons. See `parent-guardian-accounts.md`.
+346
View File
@@ -0,0 +1,346 @@
# Feature: Parent/Guardian Accounts
## Overview
A parent or guardian registers **once** and manages lessons for **one or more
children**, without each child needing their own login. The guardian signs in,
picks which child a booking is for, and pays for all of them from one account.
A guardian may also be a student in their own right — they appear in their own
"who is this for?" selector alongside their children, so a parent taking lessons
next to their kids needs only the one account.
## Vocabulary: "child" in the code, "student" in the UI
The interface says **student** and **profile**; the code says **child** and
**family**. This is deliberate, not drift. Every identifier below — the
`us_guardian_links` columns, `GuardianService::createChild()`, the `children[]`
request parameters, the `child_name` form fields, the `us-scheduler/family`
block name and the `[us_family]` shortcode — is a stable contract with the
database, saved post content and existing installs, so renaming them would break
sites for no user-visible gain. Only the strings a person reads were changed.
When adding to this feature, keep the split: internal names follow the
data model, translatable strings follow the interface.
## Core Decision: children are accountless WordPress users
Every `student_id` column in `src/Schema.php` (`us_lessons`, `us_payments`,
`us_credits`, `us_group_enrollments`, `us_question_answers`,
`us_policy_acceptances`, `us_group_access`) is a `wp_users` id, and booking,
billing, credits, policies and registration answers all resolve it directly.
Rather than change what `student_id` means, **a child is a real `wp_users` row**
with the `us_student` role, created without a usable login:
- no password (`wp_generate_password()` is used and discarded — nothing is ever
emailed, so it cannot be guessed into a session),
- no real email address; a child gets a placeholder login on the RFC 2606
reserved `.invalid` TLD (`us-child-<random>@child.invalid`, see
`GuardianService::childEmail()`) — a well-formed address that can never
resolve, so nothing about a child's account can be emailed somewhere real,
- the `us_child` user meta flag set to `1`, which
`Guardian\ChildLoginGate` uses to block authentication outright.
Consequences:
- `us_lessons`, `us_group_enrollments`, `us_question_answers` and
`us_group_access` are **unchanged** — a child books like any other student.
- A child can be promoted to their own login later by setting a password and a
real email and clearing `us_child`; no data migrates.
- A `us_guardians` link table maps guardian → child.
The alternative — a standalone `us_students` table decoupled from `wp_users`
was rejected for v1: it changes the meaning of `student_id` on seven tables and
requires migrating every existing row.
## Data Model — `{prefix}us_guardians`
| Column | Type | Notes |
|----------------|-----------------|-----------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `guardian_id` | BIGINT UNSIGNED | WordPress user ID of the parent/guardian |
| `student_id` | BIGINT UNSIGNED | WordPress user ID of the child |
| `relationship` | VARCHAR(50) | Free text shown in admin (e.g. "Parent", "Grandparent"); may be empty |
| `created_at` | DATETIME | Insertion time |
`UNIQUE KEY guardian_student (guardian_id, student_id)` — the same pair can
never be linked twice.
The table is a link table, not a child record: the child's **name** is their
`display_name` on `wp_users`, and their birth year is the `us_birth_year`
user meta. Keeping them on the user row means the admin student screens,
`get_users()` ordering, and every existing `student_id` lookup keep working with
no special-casing.
### The legacy `us_date_of_birth` meta
This feature originally collected a full date of birth in `us_date_of_birth`.
Nothing writes that key any more. It is handled entirely inside
`GuardianService`:
- **Read** — `birthYear()` falls back to the year of the old date when
`us_birth_year` is absent, so a child added before the change still shows one
without a migration step.
- **Write** — `setBirthYear()` deletes `us_date_of_birth` on *every* save,
including a save that clears the year. Without that the fallback would
resurrect the old date on the next read and the year could never be cleared.
The upshot is a lazy migration: a child's full date survives until their record
is next edited, then goes for good. There is no bulk purge — a site that wants
the remaining old dates gone should delete the `us_date_of_birth` meta directly.
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
refuses to link a child that already has a guardian. The unique key and the
guardian-side lookups already support many-to-many, so adding a second guardian
(separated parents) later is an insert, not a migration.
## Schema changes to existing tables
| Table | Change | Why |
|---|---|---|
| `us_payments` | `payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0` + `KEY payer_id` | Who owes the money, when that is not the student |
| `us_credits` | `payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0` + `KEY payer_id` | Which account holds the balance |
| `us_policy_acceptances` | `accepted_by BIGINT UNSIGNED NOT NULL DEFAULT 0` | Who actually clicked, when that is not the student |
All three default to `0`, read back as "same as `student_id`" (see
`Payment::payerOrStudent()`, `Credit::payerOrStudent()`,
`PolicyAcceptance::acceptorOrStudent()`), so **an existing row keeps its current
meaning whatever happens** — a pre-guardian payment is still owed by, and was
still accepted by, the student it names.
The installer additionally backfills them (`PaymentRepository::backfillPayerIds()`,
`CreditRepository::backfillPayerIds()`, `AcceptanceRepository::backfillAcceptedBy()`,
run from `Installer::migrateData()`), because the *balance* lookups key on
`payer_id` directly and an indexed `WHERE payer_id = 5` would not see a legacy row
still holding `0`. The backfill is idempotent — it only touches rows still at `0`
and the `payerOrStudent()` fallbacks remain as the belt to its braces.
## Billing: the guardian is the payer, the child is the subject
- `us_payments.student_id` keeps naming **the child the lesson was for**, so
per-child payment reporting is unchanged.
- `us_payments.payer_id` names **the guardian who owes it**. Payment notices,
receipts and the Stripe intent all resolve the payer.
- `us_credits.payer_id` is where a **family balance** lives. A credit from one
child's cancelled lesson is held by the guardian and can settle a sibling's
charge; `CreditRepository::availableBalance()` and `consume()` operate on the
payer.
- The **billing-method override** (`comp` / `card` / `etransfer`, user meta read
by `BillingMethodResolver`) resolves against the payer, so comping a family is
one setting on the guardian rather than one per child.
`PaymentService::createForRegistration()` takes the payer id alongside the
student id; `BookingEndpoint` and `ScheduledBillingRunner` both pass
`GuardianService::payerFor( $studentId )` — the child's guardian when they have
one, otherwise the student themselves.
Family discounts are **out of scope** for v1 but are not designed out: with the
payer on both the payment and the credit ledger, a discount rule has a family to
apply to.
## Registration
A **"I'm registering as a parent or guardian"** checkbox on the existing
`[us_student_register]` form (all three signup paths — personal invite, group
link, self-approval) reveals a repeatable child block. Ticking it requires at
least one child name.
Per child the form collects:
- **Name** (required)
- **Birth year** (required, `us_birth_year` meta) — a four-digit year between
1900 and the current year. `GuardianService::normaliseBirthYear()` is the one
definition of what counts, shared by the signup form's up-front validation and
by `createChild()`/`updateChild()` themselves, so a bad year is refused rather
than quietly discarded and a typo cannot leave a nonsense age on the record.
- **Every account-scope registration question** (`Registration\Question`,
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
practice they describe the student (instrument, level, school). The guardian
answers them on the child's behalf; the answer row's `student_id` is the child.
Name and birth year are marked required in the labels the same way a required
question is, but the signup form **cannot** lean on the browser to enforce them:
the child blocks are hidden until the parent/guardian box is ticked, and a
`required` field inside a hidden container makes the form unsubmittable with no
control the user can reach to fix. `register.js` therefore puts `required` on
and takes it off along with the block itself (`[data-us-child-required]`), and
the server checks regardless — which is what makes the rule hold with
JavaScript off. The profile screen has no such problem: its forms are always
visible, so the attribute is static there.
Order of operations in `RegistrationPage::handleSubmit()`:
1. Validate the guardian's own fields (email, password, policies).
2. Validate **every** child block — a missing name, a missing or unusable birth
year, or a missing required per-child answer fails the whole submission
**before** any user is created, so a half-registered family is never left
behind. An **entirely empty** block is dropped instead, because the form
always renders one spare for "add another"; a block with anything at all
typed into it is kept and reported on, rather than silently discarding what
the guardian entered.
3. Create the guardian user.
4. For each child: create the accountless user, link it, record its answers, and
record the signup policy acceptances **against the child** with
`accepted_by = <guardian>`.
5. Roll back — every child user created so far is deleted and the guardian user
with them — if any child creation fails, so a partial family never persists.
A guardian who does not tick the box registers exactly as before; nothing about
the single-student flow changes.
### Policy acceptance
`us_policy_acceptances` records **one row per child** for each signup-scoped
policy, with:
- `student_id` = the child (who the policy binds),
- `accepted_by` = the guardian (who actually agreed),
- `registration_type = 'account'`, `registration_id` = the child's user ID.
The guardian also gets their own acceptance row (`student_id = accepted_by =
guardian`) whether or not they book for themselves — they agreed to the terms as
an account holder. This is the legally meaningful record: "guardian X accepted
policy version N on behalf of child Y at time T from IP Z".
Booking-scope policies are accepted at booking time by whoever is signed in;
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
child.
## Managing children
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
list the children, add one, edit a name/birth year, remove one.
- **Add** creates another accountless child user and links it. Account-scope
questions are asked here too, so a child added later carries the same
information as one added at signup.
- **Edit** updates `display_name` and `us_birth_year`.
- **Remove** unlinks the child and **deletes the child user**, but only when the
child has no lessons and no enrolments — a child with history is refused, so
removing one can never orphan a lesson, payment or credit
(`GuardianService::removeChild()`). The guardian is told to contact the studio
instead.
Submissions are processed on `template_redirect` (like registration) and
post/redirect/get back to the page, so a refresh cannot resubmit.
## Booking
`GET /bookings` returns the lessons of the signed-in user **and of every child
they are guardian for**, each row carrying `student_id` and `student_name` so
the list can be grouped by child.
`POST /bookings` takes an optional **`student_id`**:
- absent or `0` → the current user books for themselves (unchanged),
- a child's id → the endpoint verifies with `GuardianService::canActFor()` that
the current user is that child's guardian, and returns `403 forbidden` when
they are not. **This is the authorisation boundary of the feature**: without
it any student could book, and bill, against any user id they cared to send.
The booking form gains a "Who is this for?" `<select>`, rendered only when the
account has more than one person on it, so a single-student account's form is
unchanged.
**Children are listed first and the account holder last**
(`GuardianService::bookableStudents()`). The order is the whole point: a
guardian's normal case is booking for a child, so the default selection — the
one a parent gets by not touching the picker at all — is a child, never
themselves. Booking for the wrong child is a correctable inconvenience; silently
billing a parent's account for a lesson meant for their kid is the error worth
designing out. The guardian is still offered, last, so a parent taking lessons
alongside their children can book for themselves.
The list is rendered server-side into `data-students` on the page wrapper and
read by `assets/js/guardian.js`, which both the booking and group-class scripts
share.
`POST /bookings/<id>/cancel` accepts a cancellation from the lesson's student
**or** their guardian, subject to the same cancellation cutoff.
## Group classes
`POST /enrollments` carries the same optional `student_id` and the same
`canActFor()` check, `GET /enrollments` covers the household, and
`POST /enrollments/<id>/withdraw` accepts the guardian — group enrolment is the
other place a family books and pays, so it gets the identical treatment rather
than being left as a single-student-only path.
## Admin
- **Students list** gains a **Profile** column: a child links to its
guardian's detail screen, a guardian lists its children as links. Children are
listed alongside every other student rather than nested, so nothing about
finding a student changes.
- **Student detail** gains a **Profile** panel — the guardian (for a child) or
the children (for a guardian), each a link to the other's screen — and the
credit balance shown is the **payer's** balance, labelled with whose it is, so
an admin looking at a child sees the family balance that will actually settle
their charges rather than an empty per-child one.
- Registration answers and policy acceptances on a child's screen show
"accepted by <guardian>" where the acceptor differs from the student.
Creating or attaching a child from wp-admin is **out of scope** for v1; a studio
admin adds children through the guardian's own family screen or asks the
guardian to.
## Capabilities
No new capability. A child user holds the `us_student` role (so every existing
`student_id` capability check keeps working) but can never sign in
(`Guardian\ChildLoginGate` blocks `wp_authenticate_user` and forces
`user_has_cap` to withhold `book_lesson` from a child), so the role grants them
nothing in practice. Guardians act for children through
`GuardianService::canActFor()`, checked at every REST and form boundary, rather
than through a capability.
## Instructor view
Lesson lists show the student's name. Where that student is a child, the
instructor also sees the guardian's name and email — the contact they actually
need — via `GuardianService::contactFor()`.
## Implementation
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
`canActFor()`, `payerFor()`, `contactFor()`, removal rules)
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
(one question rendered under a caller-supplied input name, so the same
question can appear once per child without colliding)
- Front-end script: `assets/js/guardian.js` (the shared picker),
`assets/js/register.js` (guardian toggle + "add another child")
- Extended: `Auth\RegistrationPage` (guardian checkbox, child blocks, per-child
answers/acceptances, rollback), `Booking\BookingEndpoint` and
`GroupClass\EnrollmentEndpoint` (`student_id` param + guardian
authorisation, household listings), `Booking\BookingPage`,
`GroupClass\GroupClassPage`, `Payment\PaymentService`,
`Payment\PaymentRepository`, `Payment\CreditRepository`,
`Payment\ScheduledBillingRunner` (one notice per payer),
`Policy\AcceptanceRepository`, `Registration\RegistrationGate`,
`Auth\StudentController`, `Installer` (backfills)
- Schema: `us_guardians`; `us_payments.payer_id`; `us_credits.payer_id`;
`us_policy_acceptances.accepted_by`
## Tests
- `tests/Unit/Guardian/GuardianLinkTest.php`
- `tests/Unit/Guardian/GuardianRepositoryTest.php`
- `tests/Unit/Guardian/GuardianServiceTest.php`
- `tests/Unit/Guardian/ChildLoginGateTest.php`
- `tests/Unit/Guardian/FamilyPageTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php` (guardian signup path)
- `tests/Unit/Booking/BookingEndpointTest.php` and
`tests/Unit/GroupClass/EnrollmentEndpointTest.php` (booking/enrolling for a
child, and the 403 when the caller is not the guardian)
- `tests/Unit/Booking/BookingPageTest.php` (children lead the embedded list)
- `tests/Unit/Payment/PaymentServiceTest.php`, `CreditRepositoryTest.php`,
`ScheduledBillingRunnerTest.php` (payer, family balance, one notice)
## Related
`account-registration.md`, `lesson-booking.md`, `payments.md`, `credits.md`,
`group-classes.md`, `student-administration.md`, `policies.md`,
`registration-questions.md`.
+8
View File
@@ -200,3 +200,11 @@ See `payment-reporting.md` for the monthly report and CSV export endpoints.
- `tests/Unit/Payment/PaymentTest.php`
- `tests/Unit/Payment/StripeGatewayTest.php`
- `tests/Unit/Payment/ReceiptMailerTest.php`
## Who Pays
`us_payments.student_id` names the student the charge is *for*;
`us_payments.payer_id` names who **owes** it — a child's guardian, or 0 meaning
the student pays for themselves (`Payment::payerOrStudent()`). The billing
method, receipts, payment notices and the Stripe payment step all resolve the
payer, so a family is billed and comped as one account while per-child reporting
is unchanged. See `parent-guardian-accounts.md`.
+6
View File
@@ -84,3 +84,9 @@ cover every policy's current version or the registration is rejected.
- `tests/Unit/Policy/PolicyServiceTest.php`
- `tests/Unit/Policy/PolicyControllerTest.php`
- `tests/Unit/Policy/PolicyEndpointTest.php`
## Who Accepted
`us_policy_acceptances.accepted_by` records the person who actually ticked the
box, where that differs from `student_id` — a guardian agreeing on a child's
behalf. It defaults to 0, read back as "the student agreed for themselves"
(`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`.
+9
View File
@@ -89,3 +89,12 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
- `tests/Unit/Registration/AnswerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/StudentHistoryTest.php`
## Per-Child Answers
For a parent/guardian signup, **account-scope** questions are asked **once per
child** rather than once per guardian — in practice they describe the student
(instrument, level, school), not the account holder. Each answer's `student_id`
and `registration_id` are the child's user ID, so a studio admin reading a
child's screen sees the information that describes them. The guardian's family
screen asks the same questions when a child is added later. See
`parent-guardian-accounts.md`.
+7
View File
@@ -94,3 +94,10 @@ applies that credit against their due charges before emailing the notice
- `tests/Unit/Payment/PaymentRepositoryTest.php` (`existsForPeriod`, `due_date`/`period_key`)
- `tests/Unit/Payment/PaymentServiceTest.php` (`voidPending` skips scheduled)
- `tests/Unit/Booking/BookingEndpointTest.php` / `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (deferred payment)
## One Notice Per Family
Charges are bucketed by **payer**, not student, so a guardian gets a single
notice covering every child rather than one email per child. Each line names the
student it is for when that is not the payer ("Ada: Piano Lesson — Mar 3, 2026"),
and account credit is applied across the whole bucket from the family balance.
See `parent-guardian-accounts.md`.
+7
View File
@@ -86,3 +86,10 @@ All actions are nonce-protected POSTs handled on the detail page:
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
`tests/Unit/Payment/PaymentRepositoryTest.php`
## Family Relationships
The students list gains a **Profile** column — a child links to their guardian,
a guardian lists their children — and the student screen a **Profile** panel. A
child's listed email is their guardian's, since a child's own address is an
undeliverable placeholder, and the credit balance shown is the payer's, labelled
with whose account holds it. See `parent-guardian-accounts.md`.
+7
View File
@@ -3,6 +3,13 @@ includes:
parameters:
level: 10
# Analyse against the whole supported range, not whatever PHP happens to be
# running. Without this, syntax newer than the `Requires PHP: 8.1` header
# promises passes lint on a modern local PHP and only fails in the 8.1 test
# job — which is how a PHP 8.2 `true` return type once reached CI.
phpVersion:
min: 80100
max: 80300
paths:
- src
bootstrapFiles:
+3 -2
View File
@@ -15,6 +15,7 @@ use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentActions;
use Unsupervised\Schedular\Auth\StudentController;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController;
@@ -64,7 +65,7 @@ class AdminMenu {
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits ) {
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 ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
@@ -73,7 +74,7 @@ class AdminMenu {
$this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians );
$this->instructorController = new InstructorController();
$this->settings = $settings;
$this->accessSettings = new AccessSettings();
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Who is signed in, and the way out.
*
* Meant for a header, sidebar or account page somewhere it sits alongside
* other content rather than being the whole of it. That shapes the two
* decisions below.
*/
class AccountPage {
/**
* Renders the account shortcode/block output.
*
* Signed out, this renders a sign-in link when a login page is configured and
* **nothing at all** when one is not. A block whose whole job is "you are
* signed in as X" has nothing to say to a stranger, and a bare "you are not
* signed in" in a site header is noise with no way to act on it. The editor
* preview shows the populated state regardless, so the block is never
* invisible to the person placing it.
*
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
* shortcode attributes (`login_page_id`).
*/
public function render( array $atts ): string {
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
$loginUrl = $this->pageUrl( $loginPageId );
wp_enqueue_style( 'us-scheduler' );
if ( ! is_user_logged_in() ) {
if ( null === $loginUrl ) {
return '';
}
return sprintf(
'<div class="us-account us-account-out"><a class="us-account-signin" href="%s">%s</a></div>',
esc_url( $loginUrl ),
esc_html__( 'Sign in', 'unsupervised-schedular' )
);
}
// Always a WP_User here — is_user_logged_in() above rules out the
// id-0 placeholder wp_get_current_user() returns for a visitor.
$user = wp_get_current_user();
$name = UserName::format( $user, get_current_user_id() );
$email = $user->user_email;
// Back to where they were, so signing out of a header link does not also
// navigate them somewhere. The login page is the better landing spot when
// one is configured, since the current page may be members-only.
$logoutUrl = wp_logout_url( $loginUrl ?? (string) get_permalink() );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/account-page.php';
return (string) ob_get_clean();
}
/**
* Permalink of a configured page, or null when none is chosen or the chosen
* page has since been deleted.
*/
private function pageUrl( int $pageId ): ?string {
if ( $pageId <= 0 ) {
return null;
}
$url = get_permalink( $pageId );
return is_string( $url ) ? $url : null;
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* What counts as an acceptable signup password.
*
* The check is deliberately split across the two sides, because the two sides
* can do different things:
*
* - **The browser** runs zxcvbn (WordPress ships it as `password-strength-meter`)
* and gates the submit button on {@see MIN_SCORE}. That is the nuanced test
* it knows that `Tr0ub4dor&3` is weaker than `correct horse battery staple`
* but it is only advice, because anything in a browser can be turned off.
* - **This class** runs on the server and is the rule that actually holds. It
* cannot score a password the way zxcvbn does without shipping a dictionary,
* so it does not pretend to: it rejects the categorically bad too short,
* the user's own name or email, a password from the well-known lists, or one
* built from almost no distinct characters.
*
* Neither half is sufficient alone, which is the point. A password that clears
* both is not guaranteed strong; one that fails either is definitely not.
*/
class PasswordPolicy {
/**
* Minimum length. NIST SP 800-63B puts the floor at 8 and explicitly advises
* against composition rules ("must contain a symbol") on the grounds that they
* push people towards predictable substitutions. Length plus the checks below
* does more for less annoyance.
*/
public const MIN_LENGTH = 8;
/**
* The zxcvbn score the browser demands before it will let the form submit,
* on WordPress's 0-4 scale: 0-1 weak, 2 medium, 3-4 strong. Two rejects the
* passwords a stranger would guess while still accepting an ordinary
* memorable one a studio signup form is not a bank.
*/
public const MIN_SCORE = 2;
/**
* How much of the user's own identity has to appear in the password before it
* is refused. Short enough to catch a name inside a longer password, long
* enough that a two- or three-letter coincidence does not trip it.
*/
private const IDENTITY_FRAGMENT_LENGTH = 4;
/** Fewest distinct characters a password may be built from. */
private const MIN_DISTINCT_CHARACTERS = 4;
/**
* Why this password is unacceptable, or null when it passes.
*
* `$email` and `$displayName` are what the same submission is claiming as an
* identity, so they can be checked against the password before either exists
* as a user.
*/
public static function validate( string $password, string $email = '', string $displayName = '' ): ?string {
// Not trimmed: a leading or trailing space is a legitimate character, and
// silently changing what someone typed would lock them out later.
if ( strlen( $password ) < self::MIN_LENGTH ) {
return sprintf(
/* translators: %d: minimum number of characters. */
__( 'Please choose a password of at least %d characters.', 'unsupervised-schedular' ),
self::MIN_LENGTH
);
}
$lower = strtolower( $password );
if ( in_array( $lower, self::commonPasswords(), true ) ) {
return __( 'That password is one of the most commonly used ones. Please choose something less guessable.', 'unsupervised-schedular' );
}
if ( count( array_unique( str_split( $lower ) ) ) < self::MIN_DISTINCT_CHARACTERS ) {
return __( 'Please choose a password built from more than a few repeated characters.', 'unsupervised-schedular' );
}
if ( self::echoesIdentity( $lower, $email, $displayName ) ) {
return __( 'Please choose a password that does not contain your name or email address.', 'unsupervised-schedular' );
}
return null;
}
/**
* Whether the password contains the user's display name, their email address,
* or the part of it before the `@` the first things anyone guessing would
* try, and the reason "grace2019" is worse than its length suggests.
*/
private static function echoesIdentity( string $lowerPassword, string $email, string $displayName ): bool {
$email = strtolower( trim( $email ) );
$localPart = '' !== $email ? (string) strstr( $email . '@', '@', true ) : '';
$fragments = [ $email, $localPart, strtolower( trim( $displayName ) ) ];
foreach ( $fragments as $fragment ) {
if ( strlen( $fragment ) >= self::IDENTITY_FRAGMENT_LENGTH && str_contains( $lowerPassword, $fragment ) ) {
return true;
}
}
return false;
}
/**
* Passwords common enough that a guess costs nothing. Only entries at least
* {@see MIN_LENGTH} long are worth listing anything shorter is already
* refused so this is the long tail of the usual leaked-password lists
* rather than the whole of it. zxcvbn in the browser covers the rest.
*
* @return list<string>
*/
private static function commonPasswords(): array {
return [
'password',
'password1',
'password12',
'password123',
'passw0rd',
'p@ssword',
'p@ssw0rd',
'12345678',
'123456789',
'1234567890',
'123123123',
'qwertyui',
'qwertyuiop',
'qwerty123',
'qwerty12',
'1qaz2wsx',
'zaq12wsx',
'iloveyou',
'princess',
'sunshine',
'football',
'baseball',
'basketball',
'superman',
'batman123',
'trustno1',
'welcome1',
'welcome123',
'letmein1',
'letmein123',
'admin123',
'administrator',
'abc12345',
'abcd1234',
'monkey123',
'dragon123',
'michael1',
'jennifer',
'starwars',
'computer',
'whatever',
'freedom1',
'changeme',
'secret123',
'login123',
];
}
}
+185 -15
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
@@ -47,6 +48,7 @@ class RegistrationPage {
private QuestionRepository $questions,
private AnswerRepository $answers,
private GroupAccessRepository $access,
private GuardianService $guardians,
) {}
/**
@@ -117,9 +119,33 @@ class RegistrationPage {
// gate, so it needs the plugin stylesheet that formats it.
wp_enqueue_style( 'us-scheduler' );
// The two-step script only matters when there is a second step to reveal.
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
// The script drives both the second step and the parent/guardian section
// (revealing it, and cloning the child block for "add another"), so it is
// needed whenever the form itself is on screen.
if ( $canRegister && '' === $successType ) {
wp_enqueue_script( 'us-scheduler-register' );
// The browser gate reads the same numbers the server enforces, so the
// two cannot drift into disagreeing about what it accepted.
wp_localize_script(
'us-scheduler-register',
'usSchedulerPassword',
[
'minLength' => PasswordPolicy::MIN_LENGTH,
'minScore' => PasswordPolicy::MIN_SCORE,
'strings' => [
'short' => sprintf(
/* translators: %d: minimum number of characters. */
__( 'At least %d characters, please.', 'unsupervised-schedular' ),
PasswordPolicy::MIN_LENGTH
),
'veryWeak' => __( 'Too weak — a stranger could guess this.', 'unsupervised-schedular' ),
'weak' => __( 'Still too weak. Try a longer phrase.', 'unsupervised-schedular' ),
'medium' => __( 'Good enough.', 'unsupervised-schedular' ),
'strong' => __( 'Strong password.', 'unsupervised-schedular' ),
],
]
);
}
ob_start();
@@ -244,10 +270,6 @@ class RegistrationPage {
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
if ( strlen( $password ) < 8 ) {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
}
// The email is fixed by a personal invite; group-link signups and
// self-signups supply their own.
if ( $inviteValid && ! $invite->isGroup() ) {
@@ -259,6 +281,15 @@ class RegistrationPage {
}
}
// After the email, so the password can be checked against it. The browser
// scores the password with zxcvbn and refuses to submit a weak one, but
// that is advice a client can decline to take — this is the check that
// holds. See PasswordPolicy for why the two halves differ.
$passwordError = PasswordPolicy::validate( $password, $email, $displayName );
if ( null !== $passwordError ) {
return esc_html( $passwordError );
}
$policyForms = $this->signupPolicies();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
@@ -270,14 +301,42 @@ class RegistrationPage {
}
}
// Account-signup questions (step two) — validate before creating the user so
// a missing required answer never leaves a half-registered account behind.
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$answers = $this->submittedAnswers();
foreach ( $accountQuestions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
// Registering as a parent/guardian turns the account-signup questions from
// "about you" into "about each child" — they describe the student
// (instrument, level, school), not the person holding the account.
$isGuardian = $this->submittedIsGuardian();
$children = $isGuardian ? $this->submittedChildren() : [];
$answers = $isGuardian ? [] : $this->submittedAnswers();
// Everything is validated before a single user is created, so a bad child
// block never leaves a half-registered family behind.
if ( $isGuardian && [] === $children ) {
return esc_html__( 'Please add at least one student, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
}
// Name and birth year are required per student, and are checked here for
// the same reason the questions below are: the child blocks are hidden
// until the guardian box is ticked, so the browser cannot be asked to
// enforce them without blocking a signup that has no children at all.
foreach ( $children as $child ) {
if ( '' === $child['name'] ) {
return esc_html__( 'Please give each student a name.', 'unsupervised-schedular' );
}
if ( 0 === GuardianService::normaliseBirthYear( $child['birth_year'] ) ) {
return esc_html( GuardianService::birthYearError() );
}
}
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
foreach ( $accountQuestions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
return $isGuardian
? esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' )
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
}
}
}
@@ -299,8 +358,16 @@ class RegistrationPage {
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
}
$this->recordAcceptances( $policyForms, (int) $userId );
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
if ( $isGuardian ) {
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
if ( '' !== $failure ) {
return $failure;
}
} else {
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
}
if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
@@ -440,6 +507,102 @@ class RegistrationPage {
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
}
/**
* Whether the "I'm registering as a parent or guardian" box was ticked.
*/
private function submittedIsGuardian(): bool {
// The submit nonce is verified by the caller before this runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
return '1' === sanitize_text_field( Val::string( wp_unslash( $_POST['us_is_guardian'] ?? '' ) ) );
}
/**
* The child blocks submitted with a guardian signup, as
* `children[<n>][name|birth_year|answers]`.
*
* An **entirely empty** block is dropped rather than rejected the form always
* renders one spare for "add another", and an untouched spare is not a mistake
* the guardian needs telling about. A block with anything at all filled in is
* kept, so {@see handleSubmit()} can reject it for the missing name or birth
* year rather than silently discarding what they typed.
*
* @return list<array{name: string, birth_year: string, answers: array<int, string>}>
*/
private function submittedChildren(): array {
// The submit nonce is verified by the caller before this runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each field is unslashed and sanitized below.
$raw = $_POST['children'] ?? [];
if ( ! is_array( $raw ) ) {
return [];
}
$out = [];
foreach ( $raw as $child ) {
if ( ! is_array( $child ) ) {
continue;
}
$name = trim( sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) ) );
$birthYear = trim( sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ) );
$answers = [];
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
if ( '' === $name && '' === $birthYear && '' === trim( implode( '', $answers ) ) ) {
continue;
}
$out[] = [
'name' => $name,
'birth_year' => $birthYear,
'answers' => $answers,
];
}
return $out;
}
/**
* Create each child of a guardian signup: the login-less account, its answers
* to the per-child questions, and a signup-policy acceptance recorded against
* the child but attributed to the guardian who agreed for them.
*
* Returns an empty string on success, or an error message after rolling the
* whole family back every child created so far *and* the guardian. A signup
* that half-worked would leave the guardian with an account they cannot
* re-register and children they never confirmed, so it is undone entirely and
* they simply try again.
*
* @param list<array{name: string, birth_year: string, answers: array<int, string>}> $children
* @param list<Question> $questions
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
*/
private function createChildren( array $children, array $questions, array $policyForms, int $guardianId ): string {
$created = [];
foreach ( $children as $child ) {
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['birth_year'] );
if ( $childId instanceof \WP_Error ) {
foreach ( $created as $id ) {
$this->guardians->deleteUser( $id );
}
$this->guardians->deleteUser( $guardianId );
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
}
$created[] = $childId;
$this->recordAnswers( $questions, $child['answers'], $childId );
$this->recordAcceptances( $policyForms, $childId, $guardianId );
}
return '';
}
/**
* The account-question answers submitted with the form, keyed by question id.
*
@@ -489,9 +652,15 @@ class RegistrationPage {
/**
* Record account-time acceptances for each signup policy version.
*
* `$userId` is who the policy binds the guardian for their own acceptance,
* or the child for one accepted on their behalf and `$acceptedBy` is who
* actually ticked the box. Recording both is what makes the row legally
* meaningful: "guardian X agreed to version N for child Y, at this time, from
* this IP".
*
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
*/
private function recordAcceptances( array $policyForms, int $userId ): void {
private function recordAcceptances( array $policyForms, int $userId, int $acceptedBy ): void {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
@@ -502,6 +671,7 @@ class RegistrationPage {
studentId: $userId,
registrationType: PolicyAcceptance::REG_ACCOUNT,
registrationId: $userId,
acceptedBy: $acceptedBy,
ipAddress: '' !== $ip ? $ip : null,
)
);
+18 -3
View File
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\Payment;
@@ -23,6 +24,7 @@ class StudentController {
private BillingMethodResolver $resolver,
private StudentHistory $history,
private StudentActions $actions,
private GuardianService $guardians,
) {}
public function renderPage(): void {
@@ -43,10 +45,14 @@ class StudentController {
fn( \WP_User $user ): array => [
'id' => (int) $user->ID,
'name' => $user->display_name,
'email' => $user->user_email,
// A child's own address is an undeliverable placeholder, so the
// list shows the guardian's — the address an admin would use.
'email' => $this->guardians->contactFor( (int) $user->ID )['email'],
'registered' => $user->user_registered,
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ),
'guardian' => $this->guardians->guardianOf( (int) $user->ID ),
'children' => $this->guardians->children( (int) $user->ID ),
],
array_filter(
get_users(
@@ -150,10 +156,19 @@ class StudentController {
$intake = $this->history->intakeAnswers( (int) $student->ID );
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
$credits = $canBilling ? $this->history->credits( (int) $student->ID ) : [];
$creditBalance = $canBilling ? $this->history->creditBalance( (int) $student->ID ) : 0.0;
$creditCurrency = $this->creditCurrency( $credits );
$backUrl = admin_url( 'admin.php?page=us-students' );
// The family panel, and the account whose balance actually settles this
// student's charges — a child's is their guardian's, so showing the
// child's own (always empty) balance would be actively misleading.
$guardian = $this->guardians->guardianOf( (int) $student->ID );
$children = $this->guardians->children( (int) $student->ID );
$payer = $this->guardians->contactFor( (int) $student->ID );
$creditBalance = $canBilling ? $this->history->creditBalance( $payer['id'] ) : 0.0;
$backUrl = admin_url( 'admin.php?page=us-students' );
$pageSlug = 'us-students';
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
}
+59
View File
@@ -15,6 +15,12 @@ namespace Unsupervised\Schedular;
*/
class BlockPreview {
/**
* The marker a required field's label carries, matching the one
* {@see Registration\QuestionField::render()} puts on a required question.
*/
private const REQUIRED_MARK = ' <span class="us-required" aria-hidden="true">*</span>';
/**
* Sample booking page.
*
@@ -169,6 +175,59 @@ class BlockPreview {
);
}
/**
* Sample family (manage-children) page: two representative children and the
* add form, with the controls inert so the editor preview cannot post.
*/
public static function family(): string {
$children = '';
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
$children .= sprintf(
'<li class="us-family-child"><span class="us-family-child-name">%s</span>'
. '<span class="us-family-child-actions"><a href="#">%s</a> <button type="button" disabled>%s</button></span></li>',
esc_html( $name ),
esc_html__( 'Edit', 'unsupervised-schedular' ),
esc_html__( 'Remove', 'unsupervised-schedular' )
);
}
$add = sprintf(
'<h4>%s</h4><p><label for="us-child-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-child-name"></p>'
. '<p><label for="us-child-birth-year">%s' . self::REQUIRED_MARK . '</label><input type="number" id="us-child-birth-year" placeholder="YYYY"></p>'
. '<p><button type="button" disabled>%s</button></p>',
esc_html__( 'Add a student', 'unsupervised-schedular' ),
esc_html__( 'Name', 'unsupervised-schedular' ),
esc_html__( 'Birth year', 'unsupervised-schedular' ),
esc_html__( 'Add student', 'unsupervised-schedular' )
);
return sprintf(
'<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
self::note( __( 'Editor preview — signed-in guardians see and manage their own students here.', 'unsupervised-schedular' ) ),
esc_html__( 'Your profile', 'unsupervised-schedular' ),
$children,
$add
);
}
/**
* Sample account panel. Shown populated whatever the editor's own login
* state, since on the published page a signed-out visitor may see nothing at
* all and an empty box tells the person placing the block nothing.
*/
public static function account(): string {
return sprintf(
'<div class="us-account">%s'
. '<p class="us-account-who"><span class="us-account-name">%s</span>'
. '<span class="us-account-email">%s</span></p>'
. '<p class="us-account-actions"><a class="us-account-signout" href="#">%s</a></p></div>',
self::note( __( 'Editor preview — each visitor sees their own account here.', 'unsupervised-schedular' ) ),
esc_html__( 'Grace Hopper', 'unsupervised-schedular' ),
esc_html__( '[email protected]', 'unsupervised-schedular' ),
esc_html__( 'Sign out', 'unsupervised-schedular' )
);
}
private static function note( string $text ): string {
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
}
+40
View File
@@ -3,10 +3,12 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Guardian\FamilyPage;
/**
* Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so
@@ -28,6 +30,8 @@ class BlockRegistrar {
private LoginPage $loginPage,
private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage,
private FamilyPage $familyPage,
private AccountPage $accountPage,
) {}
public function register(): void {
@@ -137,6 +141,24 @@ class BlockRegistrar {
],
],
],
'us-scheduler/family' => [
'render' => [ $this, 'renderFamily' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
],
],
'us-scheduler/account' => [
'render' => [ $this, 'renderAccount' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
],
],
];
}
@@ -184,6 +206,24 @@ class BlockRegistrar {
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
}
/**
* Renders the account (who is signed in) block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderAccount( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::account() : $this->accountPage->render( $attributes );
}
/**
* Renders the family (manage-children) block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderFamily( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::family() : $this->familyPage->render( $attributes );
}
/**
* Server-side auto-redirect for blocks that opt in via their autoRedirect
* attribute: logged-out visitors on a page containing the booking block
+84 -10
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
@@ -28,6 +29,7 @@ class BookingEndpoint {
private RegistrationGate $gate,
private PaymentService $payments,
private CancellationPolicy $cancellationPolicy,
private GuardianService $guardians,
) {}
/**
@@ -59,6 +61,13 @@ class BookingEndpoint {
'type' => 'integer',
'default' => 0,
],
// Who the lesson is for. 0/absent means the caller books for
// themselves; a child's id is honoured only for their guardian.
'student_id' => [
'type' => 'integer',
'default' => 0,
'sanitize_callback' => 'absint',
],
'recurrence' => [
'type' => 'string',
'default' => 'single',
@@ -114,12 +123,26 @@ class BookingEndpoint {
}
public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$userId = get_current_user_id();
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
? $this->bookings->findUpcomingForInstructor( $userId )
: $this->bookings->findUpcomingForStudent( $userId );
$userId = get_current_user_id();
return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 );
if ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
$lessons = $this->bookings->findUpcomingForInstructor( $userId );
} else {
// A guardian's list covers the whole household — their own lessons and
// every child's — merged and re-sorted so the soonest is first
// regardless of whose it is.
$lessons = [];
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) {
$lessons = array_merge( $lessons, $this->bookings->findUpcomingForStudent( $studentId ) );
}
}
$rows = array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons );
// usort reindexes in place, so the response is already a list.
usort( $rows, static fn( array $a, array $b ): int => Val::string( $a['start_dt'] ?? '' ) <=> Val::string( $b['start_dt'] ?? '' ) );
return new \WP_REST_Response( $rows, 200 );
}
/**
@@ -144,10 +167,21 @@ class BookingEndpoint {
'end_dt' => $slot?->endDt,
'offering_title' => $offering?->title,
'duration_minutes' => $duration,
// Whose lesson it is, so a guardian's merged list can say which child
// each row belongs to.
'student_name' => $this->guardians->studentName( $lesson->studentId ),
];
}
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
// Who the lesson is for is settled before anything else is touched: an
// unauthorised student id must never get as far as claiming a slot, and
// certainly never as far as raising a payment against someone's account.
$studentId = $this->resolveStudent( $request );
if ( $studentId instanceof \WP_Error ) {
return $studentId;
}
$slotId = Val::int( $request->get_param( 'slot_id' ) );
$slot = $this->availability->findById( $slotId );
@@ -214,7 +248,6 @@ class BookingEndpoint {
return $gateError;
}
$studentId = get_current_user_id();
$notes = Val::string( $request->get_param( 'notes' ) );
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
? Lesson::RECURRENCE_WEEKLY
@@ -254,7 +287,9 @@ class BookingEndpoint {
$ids = [ $anchorId ];
}
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
// The acceptance binds the student but is attributed to whoever actually
// ticked the boxes — the guardian, when they booked for a child.
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
$payment = null;
$status = Lesson::STATUS_PENDING;
@@ -276,7 +311,16 @@ class BookingEndpoint {
? $offering->price
: $offering->price * count( $ids );
$payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $amount, $offering->currency, $offering->etransferEmail );
$payment = $this->payments->createForRegistration(
Payment::REG_LESSON,
$anchorId,
$studentId,
$slot->instructorId,
$amount,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
);
if ( null !== $payment && $payment->isPaid() ) {
$status = Lesson::STATUS_CONFIRMED;
@@ -303,6 +347,35 @@ class BookingEndpoint {
);
}
/**
* Who this booking is for: the caller by default, or one of their children
* when a `student_id` is supplied and they are that child's guardian.
*
* This is the authorisation boundary of guardian booking without it any
* signed-in student could book, and bill, against any user id they chose to
* send. An id the caller may not act for is a 403, never a silent fallback to
* themselves: a guardian who picked the wrong child needs to be told, not to
* have the lesson quietly booked in their own name.
*/
private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error {
$userId = get_current_user_id();
$requested = absint( Val::int( $request->get_param( 'student_id' ) ) );
if ( $requested <= 0 || $requested === $userId ) {
return $userId;
}
if ( ! $this->guardians->canActFor( $userId, $requested ) ) {
return new \WP_Error(
'forbidden',
__( 'You cannot book on behalf of that student.', 'unsupervised-schedular' ),
[ 'status' => 403 ]
);
}
return $requested;
}
/**
* Extract a question_id => value map from the request.
*
@@ -342,7 +415,8 @@ class BookingEndpoint {
}
/**
* Student-initiated cancellation of their own lesson: marks it cancelled,
* Student-initiated cancellation of their own lesson or a guardian's, of one
* of their children's: marks it cancelled,
* frees the slot for rebooking, and voids any still-pending payment. A lesson
* already paid for is credited back to the student's account (a per-lesson
* share of the covering payment) to offset their future scheduled billing.
@@ -355,7 +429,7 @@ class BookingEndpoint {
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $lesson->studentId ) {
if ( ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) {
return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
+8
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Val;
class BookingPage {
@@ -18,6 +19,8 @@ class BookingPage {
/** The student's upcoming lessons only — nothing bookable. */
public const MODE_UPCOMING = 'upcoming';
public function __construct( private GuardianService $guardians ) {}
/**
* Renders the booking shortcode/block output.
*
@@ -64,6 +67,11 @@ class BookingPage {
$showBooking = self::MODE_UPCOMING !== $mode;
$showUpcoming = self::MODE_BOOKING !== $mode;
// Who this account may book for. A single-student account gets one entry
// (themselves) and no selector at all; a guardian's list leads with their
// children, so the default choice is never the parent.
$students = $this->guardians->bookableStudents( get_current_user_id() );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
return (string) ob_get_clean();
+60 -6
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
@@ -20,6 +21,7 @@ class EnrollmentEndpoint {
private RegistrationGate $gate,
private PaymentService $payments,
private GroupAccessRepository $access,
private GuardianService $guardians,
) {}
/**
@@ -47,6 +49,13 @@ class EnrollmentEndpoint {
'required' => true,
'sanitize_callback' => 'absint',
],
// Who is being enrolled. 0/absent means the caller enrols
// themselves; a child's id is honoured only for their guardian.
'student_id' => [
'type' => 'integer',
'default' => 0,
'sanitize_callback' => 'absint',
],
'answers' => [
'type' => 'object',
'default' => [],
@@ -81,13 +90,25 @@ class EnrollmentEndpoint {
} elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
$enrollments = $this->enrollments->findByInstructor( $userId );
} else {
$enrollments = $this->enrollments->findByStudent( $userId );
// A guardian sees the whole household's enrolments — their own and
// every child's — so one account covers the family.
$enrollments = [];
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) {
$enrollments = array_merge( $enrollments, $this->enrollments->findByStudent( $studentId ) );
}
}
return new \WP_REST_Response( array_map( fn( Enrollment $e ) => $e->toArray(), $enrollments ), 200 );
}
public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
// Who is being enrolled is settled before anything else, so an
// unauthorised student id never reaches a seat claim or a charge.
$studentId = $this->resolveStudent( $request );
if ( $studentId instanceof \WP_Error ) {
return $studentId;
}
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$offering = $this->offerings->findById( $offeringId );
@@ -95,8 +116,6 @@ class EnrollmentEndpoint {
return new \WP_Error( 'invalid_offering', __( 'Group class not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
$studentId = get_current_user_id();
if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) {
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
@@ -133,7 +152,9 @@ class EnrollmentEndpoint {
)
);
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
// The acceptance binds the student but is attributed to whoever ticked the
// boxes — the guardian, when they enrolled a child.
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
// Mark the access grant used so instructor rosters distinguish invited
// students from enrolled ones (a no-op for public classes).
@@ -146,7 +167,16 @@ class EnrollmentEndpoint {
// regardless of payment.
$payment = null;
if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) {
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
$payment = $this->payments->createForRegistration(
Payment::REG_ENROLLMENT,
$id,
$studentId,
$offering->instructorId,
$offering->price,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
);
}
// `payment: null` tells the front end to skip the payment step entirely.
@@ -176,7 +206,7 @@ class EnrollmentEndpoint {
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $enrollment->studentId ) {
if ( ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) {
return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
@@ -212,6 +242,30 @@ class EnrollmentEndpoint {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
}
/**
* Who this enrolment is for: the caller by default, or one of their children
* when a `student_id` is supplied and they are that child's guardian. An id
* the caller may not act for is a 403, never a silent fallback to themselves.
*/
private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error {
$userId = get_current_user_id();
$requested = absint( Val::int( $request->get_param( 'student_id' ) ) );
if ( $requested <= 0 || $requested === $userId ) {
return $userId;
}
if ( ! $this->guardians->canActFor( $userId, $requested ) ) {
return new \WP_Error(
'forbidden',
__( 'You cannot enrol that student.', 'unsupervised-schedular' ),
[ 'status' => 403 ]
);
}
return $requested;
}
/**
* Extract a question_id => value map from the request.
*
+7
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Val;
class GroupClassPage {
public function __construct( private GuardianService $guardians ) {}
/**
* Renders the group-class enrolment shortcode output.
*
@@ -39,6 +42,10 @@ class GroupClassPage {
$offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) );
// Who this account may enrol — children first, the account holder last, so
// a guardian's default choice is a child rather than themselves.
$students = $this->guardians->bookableStudents( get_current_user_id() );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
return (string) ob_get_clean();
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Guardian;
use Unsupervised\Schedular\Auth\RoleManager;
/**
* Keeps child accounts unusable as logins. A child holds the `us_student` role
* so every `student_id` lookup in the schema keeps working, but nobody is ever
* given its credentials this closes the door the role would otherwise leave
* open:
*
* - authentication is refused outright, and
* - the booking capability is withheld, so nothing that reaches a capability
* check on a child's own session (there should be none) can book as them.
*
* Both key off the `us_child` meta, so ordinary students are untouched.
*/
class ChildLoginGate {
public function register(): void {
add_filter( 'wp_authenticate_user', [ $this, 'blockChildLogin' ], 10, 1 );
add_filter( 'user_has_cap', [ $this, 'withholdBooking' ], 10, 4 );
}
/**
* Refuse authentication for a child account. Runs after password
* verification, so it holds even if a password were somehow set on one.
*
* @param \WP_User|\WP_Error $user Authenticating user, or an earlier error.
* @return \WP_User|\WP_Error
*/
public function blockChildLogin( $user ) {
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
return new \WP_Error(
'us_child_account',
esc_html__( 'This is a managed student account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' )
);
}
return $user;
}
/**
* Strip the booking capability from a child account, so the only route to a
* lesson in their name is their guardian's authorised booking.
*
* @param array<string, bool> $allcaps All capabilities currently held.
* @param array<int, string> $caps Required capabilities (unused).
* @param array<int, mixed> $args Callback args (unused).
* @param mixed $user The user being checked (a WP_User in practice).
* @return array<string, bool>
*/
public function withholdBooking( array $allcaps, array $caps, array $args, mixed $user ): array {
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] );
}
return $allcaps;
}
}
+284
View File
@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Guardian;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Val;
/**
* The guardian's "my family" screen (`[us_family]`): list, add, edit and remove
* the children they book for.
*
* Submissions are processed on `template_redirect` before any output and
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
* same child twice.
*/
class FamilyPage {
/** Query flag carrying a completed action back to {@see render()}. */
private const RESULT_ADDED = 'added';
private const RESULT_UPDATED = 'updated';
private const RESULT_REMOVED = 'removed';
/**
* Error from the most recent submission processed on `template_redirect`,
* carried over to {@see render()} so it can be shown inline with the form.
*/
private string $submitError = '';
public function __construct(
private GuardianService $guardians,
private QuestionRepository $questions,
private AnswerRepository $answers,
) {}
/**
* Renders the family shortcode/block output.
*
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
* shortcode attributes (`login_page_id`).
*/
public function render( array $atts ): string {
if ( ! is_user_logged_in() ) {
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( $this->loginUrl( $loginPageId ) ),
esc_html__( 'log in to manage your profile', 'unsupervised-schedular' )
);
}
wp_enqueue_style( 'us-scheduler' );
$userId = get_current_user_id();
$children = $this->guardians->children( $userId );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$error = $this->submitError;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
$result = sanitize_key( Val::string( wp_unslash( $_GET['us_family'] ?? '' ) ) );
$notice = $this->noticeFor( $result );
// Which child the "edit" link opened, if any — the row is swapped for an
// editable form rather than every row carrying one.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing; the edit submit is nonce-checked.
$editingId = absint( Val::int( $_GET['us_edit_child'] ?? 0 ) );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/family-page.php';
return (string) ob_get_clean();
}
/**
* Process an add/edit/remove submission on `template_redirect`, before any
* page output, then post/redirect/get back to the page. An error is stashed
* for {@see render()} to show inline with the form.
*/
public function maybeHandleSubmit(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing only; the action is nonce-checked immediately below.
$action = sanitize_key( Val::string( wp_unslash( $_POST['us_family_action'] ?? '' ) ) );
if ( '' === $action || ! is_user_logged_in() ) {
return;
}
if ( ! check_admin_referer( 'us_family' ) ) {
return;
}
$userId = get_current_user_id();
$result = match ( $action ) {
'add' => $this->handleAdd( $userId ),
'edit' => $this->handleEdit( $userId ),
'remove' => $this->handleRemove( $userId ),
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
};
if ( $result instanceof \WP_Error ) {
$this->submitError = $result->get_error_message();
return;
}
$this->redirect( add_query_arg( 'us_family', $result, $this->currentUrl() ) );
}
/**
* Add a child, then record their answers to the account-signup questions
* asked per child, since they describe the student rather than the account.
*
* Required answers are validated *before* the child is created, so a missing
* one never leaves a nameless half-added child behind.
*/
private function handleAdd( int $guardianId ): string|\WP_Error {
$name = $this->postString( 'child_name' );
$birthYear = $this->postString( 'child_birth_year' );
$relationship = $this->postString( 'child_relationship' );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$answers = $this->submittedAnswers();
$missing = $this->firstMissingAnswer( $questions, $answers );
if ( null !== $missing ) {
return $missing;
}
$childId = $this->guardians->createChild( $guardianId, $name, $birthYear, $relationship );
if ( $childId instanceof \WP_Error ) {
return $childId;
}
$this->recordAnswers( $questions, $answers, $childId );
return self::RESULT_ADDED;
}
private function handleEdit( int $guardianId ): string|\WP_Error {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_birth_year' ) );
return $error ?? self::RESULT_UPDATED;
}
private function handleRemove( int $guardianId ): string|\WP_Error {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
$error = $this->guardians->removeChild( $guardianId, $childId );
return $error ?? self::RESULT_REMOVED;
}
/**
* The first required question left unanswered, as the error to show or null
* when every required question has a value.
*
* @param list<Question> $questions
* @param array<int, string> $answers question_id => submitted value
*/
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
foreach ( $questions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this student.', 'unsupervised-schedular' ) );
}
}
return null;
}
/**
* Persist a child's answers to the account-signup questions. The answer is
* recorded against the child, not the guardian, so a studio admin reading a
* child's screen sees the information that describes them.
*
* @param list<Question> $questions
* @param array<int, string> $answers question_id => submitted value
*/
private function recordAnswers( array $questions, array $answers, int $childId ): void {
foreach ( $questions as $question ) {
$value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) );
if ( '' === $value ) {
continue;
}
$this->answers->insert(
new Answer(
questionId: (int) $question->id,
registrationType: Answer::REG_ACCOUNT,
registrationId: $childId,
studentId: $childId,
answerValue: $value,
)
);
}
}
/**
* The account-question answers submitted with the form, keyed by question id.
*
* @return array<int, string>
*/
private function submittedAnswers(): array {
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- nonce checked by the caller; each value is unslashed and sanitized in the loop below.
$raw = $_POST['us_answers'] ?? [];
if ( ! is_array( $raw ) ) {
return [];
}
$out = [];
foreach ( $raw as $questionId => $value ) {
$out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
return $out;
}
/**
* A sanitized text field from the submission. The caller has already verified
* the nonce.
*/
private function postString( string $key ): string {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
return sanitize_text_field( Val::string( wp_unslash( $_POST[ $key ] ?? '' ) ) );
}
/**
* The confirmation to show for a completed action, or an empty string when
* the flag is absent or unrecognised.
*/
private function noticeFor( string $result ): string {
return match ( $result ) {
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
default => '',
};
}
/**
* The current page's clean permalink, used as the post/redirect/get target so
* the edit flag and any stale notice are dropped from the URL.
*/
private function currentUrl(): string {
$url = get_permalink();
return is_string( $url ) ? $url : home_url( '/' );
}
/**
* Issues the post-submit redirect and stops the request. Split out so tests
* can observe the target without the process exiting.
*/
protected function redirect( string $url ): void {
wp_safe_redirect( $url );
exit;
}
/**
* URL the logged-out prompt sends visitors to: the chosen login page when one
* is configured (and still exists), otherwise the WordPress login screen with
* a redirect back to the current page.
*/
public function loginUrl( int $loginPageId ): string {
if ( $loginPageId > 0 ) {
$url = get_permalink( $loginPageId );
if ( is_string( $url ) ) {
return $url;
}
}
$permalink = get_permalink();
return wp_login_url( false === $permalink ? '' : $permalink );
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Guardian;
use Unsupervised\Schedular\Val;
/**
* One parent/guardian child link. The child is a real (login-less) WordPress
* user, so `studentId` is a `wp_users` ID exactly like every other student id in
* the schema this row only records who books and pays on their behalf.
*/
class GuardianLink {
public function __construct(
public readonly int $guardianId,
public readonly int $studentId,
public readonly string $relationship = '',
public readonly ?string $createdAt = null,
public readonly ?int $id = null,
) {}
public static function fromRow( \stdClass $row ): self {
return new self(
guardianId: Val::int( $row->guardian_id ),
studentId: Val::int( $row->student_id ),
relationship: Val::string( $row->relationship ?? '' ),
createdAt: Val::stringOrNull( $row->created_at ?? null ),
id: Val::int( $row->id ),
);
}
/**
* Returns a plain array representation of the link.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'guardian_id' => $this->guardianId,
'student_id' => $this->studentId,
'relationship' => $this->relationship,
'created_at' => $this->createdAt,
];
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Guardian;
class GuardianRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_guardians';
}
/**
* Link a child to a guardian. Returns 0 without inserting when the child
* already has a guardian: v1 is one guardian per child, and the check lives
* here so every caller (signup, the family screen, admin) gets it.
*/
public function insert( GuardianLink $link ): int {
if ( null !== $this->findByStudent( $link->studentId ) ) {
return 0;
}
$this->db->insert(
$this->table,
[
'guardian_id' => $link->guardianId,
'student_id' => $link->studentId,
'relationship' => $link->relationship,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%s' ]
);
return $this->db->insert_id;
}
/**
* The link naming this child's guardian, or null when they book for
* themselves.
*/
public function findByStudent( int $studentId ): ?GuardianLink {
$row = $this->db->get_row(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d LIMIT 1',
$this->table,
$studentId
)
);
return $row ? GuardianLink::fromRow( $row ) : null;
}
/**
* Every child linked to a guardian, oldest link first the order they are
* offered in the booking selector, so it stays stable as children are added.
*
* @return list<GuardianLink>
*/
public function findByGuardian( int $guardianId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE guardian_id = %d ORDER BY created_at ASC, id ASC',
$this->table,
$guardianId
)
);
return array_map( GuardianLink::fromRow( ... ), $rows ?? [] );
}
/**
* Whether this exact guardian↔child pair is linked the authorisation check
* behind every "act for this student" boundary.
*/
public function isGuardianOf( int $guardianId, int $studentId ): bool {
$found = $this->db->get_var(
$this->db->prepare(
'SELECT id FROM %i WHERE guardian_id = %d AND student_id = %d LIMIT 1',
$this->table,
$guardianId,
$studentId
)
);
return null !== $found;
}
/**
* Remove the link between a guardian and one of their children. Deleting the
* child user itself is the caller's decision ({@see GuardianService::removeChild()});
* this only unlinks.
*/
public function delete( int $guardianId, int $studentId ): bool {
$deleted = $this->db->delete(
$this->table,
[
'guardian_id' => $guardianId,
'student_id' => $studentId,
],
[ '%d', '%d' ]
);
return (int) $deleted > 0;
}
/**
* How many children a guardian has enough to decide whether the booking
* page needs a "who is this for?" selector at all.
*/
public function countChildren( int $guardianId ): int {
$count = $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE guardian_id = %d',
$this->table,
$guardianId
)
);
return (int) $count;
}
}
+441
View File
@@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Guardian;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Val;
/**
* Everything a guardian does on a child's behalf: creating the child's
* login-less account, deciding who may act for whom, and resolving the payer and
* contact behind a student id.
*/
class GuardianService {
/**
* Marks a `wp_users` row as a child account: created by a guardian, holding
* the student role so every `student_id` lookup keeps working, but with no
* usable login. {@see ChildLoginGate} enforces the "no login" half.
*/
public const META_CHILD = 'us_child';
/** A child's birth year (`YYYY`), collected at signup and editable after. */
public const META_BIRTH_YEAR = 'us_birth_year';
/**
* The full date of birth this feature used to collect. Nothing writes it any
* more: it is read once, to derive a birth year for a child who predates the
* change, and cleared the moment that child's record is next saved. Kept
* public so a site that wants to purge the old dates outright can find them.
*/
public const META_DOB = 'us_date_of_birth';
/**
* The earliest birth year the form will accept. Old enough for any student a
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
* that would otherwise be stored as a plausible-looking year.
*/
private const MIN_BIRTH_YEAR = 1900;
/**
* Domain used for a child's placeholder login address. `.invalid` is reserved
* by RFC 2606 and can never resolve, so a child's address is guaranteed
* undeliverable nothing about a child's account can ever be emailed to
* somewhere real by mistake.
*/
private const CHILD_EMAIL_DOMAIN = 'child.invalid';
public function __construct(
private GuardianRepository $guardians,
private BookingRepository $bookings,
private EnrollmentRepository $enrollments,
) {}
/**
* Create a login-less child account and link it to its guardian. The password
* is random and discarded it is never stored anywhere readable, emailed, or
* shown so the account cannot be signed into even if the gate were removed.
*
* Returns the new user ID, or a `WP_Error` when the name is blank, the birth
* year is missing or unusable, or WordPress refuses the insert.
*/
public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
$name = trim( $name );
if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
}
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
}
$email = $this->childEmail();
$userId = wp_insert_user(
[
'user_login' => $email,
'user_email' => $email,
'user_pass' => wp_generate_password( 24, true, true ),
'display_name' => $name,
'nickname' => $name,
'role' => RoleManager::STUDENT,
]
);
if ( is_wp_error( $userId ) ) {
return $userId;
}
$userId = (int) $userId;
update_user_meta( $userId, self::META_CHILD, '1' );
$this->setBirthYear( $userId, $birthYear );
$linkId = $this->guardians->insert(
new GuardianLink(
guardianId: $guardianId,
studentId: $userId,
relationship: trim( $relationship ),
)
);
// The child was just created, so it cannot already be linked — a failure
// here means the insert itself failed, and leaving an unreachable orphan
// user behind would be worse than reporting it.
if ( $linkId <= 0 ) {
$this->deleteUser( $userId );
return new \WP_Error( 'link_failed', __( 'Could not add this student. Please contact the studio.', 'unsupervised-schedular' ) );
}
return $userId;
}
/**
* Rename a child and update their birth year. Refuses a student the caller
* is not the guardian of, so the family screen cannot be turned into an
* arbitrary user editor by posting someone else's id.
*
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
*/
public function updateChild( int $guardianId, int $studentId, string $name, string $birthYear = '' ): ?\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
}
$name = trim( $name );
if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
}
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
}
$result = wp_update_user(
[
'ID' => $studentId,
'display_name' => $name,
'nickname' => $name,
]
);
if ( is_wp_error( $result ) ) {
return $result;
}
$this->setBirthYear( $studentId, $birthYear );
return null;
}
/**
* Unlink a child and delete their account. Refused once the child has any
* lesson or enrolment history: their id is referenced by lessons, payments and
* credits, and deleting the user would orphan all of it. A studio admin
* handles those cases by hand.
*
* Returns null on success.
*/
public function removeChild( int $guardianId, int $studentId ): ?\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
}
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
return new \WP_Error(
'has_history',
__( 'This student has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' )
);
}
$this->guardians->delete( $guardianId, $studentId );
$this->deleteUser( $studentId );
return null;
}
/**
* Whether `$actorId` may book, cancel and pay as `$studentId` true for
* themselves, and for a guardian acting as one of their own children. This is
* the authorisation boundary the REST endpoints and form handlers check before
* honouring a submitted student id.
*/
public function canActFor( int $actorId, int $studentId ): bool {
if ( $actorId <= 0 || $studentId <= 0 ) {
return false;
}
return $actorId === $studentId || $this->guardians->isGuardianOf( $actorId, $studentId );
}
/**
* Who owes a student's charges: their guardian when they have one, otherwise
* themselves. Payments, credits and the billing-method override all resolve
* through this, so a family shares one balance and one billing setting.
*/
public function payerFor( int $studentId ): int {
$link = $this->guardians->findByStudent( $studentId );
return null !== $link ? $link->guardianId : $studentId;
}
/**
* The student ids whose lessons `$userId` may see: their own plus every child
* they are guardian for.
*
* @return list<int>
*/
public function householdIds( int $userId ): array {
$ids = [ $userId ];
foreach ( $this->guardians->findByGuardian( $userId ) as $link ) {
$ids[] = $link->studentId;
}
return array_values( array_unique( $ids ) );
}
/**
* The people a user may book or enrol for: **children first**, then
* themselves. The order is the point a guardian's normal case is booking for
* a child, so the first option (and hence the default selection) is a child,
* never the parent. Booking for a child by mistake is a correctable
* inconvenience; silently billing a parent's account for a lesson meant for
* their kid is the error worth designing out.
*
* The guardian is still offered, last, so a parent taking lessons alongside
* their children can book for themselves from the same account.
*
* @return list<array{id: int, name: string, is_self: bool}>
*/
public function bookableStudents( int $userId ): array {
$out = [];
foreach ( $this->children( $userId ) as $child ) {
$out[] = [
'id' => $child['id'],
'name' => $child['name'],
'is_self' => false,
];
}
$self = get_userdata( $userId );
$out[] = [
'id' => $userId,
'name' => UserName::format( $self instanceof \WP_User ? $self : null, $userId ),
'is_self' => true,
];
return $out;
}
/**
* A guardian's children, in link order, with the details the family and admin
* screens display.
*
* @return list<array{id: int, name: string, birth_year: string, relationship: string}>
*/
public function children( int $guardianId ): array {
$out = [];
foreach ( $this->guardians->findByGuardian( $guardianId ) as $link ) {
$user = get_userdata( $link->studentId );
$out[] = [
'id' => $link->studentId,
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
'birth_year' => $this->birthYear( $link->studentId ),
'relationship' => $link->relationship,
];
}
return $out;
}
/**
* The guardian behind a child, or null when the student books for themselves.
*
* @return array{id: int, name: string, email: string}|null
*/
public function guardianOf( int $studentId ): ?array {
$link = $this->guardians->findByStudent( $studentId );
if ( null === $link ) {
return null;
}
$user = get_userdata( $link->guardianId );
return [
'id' => $link->guardianId,
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->guardianId ),
'email' => $user instanceof \WP_User ? $user->user_email : '',
];
}
/**
* Who to contact about a student: their guardian when they have one, otherwise
* the student. What an instructor looking at a child's lesson actually needs
* a child's own address is an undeliverable placeholder.
*
* @return array{id: int, name: string, email: string}
*/
public function contactFor( int $studentId ): array {
$guardian = $this->guardianOf( $studentId );
if ( null !== $guardian ) {
return $guardian;
}
$user = get_userdata( $studentId );
return [
'id' => $studentId,
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $studentId ),
'email' => $user instanceof \WP_User ? $user->user_email : '',
];
}
/**
* A student's display name, or an empty string when the user is gone. Used
* wherever a charge or lesson has to say whose it is.
*/
public function studentName( int $studentId ): string {
$user = get_userdata( $studentId );
return UserName::format( $user instanceof \WP_User ? $user : null );
}
/**
* Whether a user is a child account (created by a guardian, cannot sign in).
*/
public static function isChild( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_CHILD, true ) );
}
/**
* Delete a child's user account. Split out so the front-end paths pull in the
* admin user functions `wp_delete_user()` lives in it is not loaded on the
* front end, where the family screen runs.
*/
public function deleteUser( int $userId ): void {
if ( ! function_exists( 'wp_delete_user' ) ) {
require_once ABSPATH . 'wp-admin/includes/user.php';
}
wp_delete_user( $userId );
}
/**
* Store a child's birth year, or clear it when blank or out of range.
*
* Either way the legacy full date of birth goes with it. That is what makes
* the read fallback in {@see birthYear()} safe: without it, clearing the year
* on a child who predates this change would leave the old date behind for the
* fallback to resurrect on the very next read.
*/
private function setBirthYear( int $userId, string $birthYear ): void {
delete_user_meta( $userId, self::META_DOB );
$year = self::normaliseBirthYear( $birthYear );
if ( 0 === $year ) {
delete_user_meta( $userId, self::META_BIRTH_YEAR );
return;
}
update_user_meta( $userId, self::META_BIRTH_YEAR, (string) $year );
}
/**
* A submitted birth year as an integer, or 0 when it is blank, not a number,
* or outside {@see MIN_BIRTH_YEAR}..this year. A year in the future is a typo
* every time, so it is refused rather than stored.
*
* Public and static so the signup form can reject a bad year up front, before
* it creates any users, without a second copy of the rule to keep in step.
*/
public static function normaliseBirthYear( string $birthYear ): int {
$birthYear = trim( $birthYear );
if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
return 0;
}
$year = (int) $birthYear;
if ( $year < self::MIN_BIRTH_YEAR || $year > (int) current_time( 'Y' ) ) {
return 0;
}
return $year;
}
/**
* The message shown when a birth year is missing or unusable. One phrasing,
* shared by the signup form and the profile screen, so a guardian is told the
* same thing whichever way they got there.
*/
public static function birthYearError(): string {
return sprintf(
/* translators: %d: the earliest birth year the form accepts. */
__( 'Please give each student a birth year, as four digits from %d onwards.', 'unsupervised-schedular' ),
self::MIN_BIRTH_YEAR
);
}
/**
* A child's birth year, or an empty string when none is recorded.
*
* Falls back to the year of the full date of birth this feature used to
* collect, so a child added before the change still shows one. The fallback
* is read-only and one-way: {@see setBirthYear()} drops the old date as soon
* as the record is saved again.
*/
private function birthYear( int $userId ): string {
$year = Val::string( get_user_meta( $userId, self::META_BIRTH_YEAR, true ) );
if ( '' !== $year ) {
return $year;
}
$legacy = Val::string( get_user_meta( $userId, self::META_DOB, true ) );
return 1 === preg_match( '/^(\d{4})-/', $legacy, $m ) ? $m[1] : '';
}
/**
* An unused placeholder address for a child's account. WordPress requires a
* unique email per user, so the random suffix is retried against
* `email_exists()` rather than assumed unique.
*/
private function childEmail(): string {
do {
$email = 'us-child-' . wp_generate_password( 12, false, false ) . '@' . self::CHILD_EMAIL_DOMAIN;
} while ( false !== email_exists( $email ) );
return strtolower( $email );
}
}
+11
View File
@@ -5,7 +5,10 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
class Installer {
@@ -50,5 +53,13 @@ class Installer {
}
( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows();
// Guardian accounts introduced "who pays" / "who agreed" alongside "who the
// student is". Every row written before then had them one and the same, so
// point the new columns at the student rather than leaving them 0 — the
// balance and acceptance lookups key on them directly.
( new PaymentRepository( $wpdb ) )->backfillPayerIds();
( new CreditRepository( $wpdb ) )->backfillPayerIds();
( new AcceptanceRepository( $wpdb ) )->backfillAcceptedBy();
}
}
+17
View File
@@ -26,6 +26,12 @@ class Credit {
public readonly int $studentId,
public readonly float $amount,
public readonly float $remaining,
/**
* The account holding this balance a child's guardian, or 0 meaning
* "the student themselves". A family's credits all sit on the guardian,
* so one child's cancellation can settle a sibling's charge.
*/
public readonly int $payerId = 0,
public readonly string $currency = 'CAD',
public readonly ?int $sourcePaymentId = null,
public readonly ?int $sourceLessonId = null,
@@ -41,6 +47,7 @@ class Credit {
studentId: Val::int( $row->student_id ),
amount: Val::float( $row->amount ),
remaining: Val::float( $row->remaining ),
payerId: Val::int( $row->payer_id ?? 0 ),
currency: Val::string( $row->currency ),
sourcePaymentId: Val::intOrNull( $row->source_payment_id ?? null ),
sourceLessonId: Val::intOrNull( $row->source_lesson_id ?? null ),
@@ -56,6 +63,15 @@ class Credit {
return self::STATUS_AVAILABLE === $this->status && $this->remaining > 0.0;
}
/**
* Whose balance this credit sits in: the recorded payer, falling back to the
* student. Callers go through here rather than reading `payerId`, so the `0`
* default of a pre-guardian credit never leaks out as a user id.
*/
public function payerOrStudent(): int {
return $this->payerId > 0 ? $this->payerId : $this->studentId;
}
/**
* Returns a plain array representation of the credit.
*
@@ -65,6 +81,7 @@ class Credit {
return [
'id' => $this->id,
'student_id' => $this->studentId,
'payer_id' => $this->payerOrStudent(),
'amount' => $this->amount,
'remaining' => $this->remaining,
'currency' => $this->currency,
+32 -14
View File
@@ -16,6 +16,7 @@ class CreditRepository {
$this->table,
[
'student_id' => $credit->studentId,
'payer_id' => $credit->payerOrStudent(),
'amount' => $credit->amount,
'remaining' => $credit->remaining,
'currency' => $credit->currency,
@@ -25,7 +26,7 @@ class CreditRepository {
'status' => $credit->status,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ]
[ '%d', '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
@@ -56,15 +57,16 @@ class CreditRepository {
}
/**
* A student's total unused credit balance (sum of the remaining amounts of every
* still-available credit).
* A payer's total unused credit balance (sum of the remaining amounts of every
* still-available credit). Keyed on the payer, so a guardian's balance covers
* credits earned by any of their children one family, one balance.
*/
public function availableBalance( int $studentId ): float {
public function availableBalance( int $payerId ): float {
$total = $this->db->get_var(
$this->db->prepare(
'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE student_id = %d AND status = %s',
'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE payer_id = %d AND status = %s',
$this->table,
$studentId,
$payerId,
Credit::STATUS_AVAILABLE
)
);
@@ -73,17 +75,17 @@ class CreditRepository {
}
/**
* A student's still-available credits, oldest first the FIFO order they are
* A payer's still-available credits, oldest first the FIFO order they are
* consumed in.
*
* @return list<Credit>
*/
public function findAvailableByStudent( int $studentId ): array {
public function findAvailableByPayer( int $payerId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC',
'SELECT * FROM %i WHERE payer_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC',
$this->table,
$studentId,
$payerId,
Credit::STATUS_AVAILABLE
)
);
@@ -92,7 +94,10 @@ class CreditRepository {
}
/**
* Every credit for a student, newest first (admin history).
* Every credit earned by a student, newest first the admin history on their
* own screen. Unlike the balance this is keyed on the student, so a child's
* screen shows the credits their cancellations produced even though the
* balance itself sits with their guardian.
*
* @return list<Credit>
*/
@@ -109,17 +114,30 @@ class CreditRepository {
}
/**
* Draw down a student's credit balance by $amount, consuming their available
* Backfill `payer_id` on credits written before guardian accounts existed,
* where the student was always the payer. Run once from the installer so the
* payer-keyed balance queries see those rows.
*/
public function backfillPayerIds(): void {
$sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table );
if ( null !== $sql ) {
$this->db->query( $sql );
}
}
/**
* Draw down a payer's credit balance by $amount, consuming their available
* credits oldest first and marking each fully-spent credit `consumed`. Stops once
* the amount is exhausted; a balance shorter than $amount simply drains to zero.
*/
public function consume( int $studentId, float $amount ): void {
public function consume( int $payerId, float $amount ): void {
$remaining = round( $amount, 2 );
if ( $remaining <= 0.0 ) {
return;
}
foreach ( $this->findAvailableByStudent( $studentId ) as $credit ) {
foreach ( $this->findAvailableByPayer( $payerId ) as $credit ) {
if ( $remaining <= 0.0 ) {
break;
}
+28
View File
@@ -39,6 +39,13 @@ class Payment {
public readonly string $registrationType,
public readonly int $registrationId,
public readonly float $amount,
/**
* The account that owes this charge a child's guardian, or 0 meaning
* "the student themselves". Zero rather than a copy of `studentId` so
* every payment written before guardian accounts existed reads back with
* its original meaning without a data migration.
*/
public readonly int $payerId = 0,
public readonly string $currency = 'CAD',
public readonly string $method = self::METHOD_ETRANSFER,
public readonly string $status = self::STATUS_PENDING,
@@ -64,6 +71,7 @@ class Payment {
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
amount: Val::float( $row->amount ),
payerId: Val::int( $row->payer_id ?? 0 ),
currency: Val::string( $row->currency ),
method: Val::string( $row->method ),
status: Val::string( $row->status ),
@@ -87,6 +95,25 @@ class Payment {
return self::STATUS_PAID === $this->status;
}
/**
* Who actually owes this charge: the recorded payer, falling back to the
* student. Every caller that needs a person to bill, receipt or credit goes
* through here rather than reading `payerId` directly, so the `0` default
* never leaks out as a user id.
*/
public function payerOrStudent(): int {
return $this->payerId > 0 ? $this->payerId : $this->studentId;
}
/**
* Whether someone other than the student is paying a guardian. Drives the
* "paid by" line on admin screens, which is noise when they are the same
* person.
*/
public function hasSeparatePayer(): bool {
return $this->payerId > 0 && $this->payerId !== $this->studentId;
}
/**
* Whether this payment was generated by the daily billing scan (weekly /
* monthly) rather than taken at registration. Scheduled payments carry a due
@@ -134,6 +161,7 @@ class Payment {
return [
'id' => $this->id,
'student_id' => $this->studentId,
'payer_id' => $this->payerOrStudent(),
'instructor_id' => $this->instructorId,
'registration_type' => $this->registrationType,
'etransfer_email' => $this->etransferEmail,
+14 -1
View File
@@ -16,6 +16,7 @@ class PaymentRepository {
$this->table,
[
'student_id' => $payment->studentId,
'payer_id' => $payment->payerOrStudent(),
'instructor_id' => $payment->instructorId,
'registration_type' => $payment->registrationType,
'registration_id' => $payment->registrationId,
@@ -36,12 +37,24 @@ class PaymentRepository {
'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ),
],
[ '%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' ]
);
return $this->db->insert_id;
}
/**
* Backfill `payer_id` on payments written before guardian accounts existed,
* where the student was always the payer. Run once from the installer.
*/
public function backfillPayerIds(): void {
$sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table );
if ( null !== $sql ) {
$this->db->query( $sql );
}
}
/**
* Attach the Stripe PaymentIntent id created for a card payment so the webhook
* can later reconcile the charge back to this row.
+38 -15
View File
@@ -34,14 +34,19 @@ class PaymentService {
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
* scan (weekly / monthly) rather than taken at registration; both stay null for
* the pay-now flow.
*
* `$payerId` is who owes it a child's guardian, or 0 (the default) when the
* student pays for themselves. The billing method resolves against the payer,
* so comping or card-billing a family is one setting on the guardian.
*/
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null ): ?Payment {
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null, int $payerId = 0 ): ?Payment {
if ( $amount <= 0.0 ) {
return null;
}
$method = $this->resolver->resolve( $studentId );
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
$payerId = $payerId > 0 ? $payerId : $studentId;
$method = $this->resolver->resolve( $payerId );
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
$etransferEmail = null !== $offeringEtransferEmail && '' !== $offeringEtransferEmail
? $offeringEtransferEmail
@@ -58,6 +63,7 @@ class PaymentService {
registrationType: $type,
registrationId: $registrationId,
amount: $amount,
payerId: $payerId,
currency: $currency,
method: $method,
status: $status,
@@ -72,7 +78,9 @@ class PaymentService {
$this->linkPayment( $type, $registrationId, $id );
if ( Payment::STATUS_PAID === $status ) {
$this->finalizePaid( $id, $type, $registrationId, $studentId );
// The receipt goes to whoever paid, which for a child's lesson is the
// guardian — a child's own address is an undeliverable placeholder.
$this->finalizePaid( $id, $type, $registrationId, $payerId );
}
return $this->payments->findById( $id );
@@ -111,7 +119,7 @@ class PaymentService {
return true;
}
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->studentId );
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() );
return true;
}
@@ -174,11 +182,15 @@ class PaymentService {
return null;
}
// The credit records the child it was earned for, but the balance itself
// lands on whoever paid — so a family's credits pool on the guardian and
// one child's cancellation can settle a sibling's next charge.
$id = $this->credits->insert(
new Credit(
studentId: $payment->studentId,
amount: $share,
remaining: $share,
payerId: $payment->payerOrStudent(),
currency: $payment->currency,
sourcePaymentId: $payment->id,
sourceLessonId: $lesson->id,
@@ -209,19 +221,22 @@ class PaymentService {
}
/**
* Apply a student's available credit balance against a set of freshly-created
* Apply a payer's available credit balance against a set of freshly-created
* pending payments (the ones a billing scan just generated for them), oldest
* charge first. Each payment's `credit_applied` is raised by the amount covered;
* a payment fully covered is marked paid-by-credit and its registration confirmed
* so it leaves the confirmation queue. The credit ledger is drawn down by the
* total applied. Returns a map of payment id to the credit applied to it, so the
* caller can reflect the reduction on the student's notice.
* caller can reflect the reduction on the payer's notice.
*
* Keyed on the payer, so a guardian's balance settles charges raised against
* any of their children the payments passed in may name several students.
*
* @param list<Payment> $payments
* @return array<int, float>
*/
public function applyCredits( int $studentId, array $payments ): array {
$balance = $this->credits->availableBalance( $studentId );
public function applyCredits( int $payerId, array $payments ): array {
$balance = $this->credits->availableBalance( $payerId );
if ( $balance <= 0.0 ) {
return [];
}
@@ -258,7 +273,7 @@ class PaymentService {
}
if ( $consumed > 0.0 ) {
$this->credits->consume( $studentId, $consumed );
$this->credits->consume( $payerId, $consumed );
}
return $applied;
@@ -272,11 +287,19 @@ class PaymentService {
* needs no further action. Returns null when the registration has no payment,
* the caller does not own it, or Stripe could not create the intent.
*
* `$userId` is the caller: either the student the registration is for, or the
* guardian who owes it anyone else gets null rather than a payment step for
* a charge that is not theirs.
*
* @return array<string, mixed>|null
*/
public function createIntent( string $type, int $registrationId, int $studentId ): ?array {
public function createIntent( string $type, int $registrationId, int $userId ): ?array {
$payment = $this->payments->findByRegistration( $type, $registrationId );
if ( null === $payment || null === $payment->id || $payment->studentId !== $studentId ) {
if ( null === $payment || null === $payment->id ) {
return null;
}
if ( $payment->studentId !== $userId && $payment->payerOrStudent() !== $userId ) {
return null;
}
@@ -334,7 +357,7 @@ class PaymentService {
}
if ( 'payment_intent.succeeded' === $event->type && ! $payment->isPaid() ) {
$this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->studentId );
$this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() );
} elseif ( 'payment_intent.payment_failed' === $event->type && ! $payment->isPaid() ) {
$this->payments->updateStatus( $payment->id, Payment::STATUS_FAILED );
}
@@ -342,12 +365,12 @@ class PaymentService {
return true;
}
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $studentId ): void {
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $payerId ): void {
$this->payments->markPaid( $paymentId, 'USC-' . $paymentId );
$this->confirmRegistration( $type, $registrationId );
$paid = $this->payments->findById( $paymentId );
$user = get_userdata( $studentId );
$user = get_userdata( $payerId );
if ( null !== $paid && $this->mailer->send( $paid, $user instanceof \WP_User ? $user : null ) ) {
$this->payments->markReceiptSent( $paymentId );
}
+33 -15
View File
@@ -6,13 +6,14 @@ namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
/**
* Generates the pending payments that scheduled-billing offerings (weekly /
* monthly) owe as they come due, then emails each student one itemised notice.
* monthly) owe as they come due, then emails each payer one itemised notice.
*
* Runs from the daily WP-Cron action `us_generate_due_payments`. It is
* self-healing: every run re-scans from the current ledger state, so a missed
@@ -30,6 +31,7 @@ class ScheduledBillingRunner {
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
private PaymentDueMailer $mailer,
private GuardianService $guardians,
) {}
public function register(): void {
@@ -42,12 +44,13 @@ class ScheduledBillingRunner {
public function run(): void {
$now = $this->now();
// One notice bucket per student, filled as pending payments are created and
// flushed to a single email at the end, so a student billed for several
// lessons on one day is emailed once — never once per lesson. Each entry keeps
// the created payment and its label; credits are applied across the whole
// bucket before the notice is built, so a student's account credit offsets the
// run's charges oldest-first.
// One notice bucket per *payer*, filled as pending payments are created and
// flushed to a single email at the end, so a payer billed for several
// lessons on one day is emailed once — never once per lesson, and a guardian
// gets one notice covering every child rather than one per child. Each entry
// keeps the created payment and its label; credits are applied across the
// whole bucket before the notice is built, so the family's account credit
// offsets the run's charges oldest-first.
$buckets = [];
$this->billPrivateLessons( $now, $buckets );
@@ -303,19 +306,24 @@ class ScheduledBillingRunner {
/**
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
* add it to the student's notice bucket with the label to show on the notice.
* add it to the payer's notice bucket with the label to show on the notice.
* Credits are applied later, once the whole bucket is known. Returns the created
* payment, or null when there was nothing to charge.
*
* The charge is bucketed against whoever owes it, so a guardian's notice covers
* all their children; the label names the child when that differs from the
* payer, or a parent cannot tell whose lesson each line is.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function bill( array &$buckets, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey );
$payerId = $this->guardians->payerFor( $studentId );
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey, $payerId );
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
$buckets[ $studentId ][] = [
$buckets[ $payerId ][] = [
'payment' => $payment,
'label' => $label,
'label' => $payerId === $studentId ? $label : $this->labelFor( $studentId, $label ),
];
}
@@ -323,7 +331,17 @@ class ScheduledBillingRunner {
}
/**
* For each student, apply any account credit they hold against the run's charges,
* Prefix a notice line with the student it is for "Ada: Piano Lesson —
* Mar 3, 2026" — used only when the payer is not the student.
*/
private function labelFor( int $studentId, string $label ): string {
$name = $this->guardians->studentName( $studentId );
return '' === $name ? $label : $name . ': ' . $label;
}
/**
* For each payer, apply any account credit they hold against the run's charges,
* tag the payments they still owe with a shared batch reference, and email them
* one itemised notice. The notice lists each charge at its full amount, then the
* credit applied and the reduced total due; a charge fully covered by credit is
@@ -333,9 +351,9 @@ class ScheduledBillingRunner {
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function sendNotices( array $buckets ): void {
foreach ( $buckets as $studentId => $entries ) {
foreach ( $buckets as $payerId => $entries ) {
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
$applied = $this->payments->applyCredits( $studentId, $payments );
$applied = $this->payments->applyCredits( $payerId, $payments );
$items = [];
$batchIds = [];
@@ -366,7 +384,7 @@ class ScheduledBillingRunner {
$reference = [] !== $batchIds ? $this->reference() : '';
$this->payments->assignNoticeBatch( $batchIds, $reference );
$user = get_userdata( $studentId );
$user = get_userdata( $payerId );
if ( $user instanceof \WP_User ) {
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
}
+19 -8
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationLoginGate;
use Unsupervised\Schedular\Auth\RegistrationMailer;
@@ -17,6 +18,10 @@ use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Guardian\ChildLoginGate;
use Unsupervised\Schedular\Guardian\FamilyPage;
use Unsupervised\Schedular\Guardian\GuardianRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository;
@@ -76,6 +81,9 @@ class Plugin {
$groupAccess = new GroupAccessRepository( $wpdb );
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
$guardianRepo = new GuardianRepository( $wpdb );
$guardians = new GuardianService( $guardianRepo, $bookings, $enrollments );
$paymentRepo = new PaymentRepository( $wpdb );
$creditRepo = new CreditRepository( $wpdb );
$settings = new StudioSettings();
@@ -87,21 +95,24 @@ class Plugin {
// front-end output is identical whichever way a page embeds them.
$registrationMailer = new RegistrationMailer();
$bookingPage = new BookingPage();
$bookingPage = new BookingPage( $guardians );
$loginPage = new LoginPage();
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
$groupClassPage = new GroupClassPage();
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
$groupClassPage = new GroupClassPage( $guardians );
$familyPage = new FamilyPage( $guardians, $questions, $answers );
$accountPage = new AccountPage();
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register();
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
( new UpdateChecker() )->register();
( new RoleManager() )->register();
( new RegistrationLoginGate() )->register();
( new ChildLoginGate() )->register();
( new StudentAdminGuard() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
}
}
+15 -1
View File
@@ -17,12 +17,13 @@ class AcceptanceRepository {
[
'policy_version_id' => $acceptance->policyVersionId,
'student_id' => $acceptance->studentId,
'accepted_by' => $acceptance->acceptorOrStudent(),
'registration_type' => $acceptance->registrationType,
'registration_id' => $acceptance->registrationId,
'ip_address' => $acceptance->ipAddress,
'accepted_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%d', '%s', '%s' ]
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s' ]
);
return $this->db->insert_id;
@@ -38,6 +39,19 @@ class AcceptanceRepository {
return array_map( fn( PolicyAcceptance $a ): int => $this->insert( $a ), $acceptances );
}
/**
* Backfill `accepted_by` on acceptances recorded before guardian accounts
* existed, where the student always agreed for themselves. Run once from the
* installer so the acceptor is a real user id on every row.
*/
public function backfillAcceptedBy(): void {
$sql = $this->db->prepare( 'UPDATE %i SET accepted_by = student_id WHERE accepted_by = 0', $this->table );
if ( null !== $sql ) {
$this->db->query( $sql );
}
}
/**
* Find all acceptances attached to a registration (lesson or enrolment).
*
+27
View File
@@ -24,6 +24,13 @@ class PolicyAcceptance {
public readonly int $studentId,
public readonly string $registrationType,
public readonly int $registrationId,
/**
* Who actually clicked "I agree" a child's guardian, or 0 meaning the
* student agreed for themselves. Zero rather than a copy of `studentId`
* so every acceptance recorded before guardian accounts existed keeps its
* original meaning.
*/
public readonly int $acceptedBy = 0,
public readonly ?string $ipAddress = null,
public readonly ?string $acceptedAt = null,
public readonly ?int $id = null,
@@ -35,12 +42,31 @@ class PolicyAcceptance {
studentId: Val::int( $row->student_id ),
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
acceptedBy: Val::int( $row->accepted_by ?? 0 ),
ipAddress: Val::stringOrNull( $row->ip_address ),
acceptedAt: Val::stringOrNull( $row->accepted_at ),
id: Val::int( $row->id ),
);
}
/**
* Who this acceptance is legally attributable to: the recorded acceptor,
* falling back to the student. Callers go through here so the `0` default of a
* pre-guardian acceptance never leaks out as a user id.
*/
public function acceptorOrStudent(): int {
return $this->acceptedBy > 0 ? $this->acceptedBy : $this->studentId;
}
/**
* Whether someone other than the student agreed a guardian accepting on a
* child's behalf. Drives the "accepted by" line on the admin screen, which is
* noise when they are the same person.
*/
public function acceptedOnBehalf(): bool {
return $this->acceptedBy > 0 && $this->acceptedBy !== $this->studentId;
}
/**
* Returns a plain array representation of the acceptance.
*
@@ -51,6 +77,7 @@ class PolicyAcceptance {
'id' => $this->id,
'policy_version_id' => $this->policyVersionId,
'student_id' => $this->studentId,
'accepted_by' => $this->acceptorOrStudent(),
'registration_type' => $this->registrationType,
'registration_id' => $this->registrationId,
'ip_address' => $this->ipAddress,
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
/**
* Renders one registration question as a form field. Shared by the signup form
* and the guardian's family screen, which ask the same account-scope questions
* once per guardian at signup, and once per child either way.
*/
class QuestionField {
/**
* The field's markup, escaped and ready to echo.
*
* `$name` is the full input name (e.g. `us_answers[7]`), and `$id` the DOM id
* the label points at both supplied by the caller so the same question can
* appear more than once on a page (one block per child) without colliding.
*
* `$enforceRequired` false keeps the required marker in the label but drops
* the HTML attribute, for a block the browser must not block submission on
* because it may not apply at all the child blocks, which only count when
* the parent/guardian box is ticked. The server validates those either way.
*/
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true ): string {
$required = $question->isRequired && $enforceRequired ? ' required' : '';
$label = '<label for="' . esc_attr( $id ) . '">' . esc_html( $question->label )
. ( $question->isRequired ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
. '</label>';
return '<p>' . $label . self::input( $question, $name, $id, $required ) . '</p>';
}
/**
* The input element itself, chosen by the question's field type. `$required`
* is a literal attribute string (' required' or ''), not user input.
*/
private static function input( Question $question, string $name, string $id, string $required ): string {
$common = ' name="' . esc_attr( $name ) . '" id="' . esc_attr( $id ) . '"' . $required;
if ( Question::FIELD_TEXTAREA === $question->fieldType ) {
return '<textarea' . $common . ' rows="4"></textarea>';
}
if ( Question::FIELD_SELECT === $question->fieldType ) {
$options = '<option value="">' . esc_html__( '— Select —', 'unsupervised-schedular' ) . '</option>';
foreach ( (array) $question->options as $option ) {
$options .= '<option value="' . esc_attr( (string) $option ) . '">' . esc_html( (string) $option ) . '</option>';
}
return '<select' . $common . '>' . $options . '</select>';
}
if ( Question::FIELD_CHECKBOX === $question->fieldType ) {
return '<input type="checkbox"' . $common . ' value="1">';
}
return '<input type="text"' . $common . '>';
}
}
+6 -1
View File
@@ -58,10 +58,14 @@ class RegistrationGate {
/**
* Persist answers and policy acceptances for a created registration.
*
* `$acceptedBy` is who actually agreed, when that is not the student a
* guardian booking for a child. It defaults to 0, read back as "the student
* agreed for themselves".
*
* @param array<int, string> $answers question_id => answer value
* @param list<int> $acceptedVersionIds Accepted policy version IDs
*/
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null ): void {
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0 ): void {
foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) {
$value = (string) ( $answers[ (int) $question->id ] ?? '' );
if ( '' === $value ) {
@@ -90,6 +94,7 @@ class RegistrationGate {
studentId: $studentId,
registrationType: $registrationType,
registrationId: $registrationId,
acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId,
ipAddress: $ipAddress,
)
);
+4 -3
View File
@@ -12,6 +12,7 @@ use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentEndpoint;
@@ -37,13 +38,13 @@ class RestRegistrar {
private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
}
+21
View File
@@ -138,6 +138,7 @@ class Schema {
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
policy_version_id BIGINT UNSIGNED NOT NULL,
student_id BIGINT UNSIGNED NOT NULL,
accepted_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
registration_type VARCHAR(20) NOT NULL,
registration_id BIGINT UNSIGNED NOT NULL,
accepted_at DATETIME NOT NULL,
@@ -145,12 +146,14 @@ class Schema {
PRIMARY KEY (id),
KEY policy_version_id (policy_version_id),
KEY student_id (student_id),
KEY accepted_by (accepted_by),
KEY registration (registration_type, registration_id)
) {$charset};",
"CREATE TABLE {$prefix}us_payments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
student_id BIGINT UNSIGNED NOT NULL,
payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
instructor_id BIGINT UNSIGNED NOT NULL,
registration_type VARCHAR(20) NOT NULL,
registration_id BIGINT UNSIGNED NOT NULL,
@@ -172,6 +175,7 @@ class Schema {
paid_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY student_id (student_id),
KEY payer_id (payer_id),
KEY instructor_id (instructor_id),
KEY registration (registration_type, registration_id),
KEY status (status)
@@ -180,6 +184,7 @@ class Schema {
"CREATE TABLE {$prefix}us_credits (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
student_id BIGINT UNSIGNED NOT NULL,
payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
remaining DECIMAL(10,2) NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
@@ -191,6 +196,7 @@ class Schema {
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY student_id (student_id),
KEY payer_id (payer_id),
KEY status (status),
KEY source_lesson_id (source_lesson_id)
) {$charset};",
@@ -229,6 +235,21 @@ class Schema {
KEY status (status)
) {$charset};",
// Links a parent/guardian account to a child who books through it. The
// child is a real (login-less) wp_users row, so student_id keeps meaning
// "a WordPress user" on every other table.
"CREATE TABLE {$prefix}us_guardians (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
guardian_id BIGINT UNSIGNED NOT NULL,
student_id BIGINT UNSIGNED NOT NULL,
relationship VARCHAR(50) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY guardian_student (guardian_id, student_id),
KEY guardian_id (guardian_id),
KEY student_id (student_id)
) {$charset};",
"CREATE TABLE {$prefix}us_group_access (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
offering_id BIGINT UNSIGNED NOT NULL,
+30 -4
View File
@@ -3,10 +3,12 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Guardian\FamilyPage;
use Unsupervised\Schedular\Payment\StudioSettings;
class ShortcodeRegistrar {
@@ -16,6 +18,8 @@ class ShortcodeRegistrar {
private LoginPage $loginPage,
private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage,
private FamilyPage $familyPage,
private AccountPage $accountPage,
) {}
public function register(): void {
@@ -23,10 +27,15 @@ class ShortcodeRegistrar {
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) );
add_shortcode( 'us_account', self::shortcode( [ $this->accountPage, 'render' ] ) );
// Process registration submissions before output so the invite branch's
// auth cookie is actually sent (render() runs too late, during the_content).
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
// Same reason as registration: the family form redirects after handling,
// which render() (running during the_content) is too late to do.
add_action( 'template_redirect', [ $this->familyPage, 'maybeHandleSubmit' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
}
@@ -76,10 +85,27 @@ class ShortcodeRegistrar {
// Price formatting and the pay agreement, shared by booking and enrolment.
wp_register_script( 'us-scheduler-pricing', USC_PLUGIN_URL . 'assets/js/pricing.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing' ], USC_VERSION, true );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing' ], USC_VERSION, true );
// The "who is this for?" picker, shared by booking and enrolment.
wp_register_script( 'us-scheduler-guardian', USC_PLUGIN_URL . 'assets/js/guardian.js', [], USC_VERSION, true );
// Progressive enhancement for the two-step registration form (no dependencies).
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
/*
* Progressive enhancement for the two-step registration form.
*
* `password-strength-meter` is WordPress's own wrapper around zxcvbn, so
* the signup form scores a password exactly the way wp-admin does rather
* than inventing a second opinion. It pulls in `zxcvbn-async`, which
* fetches the (large) dictionary only once the page has loaded hence
* the guard in register.js for the window where it is not there yet.
*/
wp_register_script(
'us-scheduler-register',
USC_PLUGIN_URL . 'assets/js/register.js',
[ 'password-strength-meter' ],
USC_VERSION,
true
);
}
}
+47 -1
View File
@@ -15,8 +15,12 @@ if (! defined('ABSPATH')) {
* @var list<array{question: string, answer: string, context: string}> $intake
* @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments
* @var list<array{created_at: string, amount: float, remaining: float, currency: string, reason: string, status: string}> $credits
* @var float $creditBalance
* @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child.
* @var string $creditCurrency
* @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves.
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children Children this student books for.
* @var array{id: int, name: string, email: string} $payer Who is billed for this student themselves, or their guardian.
* @var string $pageSlug
* @var string $backUrl
* @var bool $canBilling
* @var string $billingOverride
@@ -105,6 +109,37 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
</form>
<?php if ($guardian !== null || ! empty($children)) : ?>
<h2><?php esc_html_e('Profile', 'unsupervised-schedular'); ?></h2>
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
<?php if ($guardian !== null) : ?>
<p>
<?php
printf(
/* translators: 1: linked name of the parent/guardian, 2: their email address. */
esc_html__('Books and pays through %1$s (%2$s).', 'unsupervised-schedular'),
'<a href="' . esc_url($detailUrl($guardian['id'])) . '">' . esc_html($guardian['name']) . '</a>',
esc_html($guardian['email'])
);
?>
</p>
<p class="description"><?php esc_html_e('This is a managed student account: it has no login of its own, and its email address is a placeholder that cannot receive mail.', 'unsupervised-schedular'); ?></p>
<?php endif; ?>
<?php if (! empty($children)) : ?>
<p><?php esc_html_e('Books and pays for:', 'unsupervised-schedular'); ?></p>
<ul class="ul-disc">
<?php foreach ($children as $child) : ?>
<li>
<a href="<?php echo esc_url($detailUrl($child['id'])); ?>"><?php echo esc_html($child['name']); ?></a>
<?php if ($child['birth_year'] !== '') : ?>
<span class="description"><?php echo esc_html($child['birth_year']); ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php endif; ?>
<h2><?php esc_html_e('Registration Information', 'unsupervised-schedular'); ?></h2>
<?php if (empty($registrationInfo)) : ?>
<p><?php esc_html_e('No registration questions are configured.', 'unsupervised-schedular'); ?></p>
@@ -253,6 +288,17 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
'<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 — the profile shares one balance.', 'unsupervised-schedular'),
esc_html($payer['name'])
);
?>
</span>
<?php endif; ?>
<span class="description"><?php esc_html_e('Credit from cancelled paid lessons is applied automatically to upcoming scheduled billing.', 'unsupervised-schedular'); ?></span>
</p>
<?php if (! empty($credits)) : ?>
+38 -1
View File
@@ -6,9 +6,39 @@ if (! defined('ABSPATH')) {
}
/**
* @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int}> $students
* @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int, guardian: array{id: int, name: string, email: string}|null, children: list<array{id: int, name: string, birth_year: string, relationship: string}>}> $students
* @var string $pageSlug
*/
/**
* The Family cell: for a child, the guardian who books for them; for a guardian,
* the children they book for. Both link to the other person's detail screen so
* an admin can move between a family without going back to the list.
*/
$familyCell = static function (array $student) use ($pageSlug): string {
$link = static fn(int $id, string $name): string => sprintf(
'<a href="%s">%s</a>',
esc_url(add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php'))),
esc_html($name)
);
if ($student['guardian'] !== null) {
return sprintf(
/* translators: %s: linked name of the parent/guardian who books for this student. */
esc_html__('Managed by %s', 'unsupervised-schedular'),
$link($student['guardian']['id'], $student['guardian']['name'])
);
}
if ($student['children'] === []) {
return '—';
}
return implode(', ', array_map(
static fn(array $child): string => $link($child['id'], $child['name']),
$student['children']
));
};
?>
<div class="wrap">
<h1><?php esc_html_e('Students', 'unsupervised-schedular'); ?></h1>
@@ -21,6 +51,7 @@ if (! defined('ABSPATH')) {
<tr>
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Profile', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Active enrolments', 'unsupervised-schedular'); ?></th>
@@ -32,6 +63,12 @@ if (! defined('ABSPATH')) {
<tr>
<td><a href="<?php echo esc_url($detailUrl); ?>"><?php echo esc_html($student['name']); ?></a></td>
<td><?php echo esc_html($student['email']); ?></td>
<td>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $familyCell escapes every name and URL it interpolates.
echo $familyCell($student);
?>
</td>
<td><?php echo esc_html($student['registered']); ?></td>
<td><?php echo esc_html((string) $student['upcoming']); ?></td>
<td><?php echo esc_html((string) $student['enrolments']); ?></td>
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var string $name Display name of the signed-in visitor.
* @var string $email Their account email.
* @var string $logoutUrl Nonced sign-out URL, already carrying its redirect.
*/
?>
<div class="us-account">
<p class="us-account-who">
<span class="us-account-name"><?php echo esc_html($name); ?></span>
<?php if ($email !== '') : ?>
<span class="us-account-email"><?php echo esc_html($email); ?></span>
<?php endif; ?>
</p>
<p class="us-account-actions">
<a class="us-account-signout" href="<?php echo esc_url($logoutUrl); ?>"><?php esc_html_e('Sign out', 'unsupervised-schedular'); ?></a>
</p>
</div>
+15 -4
View File
@@ -9,18 +9,29 @@ if (! defined('ABSPATH')) {
/** @var bool $showTypeFilter Whether the "Show Only" lesson-type filter is offered. */
/** @var bool $showBooking Whether the booking calendar is part of this embed. */
/** @var bool $showUpcoming Whether the student's upcoming-lessons panel is part of this embed. */
/** @var list<array{id: int, name: string, is_self: bool}> $students Who this account may book for — children first, the account holder last. */
// The booking script reads the list as JSON rather than rendering a <select>
// here: the picker belongs inside the booking form it builds, and it is the same
// list the group-classes script needs.
$studentsJson = wp_json_encode(array_values($students));
?>
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>"<?php echo $lessonTypeId > 0 ? ' data-lesson-type="' . esc_attr((string) $lessonTypeId) . '"' : ''; ?><?php echo $showTypeFilter ? '' : ' data-type-filter="0"'; ?>>
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>" data-students="<?php echo esc_attr(is_string($studentsJson) ? $studentsJson : '[]'); ?>"<?php echo $lessonTypeId > 0 ? ' data-lesson-type="' . esc_attr((string) $lessonTypeId) . '"' : ''; ?><?php echo $showTypeFilter ? '' : ' data-type-filter="0"'; ?>>
<?php if ($showUpcoming) : ?>
<div id="us-my-lessons"></div>
<?php endif; ?>
<?php if ($showBooking) : ?>
<?php
/*
* Above the calendar, because it reports on what the student just did and
* the calendar below it is what they do next. Filled and shown by
* booking.js; empty and hidden until then.
*/
?>
<div id="us-booking-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
<div id="us-slot-list">
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
</div>
<div id="us-booking-confirmation" style="display:none;">
<p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p>
</div>
<?php endif; ?>
<div id="us-booking-error" style="display:none;" role="alert"></div>
</div>
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use Unsupervised\Schedular\Registration\QuestionField;
if (! defined('ABSPATH')) {
exit;
}
/**
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
* @var string $error Validation error from the last submission, if any.
* @var string $notice Confirmation of a completed add/edit/remove, if any.
* @var int $editingId Child whose row is open for editing; 0 for none.
*/
?>
<div class="us-family">
<h3><?php esc_html_e('Your profile', 'unsupervised-schedular'); ?></h3>
<?php if ($notice !== '') : ?>
<p class="us-success"><?php echo esc_html($notice); ?></p>
<?php endif; ?>
<?php if ($error !== '') : ?>
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
<?php endif; ?>
<?php if (empty($children)) : ?>
<p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<ul class="us-family-list">
<?php foreach ($children as $child) : ?>
<li class="us-family-child">
<?php if ($editingId === $child['id']) : ?>
<form method="post" action="" class="us-family-edit">
<?php wp_nonce_field('us_family'); ?>
<input type="hidden" name="us_family_action" value="edit">
<input type="hidden" name="child_id" value="<?php echo esc_attr((string) $child['id']); ?>">
<p>
<label for="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required>
</p>
<p>
<label for="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="child_birth_year" required id="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p>
<p>
<button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
<a href="<?php echo esc_url((string) get_permalink()); ?>"><?php esc_html_e('Cancel', 'unsupervised-schedular'); ?></a>
</p>
</form>
<?php else : ?>
<span class="us-family-child-name"><?php echo esc_html($child['name']); ?></span>
<?php if ($child['birth_year'] !== '') : ?>
<span class="us-family-child-birth-year"><?php echo esc_html($child['birth_year']); ?></span>
<?php endif; ?>
<span class="us-family-child-actions">
<a href="<?php echo esc_url(add_query_arg('us_edit_child', $child['id'], (string) get_permalink())); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
<form method="post" action="" class="us-family-remove">
<?php wp_nonce_field('us_family'); ?>
<input type="hidden" name="us_family_action" value="remove">
<input type="hidden" name="child_id" value="<?php echo esc_attr((string) $child['id']); ?>">
<button type="submit"><?php esc_html_e('Remove', 'unsupervised-schedular'); ?></button>
</form>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post" action="" class="us-family-add">
<?php wp_nonce_field('us_family'); ?>
<input type="hidden" name="us_family_action" value="add">
<h4><?php esc_html_e('Add a student', 'unsupervised-schedular'); ?></h4>
<p>
<label for="us-child-name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="child_name" id="us-child-name" required>
</p>
<p>
<label for="us-child-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="child_birth_year" id="us-child-birth-year" required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p>
<p>
<label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label>
<input type="text" name="child_relationship" id="us-child-relationship" placeholder="<?php esc_attr_e('Parent', 'unsupervised-schedular'); ?>">
</p>
<?php if (! empty($questions)) : ?>
<fieldset class="us-reg-questions">
<legend><?php esc_html_e('About this student', 'unsupervised-schedular'); ?></legend>
<?php foreach ($questions as $question) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-family-q-' . (int) $question->id);
?>
<?php endforeach; ?>
</fieldset>
<?php endif; ?>
<p>
<button type="submit"><?php esc_html_e('Add student', 'unsupervised-schedular'); ?></button>
</p>
</form>
</div>
+8 -4
View File
@@ -6,13 +6,17 @@ if (! defined('ABSPATH')) {
}
/** @var int $offeringId Offering id when the page is restricted to a single class; 0 for the full catalog. */
/** @var list<array{id: int, name: string, is_self: bool}> $students Who this account may enrol — children first, the account holder last. */
// The enrolment script reads the list as JSON, so the picker can be built inside
// the enrolment form it renders.
$studentsJson = wp_json_encode(array_values($students));
?>
<div id="us-group-app"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
<div id="us-group-app" data-students="<?php echo esc_attr(is_string($studentsJson) ? $studentsJson : '[]'); ?>"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
<?php /* Above the list, for the same reason as the booking page. */ ?>
<div id="us-group-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
<div id="us-group-list">
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
</div>
<div id="us-group-confirmation" style="display:none;">
<p><?php esc_html_e('You are enrolled. The studio will be in touch.', 'unsupervised-schedular'); ?></p>
</div>
<div id="us-group-error" style="display:none;" role="alert"></div>
</div>
+68 -35
View File
@@ -1,7 +1,9 @@
<?php
declare(strict_types=1);
use Unsupervised\Schedular\Auth\PasswordPolicy;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionField;
if (! defined('ABSPATH')) {
exit;
@@ -22,39 +24,6 @@ if (! defined('ABSPATH')) {
* @var list<Question> $accountQuestions Studio-wide questions answered as step two.
*/
/**
* Render one account-signup question's input, named `us_answers[<id>]`.
*/
$renderQuestionField = static function (Question $question): void {
$id = (int) $question->id;
$name = 'us_answers[' . $id . ']';
$fieldId = 'us-reg-q-' . $id;
$required = $question->isRequired ? ' required' : '';
?>
<p>
<label for="<?php echo esc_attr($fieldId); ?>">
<?php echo esc_html($question->label); ?>
<?php if ($question->isRequired) : ?>
<span class="us-required" aria-hidden="true">*</span>
<?php endif; ?>
</label>
<?php if ($question->fieldType === Question::FIELD_TEXTAREA) : ?>
<textarea name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>" rows="4"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>></textarea>
<?php elseif ($question->fieldType === Question::FIELD_SELECT) : ?>
<select name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<option value=""><?php esc_html_e('— Select —', 'unsupervised-schedular'); ?></option>
<?php foreach ((array) $question->options as $option) : ?>
<option value="<?php echo esc_attr((string) $option); ?>"><?php echo esc_html((string) $option); ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($question->fieldType === Question::FIELD_CHECKBOX) : ?>
<input type="checkbox" name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>" value="1"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<?php else : ?>
<input type="text" name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<?php endif; ?>
</p>
<?php
};
?>
<div class="us-register-form">
<?php if ($successType === 'confirm') : ?>
@@ -99,9 +68,59 @@ $renderQuestionField = static function (Question $question): void {
</p>
<p>
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required>
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="<?php echo esc_attr((string) PasswordPolicy::MIN_LENGTH); ?>" required aria-describedby="us-reg-pass-strength">
<?php
/*
* Filled in by register.js. `aria-live` announces the verdict as
* it changes, and it starts empty so nothing is announced or
* takes up space before anything has been typed.
*/
?>
<span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span>
</p>
<fieldset class="us-guardian">
<legend><?php esc_html_e('Who are you registering?', 'unsupervised-schedular'); ?></legend>
<p>
<label>
<input type="checkbox" name="us_is_guardian" id="us-is-guardian" value="1">
<?php esc_html_e("I'm registering as a parent or guardian, for one or more students", 'unsupervised-schedular'); ?>
</label>
</p>
<?php /* Revealed by the checkbox; without JS it is simply always visible. */ ?>
<div class="us-children" id="us-children">
<p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p>
<?php /* The first block is the template the "Add another student" button clones. */ ?>
<div class="us-child" data-child-index="0">
<p>
<label for="us-child-0-name"><?php esc_html_e("Student's name", 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="children[0][name]" id="us-child-0-name" aria-required="true" data-us-child-required>
</p>
<p>
<label for="us-child-0-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="children[0][birth_year]" id="us-child-0-birth-year" aria-required="true" data-us-child-required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p>
<?php foreach ($accountQuestions as $question) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render(
$question,
'children[0][answers][' . (int) $question->id . ']',
'us-child-0-q-' . (int) $question->id,
enforceRequired: false
);
?>
<?php endforeach; ?>
</div>
<p>
<button type="button" class="us-add-child"><?php esc_html_e('Add another student', 'unsupervised-schedular'); ?></button>
</p>
</div>
</fieldset>
<?php if (! empty($policyForms)) : ?>
<fieldset class="us-policies">
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
@@ -124,6 +143,17 @@ $renderQuestionField = static function (Question $question): void {
<?php if ($hasQuestions) : ?>
<p>
<button type="button" class="us-reg-next"><?php esc_html_e('Next', 'unsupervised-schedular'); ?></button>
<?php
/*
* In parent/guardian mode the questions are asked per child,
* up in the children section, so step two has nothing left to
* ask and "Next" leads nowhere. This submit takes its place
* hidden until the guardian box is ticked (and never shown at
* all without JS, where both steps are visible anyway and the
* step-two submit does the job).
*/
?>
<input type="submit" name="us_register" class="us-reg-submit-early" hidden value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
<?php else : ?>
<p>
@@ -137,7 +167,10 @@ $renderQuestionField = static function (Question $question): void {
<fieldset class="us-reg-questions">
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
<?php foreach ($accountQuestions as $question) : ?>
<?php $renderQuestionField($question); ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-reg-q-' . (int) $question->id);
?>
<?php endforeach; ?>
</fieldset>
<p>
+14
View File
@@ -0,0 +1,14 @@
# Writing tests
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to stub WordPress functions without a full WP installation, and Mockery to mock `$wpdb` and other dependencies.
All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()` / `Monkey\tearDown()` and stubs all WP translation/escape functions automatically.
**Brain\Monkey API notes:**
- `Functions\when('fn')->alias(fn() => ...)` — stub with a closure (NOT `returnUsing()`)
- `Functions\when('fn')->justReturn($val)` — stub returning a fixed value
- `Functions\expect('fn')->once()->with(...)` — assert call count and arguments
- Use `Functions\when()` (not `Functions\expect()`) when you need argument-routing (e.g. `get_role` returning different values per argument) to avoid chaining ambiguity
- Mockery matchers (e.g. `\Mockery::type()`) inside plain PHP arrays do not work with `with()` — use `\Mockery::on(fn($arr) => ...)` or `\Mockery::any()` instead
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class AccountPageTest extends TestCase
{
private AccountPage $page;
protected function setUp(): void
{
parent::setUp();
$this->page = new AccountPage();
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('get_permalink')->alias(
static fn (int $id = 0): string => $id > 0
? 'https://studio.test/sign-in/'
: 'https://studio.test/current/'
);
Functions\when('wp_logout_url')->alias(
static fn (string $redirect): string => 'https://studio.test/wp-login.php?action=logout&redirect_to=' . rawurlencode($redirect)
);
Functions\when('wp_get_current_user')->justReturn($this->user('Grace', 'Hopper', '[email protected]'));
}
private function user(string $first, string $last, string $email): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->ID = 5;
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = '';
$user->user_email = $email;
return $user;
}
public function testShowsTheSignedInNameAndEmail(): void
{
$html = $this->page->render([]);
self::assertStringContainsString('Grace Hopper', $html);
self::assertStringContainsString('[email protected]', $html);
self::assertStringContainsString('Sign out', $html);
}
public function testSigningOutReturnsToTheConfiguredLoginPage(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/sign-in/'),
$this->page->render(['loginPageId' => 9])
);
}
/**
* With no page chosen, signing out from a header link should leave the
* visitor where they were rather than navigating them somewhere.
*/
public function testSigningOutReturnsToTheCurrentPageWhenNoLoginPageIsSet(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/current/'),
$this->page->render([])
);
}
public function testTheShortcodeAttributeNameIsAccepted(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/sign-in/'),
$this->page->render(['login_page_id' => 9])
);
}
/**
* A block whose whole job is "you are signed in as X" has nothing to say to
* a stranger, and a bare notice in a site header cannot be acted on.
*/
public function testRendersNothingForASignedOutVisitorWithNoLoginPage(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
self::assertSame('', $this->page->render([]));
}
public function testOffersASignInLinkToASignedOutVisitorWhenAPageIsChosen(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
$html = $this->page->render(['loginPageId' => 9]);
self::assertStringContainsString('https://studio.test/sign-in/', $html);
self::assertStringContainsString('Sign in', $html);
self::assertStringNotContainsString('Sign out', $html);
}
/**
* A page can be deleted after it has been chosen in the block, which
* get_permalink() reports as false.
*/
public function testTreatsADeletedLoginPageAsNoneChosen(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('get_permalink')->justReturn(false);
self::assertSame('', $this->page->render(['loginPageId' => 9]));
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Unsupervised\Schedular\Auth\PasswordPolicy;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class PasswordPolicyTest extends TestCase
{
public function testAcceptsAnOrdinaryMemorablePassword(): void
{
self::assertNull(PasswordPolicy::validate('thistle-marrow-42', '[email protected]', 'Grace Hopper'));
}
/**
* A leading or trailing space is a character like any other. Trimming it
* would accept a password the user could then never type back.
*/
public function testCountsSurroundingSpaceAsPartOfThePassword(): void
{
self::assertNull(PasswordPolicy::validate(' spaced-out-phrase '));
// Seven characters counting both spaces: one short, and still one short
// after the spaces are counted rather than stripped.
self::assertNotNull(PasswordPolicy::validate(' short '));
}
/**
* @dataProvider tooShort
*/
public function testRejectsAPasswordShorterThanTheMinimum(string $password): void
{
self::assertStringContainsString('at least', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function tooShort(): array
{
return [
'empty' => [''],
'one short' => ['sevench'],
'a few chars' => ['abc'],
];
}
/**
* @dataProvider commonPasswords
*/
public function testRejectsAWellKnownPassword(string $password): void
{
self::assertStringContainsString('commonly used', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function commonPasswords(): array
{
return [
'password123' => ['password123'],
'shouting' => ['PASSWORD123'],
'mixed case' => ['PassWord123'],
'a keyboard walk' => ['qwertyuiop'],
'digits in a row' => ['123456789'],
'the classic' => ['iloveyou'],
];
}
/**
* @dataProvider tooFewDistinctCharacters
*/
public function testRejectsAPasswordBuiltFromAlmostNoDistinctCharacters(string $password): void
{
self::assertStringContainsString('repeated characters', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function tooFewDistinctCharacters(): array
{
return [
'one character' => ['aaaaaaaaaa'],
'two alternating' => ['abababababab'],
'three' => ['abcabcabcabc'],
];
}
/**
* @dataProvider identityEchoes
*/
public function testRejectsAPasswordContainingTheUsersOwnDetails(string $password, string $email, string $name): void
{
self::assertStringContainsString('name or email', (string) PasswordPolicy::validate($password, $email, $name));
}
/** @return array<string, array{string, string, string}> */
public static function identityEchoes(): array
{
return [
'the whole email' => ['[email protected]!', '[email protected]', 'Grace'],
'the local part' => ['grace-hopper-1906', '[email protected]', ''],
'the display name' => ['xxhopperxx-2019', '[email protected]', 'Hopper'],
'differing in case' => ['MyGRACEpassword', '[email protected]', ''],
];
}
/**
* A two- or three-letter overlap with a name is coincidence, not a weakness,
* and refusing it would be baffling to the person typing.
*/
public function testShortIdentityFragmentsDoNotTripTheCheck(): void
{
self::assertNull(PasswordPolicy::validate('bramble-thicket', '[email protected]', 'Bo'));
}
public function testAnEmptyIdentityIsNotTreatedAsContainedInEverything(): void
{
self::assertNull(PasswordPolicy::validate('bramble-thicket', '', ''));
}
}
+323 -9
View File
@@ -10,9 +10,11 @@ use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersion;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
@@ -35,10 +37,18 @@ class RegistrationPageTest extends TestCase
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
// Reached on every submit now that the email is validated before the
// password, so the password can be checked against it.
Functions\when('is_email')->alias(static fn (string $v): bool => (bool) preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $v));
Functions\when('absint')->alias(static fn ($v) => (int) $v);
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
// The birth-year check reads current_time('Y'), so answer that format
// properly rather than leaving it to cast out of the datetime string.
Functions\when('current_time')->alias(
static fn (string $type = 'mysql'): string => 'Y' === $type ? '2024' : '2024-01-01 00:00:00'
);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_enqueue_script')->justReturn(null);
Functions\when('wp_localize_script')->justReturn(true);
$invites = Mockery::mock(InviteRepository::class);
$policies = Mockery::mock(PolicyRepository::class);
@@ -63,6 +73,7 @@ class RegistrationPageTest extends TestCase
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
$this->ctx['guardians'] = Mockery::mock(GuardianService::class);
$this->ctx['page'] = new RegistrationPage(
$invites,
@@ -74,6 +85,7 @@ class RegistrationPageTest extends TestCase
$questions,
$answers,
$access,
$this->ctx['guardians'],
);
$_POST = [];
@@ -106,7 +118,7 @@ class RegistrationPageTest extends TestCase
public function testInviteBranchCreatesAndLogsInTheStudent(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
@@ -123,7 +135,7 @@ class RegistrationPageTest extends TestCase
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
@@ -143,7 +155,7 @@ class RegistrationPageTest extends TestCase
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
@@ -170,7 +182,7 @@ class RegistrationPageTest extends TestCase
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
@@ -338,7 +350,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
@@ -357,7 +369,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
@@ -377,7 +389,7 @@ class RegistrationPageTest extends TestCase
public function testRecordsAccountAnswersOnSuccess(): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Ada',
'us_answers' => [ '5' => 'By a friend' ],
];
@@ -411,7 +423,7 @@ class RegistrationPageTest extends TestCase
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
{
$_POST = [ 'us_register' => '1', 'password' => 'password123', 'display_name' => 'Ada' ];
$_POST = [ 'us_register' => '1', 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
$_REQUEST = [ 'us_invite' => 'raw-token' ];
Functions\when('is_user_logged_in')->justReturn(false);
@@ -444,6 +456,7 @@ class RegistrationPageTest extends TestCase
$this->ctx['questions'],
$this->ctx['answers'],
$this->ctx['access'],
$this->ctx['guardians'],
]
)->makePartial()->shouldAllowMockingProtectedMethods();
@@ -600,4 +613,305 @@ class RegistrationPageTest extends TestCase
self::assertStringContainsString('Ask the front desk for a link.', $html);
self::assertStringNotContainsString('by invitation only', $html);
}
/**
* A guardian's signup creates one login-less child per filled block, links
* them, and records each child's answers against the child rather than the
* account holder the questions describe the student, not the parent.
*/
public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']],
// An untouched spare block is dropped, not rejected.
['name' => ' ', 'birth_year' => '', 'answers' => []],
],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
]);
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')->andReturn(102);
$recorded = [];
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
static function (Answer $a) use (&$recorded): int {
$recorded[] = [$a->studentId, $a->answerValue];
return 1;
}
);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\expect('wp_set_current_user')->once()->with(42);
Functions\expect('wp_set_auth_cookie')->once()->with(42);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded);
}
/**
* The browser gates on zxcvbn, but that is advice a client can decline to
* take. Nothing is created for a password the server refuses.
*
* @dataProvider refusedPasswords
*/
public function testSignupRefusesAPasswordThePolicyRejects(string $password, string $expected): void
{
$_POST = [
'email' => '[email protected]',
'password' => $password,
'display_name' => 'Grace Hopper',
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
self::assertStringContainsString(
$expected,
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/** @return array<string, array{string, string}> */
public static function refusedPasswords(): array
{
return [
'too short' => ['abc123', 'at least'],
'a known password' => ['password123', 'commonly used'],
'barely any variety' => ['ababababab', 'repeated characters'],
'their own name' => ['grace-hopper-1906', 'name or email'],
];
}
public function testSignupRefusesAnAddressThatIsNotAnEmail(): void
{
$_POST = [
'email' => 'not-an-email',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
self::assertStringContainsString(
'valid email address',
$this->submit(null, true)
);
}
public function testGuardianSignupWithNoChildrenIsRejected(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
self::assertStringContainsString('at least one student', $result);
}
/**
* A block the guardian actually typed into is theirs to correct, not ours to
* discard only a wholly untouched spare is dropped. Losing the birth year
* they filled in and registering a nameless student would be worse than
* telling them what is missing.
*/
public function testGuardianSignupRejectsAHalfFilledChildRatherThanDroppingIt(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => '', 'birth_year' => '2017', 'answers' => []],
],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString(
'give each student a name',
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/**
* @dataProvider rejectedBirthYears
*/
public function testGuardianSignupRejectsAChildWithoutAUsableBirthYear(string $submitted): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString(
'birth year',
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/** @return array<string, array{string}> */
public static function rejectedBirthYears(): array
{
return [
'left blank' => [''],
'a full date' => ['2015-04-02'],
'in the future' => ['2027'],
];
}
/**
* Required per-child answers are validated before any user exists, so a
* missing one never leaves a half-registered family behind.
*/
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => ' ']],
],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$this->ctx['guardians']->shouldNotReceive('createChild');
self::assertStringContainsString('for each student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* A family that half-created would leave the guardian unable to re-register
* and their children unconfirmed, so the whole signup is undone.
*/
public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => []],
],
];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')
->andReturn(new \WP_Error('link_failed', 'Nope.'));
$deleted = [];
$this->ctx['guardians']->shouldReceive('deleteUser')->andReturnUsing(
static function (int $id) use (&$deleted): void {
$deleted[] = $id;
}
);
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
self::assertStringContainsString('Could not create the account', $result);
self::assertSame([101, 42], $deleted);
}
/**
* The child is who the policy binds; the guardian is who agreed. Both are
* recorded, which is what makes the acceptance legally meaningful.
*/
public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'accept' => [3],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
];
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
$this->ctx['policies']->shouldReceive('findForScope')->andReturn([
new Policy(title: 'Studio Terms', slug: 'terms', currentVersionId: 3, id: 1),
]);
$this->ctx['versions']->shouldReceive('findById')->with(3)->andReturn($version);
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$recorded = [];
$this->ctx['acceptances']->shouldReceive('insert')->andReturnUsing(
static function (PolicyAcceptance $a) use (&$recorded): int {
$recorded[] = [$a->studentId, $a->acceptedBy];
return 1;
}
);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\expect('wp_set_current_user')->once();
Functions\expect('wp_set_auth_cookie')->once();
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
// The guardian agreed for themselves as an account holder, and for the child.
self::assertSame([[42, 42], [101, 42]], $recorded);
}
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
{
$_POST = ['password' => 'thistle-marrow-42', 'display_name' => 'Ada'];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
$this->ctx['guardians']->shouldNotReceive('createChild');
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\expect('wp_set_current_user')->once();
Functions\expect('wp_set_auth_cookie')->once();
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
}
+5 -5
View File
@@ -58,7 +58,7 @@ class StudentHistoryTest extends TestCase
public function testPolicyAcceptancesResolvePolicyTitleAndVersion(): void
{
$this->acceptances->shouldReceive('findByStudent')->once()->with(5)->andReturn([
new PolicyAcceptance(9, 5, PolicyAcceptance::REG_ACCOUNT, 5, null, '2026-06-02 09:00:00', 1),
new PolicyAcceptance(9, 5, PolicyAcceptance::REG_ACCOUNT, 5, ipAddress: null, acceptedAt: '2026-06-02 09:00:00', id: 1),
]);
$this->policyVersions->shouldReceive('findById')->with(9)
->andReturn(new PolicyVersion(2, 3, null, PolicyVersion::STATUS_PUBLISHED, id: 9));
@@ -177,9 +177,9 @@ class StudentHistoryTest extends TestCase
Payment::REG_LESSON,
12,
100.00,
'CAD',
Payment::METHOD_CARD,
Payment::STATUS_PAID,
currency: 'CAD',
method: Payment::METHOD_CARD,
status: Payment::STATUS_PAID,
taxRate: 13.0,
taxAmount: 13.00,
receiptNumber: 'USC-7',
@@ -231,7 +231,7 @@ class StudentHistoryTest extends TestCase
public function testCreditsBuildDisplayRows(): void
{
$this->credits->shouldReceive('findByStudent')->once()->with(5)->andReturn([
new Credit(5, 33.00, 13.00, 'CAD', 12, 77, 'Credit for cancelled lesson #77', Credit::STATUS_AVAILABLE, '2026-07-01 09:00:00', id: 300),
new Credit(5, 33.00, 13.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, reason: 'Credit for cancelled lesson #77', status: Credit::STATUS_AVAILABLE, createdAt: '2026-07-01 09:00:00', id: 300),
]);
$rows = $this->history->credits(5);
+13 -1
View File
@@ -6,11 +6,13 @@ namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\BlockRegistrar;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Guardian\FamilyPage;
/**
* Test double exposing editor-preview mode as a switch (the real detection
@@ -41,6 +43,8 @@ class BlockRegistrarTest extends TestCase
private LoginPage&Mockery\MockInterface $loginPage;
private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage;
private FamilyPage&Mockery\MockInterface $familyPage;
private AccountPage&Mockery\MockInterface $accountPage;
private TestableBlockRegistrar $registrar;
protected function setUp(): void
@@ -51,6 +55,8 @@ class BlockRegistrarTest extends TestCase
$this->loginPage = Mockery::mock(LoginPage::class);
$this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
$this->familyPage = Mockery::mock(FamilyPage::class);
$this->accountPage = Mockery::mock(AccountPage::class);
// Most requests are not a just-finished registration; the tests that
// exercise that path override this.
@@ -63,6 +69,8 @@ class BlockRegistrarTest extends TestCase
$this->loginPage,
$this->registrationPage,
$this->groupClassPage,
$this->familyPage,
$this->accountPage,
);
}
@@ -74,7 +82,7 @@ class BlockRegistrarTest extends TestCase
$this->registrar->register();
}
public function testRegisterBlocksRegistersAllFourBlocksWithAssets(): void
public function testRegisterBlocksRegistersAllBlocksWithAssets(): void
{
Functions\expect('wp_register_script')
->once()
@@ -111,6 +119,8 @@ class BlockRegistrarTest extends TestCase
'us-scheduler/student-login',
'us-scheduler/student-register',
'us-scheduler/group-classes',
'us-scheduler/family',
'us-scheduler/account',
],
array_keys($registered)
);
@@ -207,6 +217,8 @@ class BlockRegistrarTest extends TestCase
$this->loginPage,
$this->registrationPage,
$this->groupClassPage,
$this->familyPage,
$this->accountPage,
);
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
+147 -8
View File
@@ -11,16 +11,19 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BookingEndpointTest extends TestCase
{
private GuardianService&Mockery\MockInterface $guardians;
private AvailabilityRepository $availability;
private BookingRepository $bookings;
private OfferingRepository $offerings;
@@ -52,6 +55,14 @@ class BookingEndpointTest extends TestCase
// cancellation paths simply allow the call.
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
$this->guardians = Mockery::mock(GuardianService::class);
// The default account books only for itself: no guardian link anywhere.
$this->guardians->shouldReceive('canActFor')
->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault();
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
$this->guardians->shouldReceive('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault();
$this->guardians->shouldReceive('studentName')->andReturn('Ada')->byDefault();
$this->endpoint = new BookingEndpoint(
$this->availability,
$this->bookings,
@@ -59,6 +70,7 @@ class BookingEndpointTest extends TestCase
$this->gate,
$this->payments,
new CancellationPolicy($this->settings),
$this->guardians,
);
}
@@ -182,7 +194,7 @@ class BookingEndpointTest extends TestCase
$this->gate->shouldReceive('record')->once();
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null)
->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(
studentId: 5,
instructorId: 3,
@@ -260,7 +272,7 @@ class BookingEndpointTest extends TestCase
$this->gate->shouldReceive('record')->once();
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null)
->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(
studentId: 5,
instructorId: 3,
@@ -336,8 +348,8 @@ class BookingEndpointTest extends TestCase
// Three claimed occurrences at a per-lesson (one_time) price of 50 → 150.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 150.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 150.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']);
@@ -375,8 +387,8 @@ class BookingEndpointTest extends TestCase
// A full_term price already covers the whole reservation.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 400.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 400.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']);
@@ -427,8 +439,8 @@ class BookingEndpointTest extends TestCase
// Charged now, for a single lesson's fee, as a normal (non-scheduled) payment.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
$this->bookings->shouldNotReceive('updateStatus');
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
@@ -657,4 +669,131 @@ class BookingEndpointTest extends TestCase
self::assertSame('Piano Lesson', $data[0]['offering_title']);
self::assertSame(60, $data[0]['duration_minutes']);
}
/**
* The authorisation boundary of guardian booking: without it any signed-in
* student could book and bill against any user id they cared to send.
*/
public function testBookForAStudentTheCallerDoesNotGuardIsForbidden(): void
{
$this->guardians->shouldReceive('canActFor')->with(5, 99)->andReturn(false);
// Rejected before anything is looked up, claimed, or charged.
$this->availability->shouldNotReceive('findById');
$this->availability->shouldNotReceive('claim');
$this->bookings->shouldNotReceive('insert');
$this->payments->shouldNotReceive('createForRegistration');
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 99]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('forbidden', $result->get_error_code());
}
public function testGuardianBooksTheLessonInTheChildsNameAndBillsThemselves(): void
{
$this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
$this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
// The lesson belongs to the child…
$this->bookings->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Lesson $l): bool => $l->studentId === 42))
->andReturn(77);
// …the acceptance names the child but is attributed to the guardian…
$this->gate->shouldReceive('record')
->once()
->with(PolicyAcceptance::REG_LESSON, 77, 42, 8, Mockery::any(), Mockery::any(), Mockery::any(), 5);
// …and the charge is raised against the child but owed by the guardian.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 42, 3, 50.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(42, 3, Payment::REG_LESSON, 77, 50.0, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 42]);
$result = $this->endpoint->book($request);
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(201, $result->get_status());
}
/**
* Sending your own id explicitly is the same as sending none no guardian
* lookup is needed to book for yourself.
*/
public function testBookForYourOwnIdNeedsNoGuardianLink(): void
{
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 0.0, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Lesson $l): bool => $l->studentId === 5))
->andReturn(77);
$this->gate->shouldReceive('record')->once();
$this->bookings->shouldReceive('updateStatus')->once()->andReturn(true);
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 5]);
self::assertInstanceOf(\WP_REST_Response::class, $this->endpoint->book($request));
}
public function testGuardianMayCancelTheirChildsLesson(): void
{
$this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
$lesson = new Lesson(slotId: 10, studentId: 42, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->bookings->shouldReceive('updateStatus')->once()->with(77, Lesson::STATUS_CANCELLED)->andReturn(true);
$this->availability->shouldReceive('release')->once()->with(10)->andReturn(true);
$this->payments->shouldReceive('voidPending')->once();
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
}
public function testMyLessonsCoversTheWholeHouseholdSortedByStart(): void
{
Functions\when('current_user_can')->justReturn(false);
$this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5, 42]);
$this->guardians->shouldReceive('studentName')->with(5)->andReturn('Grace');
$this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada');
$mine = new Lesson(slotId: 11, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 78);
$childs = new Lesson(slotId: 10, studentId: 42, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 77);
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([$mine]);
$this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->andReturn([$childs]);
// Slot 10 starts first, so the child's lesson leads the merged list.
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->availability->shouldReceive('findById')->with(11)->andReturn(new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-02 10:00:00',
endDt: '2026-07-02 11:00:00',
durationMinutes: 60,
id: 11,
));
$data = $this->endpoint->myLessons(new \WP_REST_Request([]))->get_data();
self::assertSame([77, 78], array_column($data, 'id'));
self::assertSame(['Ada', 'Grace'], array_column($data, 'student_name'));
}
}
+41 -1
View File
@@ -4,17 +4,26 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BookingPageTest extends TestCase
{
private BookingPage $page;
private GuardianService&Mockery\MockInterface $guardians;
protected function setUp(): void
{
parent::setUp();
$this->page = new BookingPage();
$this->guardians = Mockery::mock(GuardianService::class);
// Most cases are a single-student account: one bookable person, no picker.
$this->guardians->shouldReceive('bookableStudents')->andReturn(
[['id' => 3, 'name' => 'Ada', 'is_self' => true]]
)->byDefault();
$this->page = new BookingPage($this->guardians);
}
/**
@@ -171,4 +180,35 @@ class BookingPageTest extends TestCase
self::assertSame('https://example.com/wp-login.php', $this->page->loginUrl(5));
}
/**
* A single-student account gets a one-entry list, which the script renders
* as no picker at all.
*/
public function testStudentListIsEmbeddedForTheScript(): void
{
$html = $this->renderForStudent([]);
self::assertStringContainsString('data-students=', $html);
self::assertStringContainsString('&quot;is_self&quot;:true', $html);
}
/**
* Children lead the embedded list, so the picker's default selection is a
* child rather than the parent.
*/
public function testGuardianListLeadsWithChildren(): void
{
$this->guardians->shouldReceive('bookableStudents')->with(3)->andReturn([
['id' => 42, 'name' => 'Ada', 'is_self' => false],
['id' => 3, 'name' => 'Grace', 'is_self' => true],
]);
$html = $this->renderForStudent([]);
$students = json_decode(html_entity_decode((string) preg_replace('/.*data-students="([^"]*)".*/s', '$1', $html)), true);
self::assertSame([42, 3], array_column((array) $students, 'id'));
self::assertFalse($students[0]['is_self']);
}
}
@@ -9,15 +9,18 @@ use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class EnrollmentEndpointTest extends TestCase
{
private GuardianService&Mockery\MockInterface $guardians;
private EnrollmentRepository $enrollments;
private OfferingRepository $offerings;
private RegistrationGate $gate;
@@ -41,12 +44,19 @@ class EnrollmentEndpointTest extends TestCase
$this->payments = Mockery::mock(PaymentService::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
$this->guardians = Mockery::mock(GuardianService::class);
$this->guardians->shouldReceive('canActFor')
->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault();
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
$this->guardians->shouldReceive('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault();
$this->endpoint = new EnrollmentEndpoint(
$this->enrollments,
$this->offerings,
$this->gate,
$this->payments,
$this->access,
$this->guardians,
);
}
@@ -89,7 +99,7 @@ class EnrollmentEndpointTest extends TestCase
$this->expectSuccessfulEnrollment();
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null)
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(
studentId: 5,
instructorId: 3,
@@ -252,4 +262,82 @@ class EnrollmentEndpointTest extends TestCase
self::assertSame(200, $result->get_status());
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']);
}
/**
* The same boundary as booking: a student id the caller may not act for is
* a 403, never a silent fallback that enrols the wrong person.
*/
public function testEnrolForAStudentTheCallerDoesNotGuardIsForbidden(): void
{
$this->guardians->shouldReceive('canActFor')->with(5, 99)->andReturn(false);
$this->offerings->shouldNotReceive('findById');
$this->enrollments->shouldNotReceive('insert');
$this->payments->shouldNotReceive('createForRegistration');
$request = new \WP_REST_Request(['offering_id' => 8, 'student_id' => 99]);
$result = $this->endpoint->enroll($request);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('forbidden', $result->get_error_code());
}
public function testGuardianEnrolsTheChildAndIsBilledForIt(): void
{
$this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
$this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 42)->andReturn(false);
$this->enrollments->shouldReceive('countActiveForOffering')->andReturn(0);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->enrollments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Enrollment $e): bool => $e->studentId === 42))
->andReturn(44);
$this->gate->shouldReceive('record')
->once()
->with(PolicyAcceptance::REG_ENROLLMENT, 44, 42, 8, Mockery::any(), Mockery::any(), Mockery::any(), 5);
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 42, 3, 120.0, 'CAD', null, null, null, 5)
->andReturn(new Payment(42, 3, Payment::REG_ENROLLMENT, 44, 120.0, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12));
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8, 'student_id' => 42]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(201, $result->get_status());
}
public function testGuardianMayWithdrawTheirChild(): void
{
$this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true);
$enrollment = new Enrollment(offeringId: 8, studentId: 42, instructorId: 3, status: Enrollment::STATUS_ACTIVE, id: 44);
$this->enrollments->shouldReceive('findById')->with(44)->andReturn($enrollment);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0));
$this->enrollments->shouldReceive('updateStatus')->once()->with(44, Enrollment::STATUS_CANCELLED)->andReturn(true);
$this->payments->shouldReceive('voidPending')->once();
self::assertInstanceOf(\WP_REST_Response::class, $this->endpoint->withdraw(new \WP_REST_Request(['id' => 44])));
}
public function testIndexCoversTheWholeHouseholdForAGuardian(): void
{
Functions\when('current_user_can')->justReturn(false);
$this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5, 42]);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([
new Enrollment(offeringId: 8, studentId: 42, instructorId: 3, id: 44),
]);
$data = $this->endpoint->index(new \WP_REST_Request([]))->get_data();
self::assertCount(1, $data);
self::assertSame(42, $data[0]['student_id']);
}
}
+10 -1
View File
@@ -4,20 +4,29 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupClassPageTest extends TestCase
{
private GroupClassPage $page;
private GuardianService&Mockery\MockInterface $guardians;
protected function setUp(): void
{
parent::setUp();
$this->page = new GroupClassPage();
$this->guardians = Mockery::mock(GuardianService::class);
$this->guardians->shouldReceive('bookableStudents')->andReturn(
[['id' => 3, 'name' => 'Ada', 'is_self' => true]]
)->byDefault();
$this->page = new GroupClassPage($this->guardians);
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_enqueue_script')->justReturn(null);
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Guardian;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Guardian\ChildLoginGate;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class ChildLoginGateTest extends TestCase
{
private ChildLoginGate $gate;
protected function setUp(): void
{
parent::setUp();
$this->gate = new ChildLoginGate();
}
/**
* @param array<int, string> $children User IDs flagged as child accounts.
*/
private function stubChildren(array $children): void
{
Functions\when('get_user_meta')->alias(
static fn (int $userId, string $key, bool $single = false): string => in_array($userId, $children, true) ? '1' : ''
);
}
private function user(int $id): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->ID = $id;
return $user;
}
public function testRegisterHooksBothFilters(): void
{
$this->gate->register();
self::assertNotFalse(Filters\has('wp_authenticate_user', [$this->gate, 'blockChildLogin']));
self::assertNotFalse(Filters\has('user_has_cap', [$this->gate, 'withholdBooking']));
}
public function testChildAccountCannotAuthenticate(): void
{
$this->stubChildren([42]);
$result = $this->gate->blockChildLogin($this->user(42));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('us_child_account', $result->get_error_code());
}
public function testOrdinaryStudentPassesThrough(): void
{
$this->stubChildren([42]);
$user = $this->user(9);
self::assertSame($user, $this->gate->blockChildLogin($user));
}
public function testAnEarlierAuthenticationErrorIsPassedThroughUntouched(): void
{
$this->stubChildren([42]);
$error = new \WP_Error('bad_password', 'Nope.');
self::assertSame($error, $this->gate->blockChildLogin($error));
}
public function testBookingCapabilityIsWithheldFromAChild(): void
{
$this->stubChildren([42]);
$caps = $this->gate->withholdBooking(
['read' => true, RoleManager::CAP_BOOK_LESSON => true],
[],
[],
$this->user(42)
);
self::assertArrayNotHasKey(RoleManager::CAP_BOOK_LESSON, $caps);
self::assertTrue($caps['read']);
}
public function testBookingCapabilityIsLeftAloneForAnOrdinaryStudent(): void
{
$this->stubChildren([42]);
$caps = $this->gate->withholdBooking(
[RoleManager::CAP_BOOK_LESSON => true],
[],
[],
$this->user(9)
);
self::assertTrue($caps[RoleManager::CAP_BOOK_LESSON]);
}
public function testNonUserSubjectIsIgnored(): void
{
$caps = [RoleManager::CAP_BOOK_LESSON => true];
self::assertSame($caps, $this->gate->withholdBooking($caps, [], [], null));
}
public function testGuardianServiceIsTheSingleSourceOfTheChildFlag(): void
{
$this->stubChildren([42]);
self::assertTrue(GuardianService::isChild(42));
self::assertFalse(GuardianService::isChild(9));
}
}
+278
View File
@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Guardian;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Guardian\FamilyPage;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class FamilyPageTest extends TestCase
{
private GuardianService&Mockery\MockInterface $guardians;
private QuestionRepository&Mockery\MockInterface $questions;
private AnswerRepository&Mockery\MockInterface $answers;
private FamilyPage $page;
protected function setUp(): void
{
parent::setUp();
$this->guardians = Mockery::mock(GuardianService::class);
$this->questions = Mockery::mock(QuestionRepository::class);
$this->answers = Mockery::mock(AnswerRepository::class);
$this->page = new FamilyPage($this->guardians, $this->questions, $this->answers);
$_POST = [];
$_GET = [];
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('get_permalink')->justReturn('https://studio.test/family/');
// The birth-year input caps itself at the current year.
Functions\when('current_time')->justReturn('2026');
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? ''));
Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v));
Functions\when('sanitize_textarea_field')->alias(static fn (string $v): string => trim($v));
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('add_query_arg')->alias(
static fn (string $key, $value, string $url): string => $url . '?' . $key . '=' . $value
);
}
protected function tearDown(): void
{
$_POST = [];
$_GET = [];
parent::tearDown();
}
/**
* A FamilyPage whose redirect is captured instead of exiting the process.
*
* @param-out string $captured
*/
private function capturingPage(?string &$captured): FamilyPage
{
$page = Mockery::mock(FamilyPage::class, [$this->guardians, $this->questions, $this->answers])
->makePartial()
->shouldAllowMockingProtectedMethods();
$page->shouldReceive('redirect')->andReturnUsing(static function (string $url) use (&$captured): void {
$captured = $url;
});
return $page;
}
private function question(int $id, bool $required): Question
{
return new Question(offeringId: null, label: 'Instrument', isRequired: $required, scope: Question::SCOPE_ACCOUNT, id: $id);
}
public function testLoggedOutVisitorIsOfferedALoginLink(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('wp_login_url')->justReturn('https://studio.test/wp-login.php');
$html = $this->page->render([]);
self::assertStringContainsString('log in to manage your profile', $html);
}
public function testRenderListsTheGuardiansChildren(): void
{
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]);
self::assertStringContainsString('Ada', $html);
self::assertStringContainsString('2015', $html);
self::assertStringContainsString('Add a student', $html);
}
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
{
$_POST = [
'us_family_action' => 'add',
'child_name' => 'Ada',
'child_birth_year' => '2015',
'child_relationship' => 'Parent',
'us_answers' => [7 => 'Piano'],
];
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
$this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015', 'Parent')->andReturn(42);
$this->answers->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Answer $a): bool =>
$a->questionId === 7
&& $a->studentId === 42
&& $a->registrationId === 42
&& $a->registrationType === Answer::REG_ACCOUNT
&& $a->answerValue === 'Piano'));
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=added', $captured);
}
/**
* Validating before creating is what stops a missing answer from leaving a
* half-added child behind.
*/
public function testAddRefusesAMissingRequiredAnswerBeforeCreatingTheChild(): void
{
$_POST = [
'us_family_action' => 'add',
'child_name' => 'Ada',
'us_answers' => [7 => ' '],
];
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
$this->guardians->shouldNotReceive('createChild');
$this->answers->shouldNotReceive('insert');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testAddSurfacesAServiceErrorInsteadOfRedirecting(): void
{
$_POST = ['us_family_action' => 'add', 'child_name' => ''];
$this->questions->shouldReceive('findByScope')->once()->andReturn([]);
$this->guardians->shouldReceive('createChild')->once()->andReturn(new \WP_Error('missing_name', 'Please give each student a name.'));
$this->answers->shouldNotReceive('insert');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testEditDelegatesToTheServiceAndRedirects(): void
{
$_POST = [
'us_family_action' => 'edit',
'child_id' => '42',
'child_name' => 'Ada L',
'child_birth_year' => '2015',
];
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015')->andReturn(null);
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=updated', $captured);
}
public function testRemoveDelegatesToTheServiceAndRedirects(): void
{
$_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
$this->guardians->shouldReceive('removeChild')->once()->with(5, 42)->andReturn(null);
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=removed', $captured);
}
public function testRemoveRefusalIsShownRatherThanRedirected(): void
{
$_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
$this->guardians->shouldReceive('removeChild')->once()->andReturn(
new \WP_Error('has_history', 'This student has lessons or enrolments on record.')
);
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testAnUnrecognisedActionDoesNothing(): void
{
$_POST = ['us_family_action' => 'destroy'];
$this->guardians->shouldNotReceive('createChild');
$this->guardians->shouldNotReceive('removeChild');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testNoActionIsANoOp(): void
{
$this->guardians->shouldNotReceive('createChild');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testLoggedOutSubmissionIsIgnored(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
$_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
$this->guardians->shouldNotReceive('removeChild');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testCompletedActionRendersItsConfirmation(): void
{
$_GET = ['us_family' => 'added'];
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
self::assertStringContainsString('Student added.', $this->page->render([]));
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Guardian;
use Unsupervised\Schedular\Guardian\GuardianLink;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GuardianLinkTest extends TestCase
{
public function testFromRowCoercesWpdbStrings(): void
{
$link = GuardianLink::fromRow((object) [
'id' => '7',
'guardian_id' => '5',
'student_id' => '42',
'relationship' => 'Parent',
'created_at' => '2026-07-29 09:00:00',
]);
self::assertSame(7, $link->id);
self::assertSame(5, $link->guardianId);
self::assertSame(42, $link->studentId);
self::assertSame('Parent', $link->relationship);
self::assertSame('2026-07-29 09:00:00', $link->createdAt);
}
public function testRelationshipDefaultsToEmptyWhenTheColumnIsNull(): void
{
$link = GuardianLink::fromRow((object) [
'id' => '7',
'guardian_id' => '5',
'student_id' => '42',
'relationship' => null,
'created_at' => null,
]);
self::assertSame('', $link->relationship);
self::assertNull($link->createdAt);
}
public function testToArrayExposesEveryColumn(): void
{
$link = new GuardianLink(guardianId: 5, studentId: 42, relationship: 'Parent', createdAt: '2026-07-29 09:00:00', id: 7);
self::assertSame(
[
'id' => 7,
'guardian_id' => 5,
'student_id' => 42,
'relationship' => 'Parent',
'created_at' => '2026-07-29 09:00:00',
],
$link->toArray()
);
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Guardian;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Guardian\GuardianLink;
use Unsupervised\Schedular\Guardian\GuardianRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GuardianRepositoryTest extends TestCase
{
private \wpdb $db;
private GuardianRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->db = Mockery::mock(\wpdb::class);
$this->db->prefix = 'wp_';
$this->repo = new GuardianRepository($this->db);
$this->db->shouldReceive('prepare')->andReturnUsing(
static fn (string $sql, ...$args): string => $sql . '|' . implode(',', $args)
)->byDefault();
}
public function testInsertStoresTheLinkAndReturnsId(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-07-29 09:00:00');
$this->db->shouldReceive('get_row')->once()->andReturn(null);
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_guardians',
[
'guardian_id' => 5,
'student_id' => 42,
'relationship' => 'Parent',
'created_at' => '2026-07-29 09:00:00',
],
['%d', '%d', '%s', '%s']
);
$this->db->insert_id = 7;
self::assertSame(7, $this->repo->insert(new GuardianLink(5, 42, 'Parent')));
}
/**
* v1 is one guardian per child. The check lives in the repository so every
* caller signup, the family screen, admin gets it without repeating it.
*/
public function testInsertRefusesAChildThatAlreadyHasAGuardian(): void
{
$this->db->shouldReceive('get_row')->once()->andReturn((object) [
'id' => 1,
'guardian_id' => 9,
'student_id' => 42,
'relationship' => '',
'created_at' => '2026-07-01 09:00:00',
]);
$this->db->shouldNotReceive('insert');
self::assertSame(0, $this->repo->insert(new GuardianLink(5, 42)));
}
public function testFindByStudentReturnsNullWhenTheyBookForThemselves(): void
{
$this->db->shouldReceive('get_row')->once()->andReturn(null);
self::assertNull($this->repo->findByStudent(42));
}
public function testFindByGuardianMapsEveryRow(): void
{
$this->db->shouldReceive('get_results')->once()->andReturn([
(object) ['id' => 1, 'guardian_id' => 5, 'student_id' => 42, 'relationship' => '', 'created_at' => '2026-07-01 09:00:00'],
(object) ['id' => 2, 'guardian_id' => 5, 'student_id' => 43, 'relationship' => '', 'created_at' => '2026-07-02 09:00:00'],
]);
$links = $this->repo->findByGuardian(5);
self::assertCount(2, $links);
self::assertSame([42, 43], array_map(static fn (GuardianLink $l): int => $l->studentId, $links));
}
public function testFindByGuardianReturnsAnEmptyListWhenTheQueryReturnsNull(): void
{
$this->db->shouldReceive('get_results')->once()->andReturn(null);
self::assertSame([], $this->repo->findByGuardian(5));
}
public function testIsGuardianOfIsTrueOnlyForALinkedPair(): void
{
$this->db->shouldReceive('get_var')->once()->andReturn('1');
self::assertTrue($this->repo->isGuardianOf(5, 42));
$this->db->shouldReceive('get_var')->once()->andReturn(null);
self::assertFalse($this->repo->isGuardianOf(5, 99));
}
public function testDeleteReportsWhetherARowWasRemoved(): void
{
$this->db->shouldReceive('delete')
->once()
->with('wp_us_guardians', ['guardian_id' => 5, 'student_id' => 42], ['%d', '%d'])
->andReturn(1);
self::assertTrue($this->repo->delete(5, 42));
$this->db->shouldReceive('delete')->once()->andReturn(0);
self::assertFalse($this->repo->delete(5, 99));
}
public function testCountChildrenReturnsAnInt(): void
{
$this->db->shouldReceive('get_var')->once()->andReturn('2');
self::assertSame(2, $this->repo->countChildren(5));
}
}
+377
View File
@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Guardian;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianLink;
use Unsupervised\Schedular\Guardian\GuardianRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GuardianServiceTest extends TestCase
{
private GuardianRepository&Mockery\MockInterface $guardians;
private BookingRepository&Mockery\MockInterface $bookings;
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private GuardianService $service;
/** @var array<int, array<string, string>> */
private array $meta = [];
protected function setUp(): void
{
parent::setUp();
$this->guardians = Mockery::mock(GuardianRepository::class);
$this->bookings = Mockery::mock(BookingRepository::class);
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->service = new GuardianService($this->guardians, $this->bookings, $this->enrollments);
$meta = &$this->meta;
Functions\when('update_user_meta')->alias(
static function (int $userId, string $key, $value) use (&$meta): bool {
$meta[$userId][$key] = (string) $value;
return true;
}
);
// A regular closure, not an arrow fn: arrow functions capture by value,
// so the stub would read a snapshot of the meta taken at setUp.
Functions\when('get_user_meta')->alias(
static function (int $userId, string $key, bool $single = false) use (&$meta): string {
return $meta[$userId][$key] ?? '';
}
);
Functions\when('delete_user_meta')->alias(
static function (int $userId, string $key) use (&$meta): bool {
unset($meta[$userId][$key]);
return true;
}
);
// The birth-year range is validated against "this year", so pin it.
Functions\when('current_time')->justReturn('2026');
Functions\when('wp_generate_password')->justReturn('abc123def456');
Functions\when('email_exists')->justReturn(false);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
}
private function user(int $id, string $first = '', string $last = '', string $nickname = '', string $email = ''): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->ID = $id;
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = $nickname;
$user->user_email = $email;
return $user;
}
public function testCreateChildInsertsALoginLessUserAndLinksIt(): void
{
$captured = [];
Functions\when('wp_insert_user')->alias(
static function (array $args) use (&$captured): int {
$captured = $args;
return 42;
}
);
$this->guardians->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent'))
->andReturn(7);
$result = $this->service->createChild(5, ' Ada ', '2015', 'Parent');
self::assertSame(42, $result);
self::assertSame('Ada', $captured['display_name']);
// The address is on the reserved .invalid TLD, so it can never receive mail.
self::assertStringEndsWith('@child.invalid', $captured['user_email']);
self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]);
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
}
public function testCreateChildRejectsABlankName(): void
{
Functions\expect('wp_insert_user')->never();
$result = $this->service->createChild(5, ' ');
self::assertInstanceOf(\WP_Error::class, $result);
}
/**
* A child whose link could not be written would be an unreachable orphan
* account, so the user is removed again rather than left behind.
*/
public function testCreateChildDeletesTheUserWhenTheLinkFails(): void
{
Functions\when('wp_insert_user')->justReturn(42);
$this->guardians->shouldReceive('insert')->once()->andReturn(0);
Functions\expect('wp_delete_user')->once()->with(42);
self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada', '2015'));
}
/**
* @dataProvider unusableBirthYears
*/
public function testCreateChildRefusesAnUnusableBirthYear(string $submitted): void
{
// Refused before anything is written, so no orphan user is left behind.
Functions\expect('wp_insert_user')->never();
$this->guardians->shouldNotReceive('insert');
$result = $this->service->createChild(5, 'Ada', $submitted);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('missing_birth_year', $result->get_error_code());
self::assertArrayNotHasKey(42, $this->meta);
}
/** @dataProvider unusableBirthYears */
public function testUpdateChildRefusesAnUnusableBirthYear(string $submitted): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\expect('wp_update_user')->never();
$result = $this->service->updateChild(5, 42, 'Ada', $submitted);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('missing_birth_year', $result->get_error_code());
}
/** @return array<string, array{string}> */
public static function unusableBirthYears(): array
{
return [
'not a number' => ['not-a-year'],
'a full date' => ['2015-04-02'],
'too few digits' => ['15'],
'too many digits' => ['20155'],
'before 1900' => ['1899'],
'later than today' => ['2027'],
'left blank' => [''],
];
}
public function testCanActForSelfAndOwnChildOnly(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
$this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false);
self::assertTrue($this->service->canActFor(5, 5));
self::assertTrue($this->service->canActFor(5, 42));
self::assertFalse($this->service->canActFor(5, 99));
}
public function testCanActForRejectsNonPositiveIds(): void
{
self::assertFalse($this->service->canActFor(0, 42));
self::assertFalse($this->service->canActFor(5, 0));
}
public function testPayerForResolvesTheGuardianAndFallsBackToTheStudent(): void
{
$this->guardians->shouldReceive('findByStudent')->with(42)->andReturn(new GuardianLink(5, 42));
$this->guardians->shouldReceive('findByStudent')->with(9)->andReturn(null);
self::assertSame(5, $this->service->payerFor(42));
self::assertSame(9, $this->service->payerFor(9));
}
public function testHouseholdIdsCoverTheUserAndEveryChild(): void
{
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([
new GuardianLink(5, 42),
new GuardianLink(5, 43),
]);
self::assertSame([5, 42, 43], $this->service->householdIds(5));
}
/**
* The order is the feature: a guardian's default selection must be a child,
* never themselves, so a lesson meant for a kid is not booked in the
* parent's name by simply not touching the picker.
*/
public function testBookableStudentsListsChildrenBeforeTheAccountHolder(): void
{
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([
new GuardianLink(5, 42),
new GuardianLink(5, 43),
]);
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => match ($id) {
5 => $this->user(5, 'Grace', 'Hopper'),
42 => $this->user(42, 'Ada', 'Lovelace'),
default => $this->user(43, 'Alan', 'Turing'),
});
$students = $this->service->bookableStudents(5);
self::assertSame(['Ada Lovelace', 'Alan Turing', 'Grace Hopper'], array_column($students, 'name'));
self::assertSame([42, 43, 5], array_column($students, 'id'));
self::assertSame([false, false, true], array_column($students, 'is_self'));
}
public function testChildrenReportsTheStoredBirthYear(): void
{
$this->meta[42][GuardianService::META_BIRTH_YEAR] = '2015';
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
}
/**
* A child added before this feature switched to a year has only the old full
* date on record, and must still show a birth year.
*/
public function testChildrenDerivesABirthYearFromALegacyDateOfBirth(): void
{
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
}
/**
* Saving a child drops the legacy full date, so the fallback above can never
* outrank a year the guardian has since corrected by hand.
*/
public function testSavingAChildClearsTheLegacyDateOfBirth(): void
{
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\when('wp_update_user')->justReturn(42);
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2016'));
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
self::assertSame('2016', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
// The corrected year is what is read back, not the year of the old date.
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('2016', $this->service->children(5)[0]['birth_year']);
}
public function testBookableStudentsIsJustTheUserWithoutChildren(): void
{
$this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]);
Functions\when('get_userdata')->justReturn($this->user(9, 'Ada', 'Lovelace'));
$students = $this->service->bookableStudents(9);
self::assertCount(1, $students);
self::assertTrue($students[0]['is_self']);
}
public function testContactForPrefersTheGuardian(): void
{
$this->guardians->shouldReceive('findByStudent')->with(42)->andReturn(new GuardianLink(5, 42));
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
self::assertSame(
['id' => 5, 'name' => 'Grace Hopper', 'email' => '[email protected]'],
$this->service->contactFor(42)
);
}
public function testContactForFallsBackToTheStudentThemselves(): void
{
$this->guardians->shouldReceive('findByStudent')->with(9)->andReturn(null);
Functions\when('get_userdata')->justReturn($this->user(9, 'Ada', 'Lovelace', email: '[email protected]'));
self::assertSame(
['id' => 9, 'name' => 'Ada Lovelace', 'email' => '[email protected]'],
$this->service->contactFor(9)
);
}
public function testUpdateChildRefusesAStudentTheCallerDoesNotGuard(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false);
Functions\expect('wp_update_user')->never();
self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory'));
}
public function testUpdateChildRenamesAndStoresTheBirthYear(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\expect('wp_update_user')
->once()
->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L'])
->andReturn(42);
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015'));
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
}
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
$this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([]);
$this->guardians->shouldReceive('delete')->once()->with(5, 42)->andReturn(true);
Functions\expect('wp_delete_user')->once()->with(42);
self::assertNull($this->service->removeChild(5, 42));
}
/**
* A child's id is referenced by lessons, payments and credits, so deleting
* one with history would orphan all of it.
*/
public function testRemoveChildRefusesOnceTheyHaveLessons(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
$this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([Mockery::mock(\stdClass::class)]);
$this->guardians->shouldNotReceive('delete');
$result = $this->service->removeChild(5, 42);
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('has_history', $result->get_error_code());
}
public function testRemoveChildRefusesOnceTheyHaveEnrolments(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
$this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([Mockery::mock(\stdClass::class)]);
$this->guardians->shouldNotReceive('delete');
self::assertInstanceOf(\WP_Error::class, $this->service->removeChild(5, 42));
}
public function testRemoveChildRefusesAStudentTheCallerDoesNotGuard(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false);
$this->guardians->shouldNotReceive('delete');
self::assertInstanceOf(\WP_Error::class, $this->service->removeChild(5, 99));
}
public function testIsChildReadsTheMetaFlag(): void
{
$this->meta[42][GuardianService::META_CHILD] = '1';
self::assertTrue(GuardianService::isChild(42));
self::assertFalse(GuardianService::isChild(9));
}
}
+64 -1
View File
@@ -42,7 +42,7 @@ class CreditRepositoryTest extends TestCase
);
$this->db->insert_id = 300;
$credit = new Credit(5, 33.0, 33.0, 'CAD', 12, 77, 'Credit for cancelled lesson #77');
$credit = new Credit(5, 33.0, 33.0, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, reason: 'Credit for cancelled lesson #77');
self::assertSame(300, $this->repo->insert($credit));
}
@@ -108,4 +108,67 @@ class CreditRepositoryTest extends TestCase
$this->repo->consume(5, 0.0);
}
/**
* The balance is keyed on the payer, not the student, so a guardian's
* account carries the credits every one of their children earned.
*/
public function testAvailableBalanceQueriesThePayer(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'payer_id = %d')), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE)
->andReturn('sql');
$this->db->shouldReceive('get_var')->once()->with('sql')->andReturn('60.00');
self::assertSame(60.0, $this->repo->availableBalance(5));
}
public function testFindAvailableByPayerReturnsCreditsOldestFirst(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'payer_id = %d')), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE)
->andReturn('sql');
$this->db->shouldReceive('get_results')->once()->with('sql')->andReturn([
(object) ['id' => '1', 'student_id' => '42', 'payer_id' => '5', 'amount' => '10.00', 'remaining' => '10.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-07-01 09:00:00', 'updated_at' => null],
]);
$credits = $this->repo->findAvailableByPayer(5);
self::assertCount(1, $credits);
self::assertSame(42, $credits[0]->studentId);
self::assertSame(5, $credits[0]->payerId);
}
/**
* Rows written before guardian accounts existed carry payer_id 0; the
* installer points them at the student who was always the payer.
*/
public function testBackfillPayerIdsPointsLegacyRowsAtTheStudent(): void
{
$this->db->shouldReceive('prepare')
->once()
->with('UPDATE %i SET payer_id = student_id WHERE payer_id = 0', 'wp_us_credits')
->andReturn('sql');
$this->db->shouldReceive('query')->once()->with('sql');
$this->repo->backfillPayerIds();
}
public function testInsertDefaultsThePayerToTheStudent(): void
{
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_credits',
Mockery::on(static fn (array $d): bool => $d['student_id'] === 5 && $d['payer_id'] === 5),
Mockery::type('array')
);
$this->db->insert_id = 301;
self::assertSame(301, $this->repo->insert(new Credit(5, 10.0, 10.0)));
}
}
+93 -12
View File
@@ -65,7 +65,7 @@ class PaymentServiceTest extends TestCase
private function payment(string $method, string $status, int $id): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', $method, $status, id: $id);
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: $method, status: $status, id: $id);
}
public function testFreeRegistrationCreatesNoPayment(): void
@@ -85,7 +85,7 @@ class PaymentServiceTest extends TestCase
{
// A scheduled (weekly/monthly) payment can cover several lessons and may be
// collected: cancelling one lesson must never void it or trigger a rebill.
$scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60);
$scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60);
$this->payments->shouldReceive('findById')->with(60)->andReturn($scheduled);
$this->payments->shouldNotReceive('updateStatus');
@@ -252,7 +252,7 @@ class PaymentServiceTest extends TestCase
public function testCreateIntentForEtransferReturnsDisplayDataWithoutStripe(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, etransferEmail: '[email protected]', id: 91);
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, etransferEmail: '[email protected]', id: 91);
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn($payment);
$this->stripe->shouldNotReceive('createIntent');
@@ -348,7 +348,7 @@ class PaymentServiceTest extends TestCase
public function testCreditForCancelledLessonCreditsWholeTotalOfSingleLessonPayment(): void
{
// A paid single-lesson payment: the whole total (incl. tax) is credited.
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12);
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false);
@@ -361,7 +361,7 @@ class PaymentServiceTest extends TestCase
&& $c->sourceLessonId === 77))
->andReturn(300);
$this->credits->shouldReceive('findById')->with(300)->andReturn(
new Credit(5, 33.00, 33.00, 'CAD', 12, 77, id: 300)
new Credit(5, 33.00, 33.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, id: 300)
);
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 77);
@@ -371,7 +371,7 @@ class PaymentServiceTest extends TestCase
public function testCreditForCancelledLessonSplitsSharedMonthlyPayment(): void
{
// A monthly scheduled charge covering 3 lessons: one cancellation credits a third.
$paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12);
$paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(3);
$this->credits->shouldReceive('existsForLesson')->with(202)->andReturn(false);
@@ -380,7 +380,7 @@ class PaymentServiceTest extends TestCase
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
->andReturn(301);
$this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 202, id: 301));
$this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 202, id: 301));
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 202);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
@@ -388,7 +388,7 @@ class PaymentServiceTest extends TestCase
public function testCreditForCancelledLessonSkipsUnpaidPayment(): void
{
$pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12);
$pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($pending);
$this->credits->shouldNotReceive('insert');
@@ -406,7 +406,7 @@ class PaymentServiceTest extends TestCase
public function testCreditForCancelledLessonSkipsAlreadyCredited(): void
{
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12);
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(true);
@@ -420,7 +420,7 @@ class PaymentServiceTest extends TestCase
{
// A non-anchor series lesson has no payment_id of its own; the anchor's
// upfront (unscheduled) payment covers the whole 4-lesson series.
$anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12);
$anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 40)->andReturn($anchorPayment);
$this->payments->shouldReceive('findById')->with(12)->andReturn($anchorPayment);
$this->bookings->shouldReceive('countBySeries')->with(40)->andReturn(4);
@@ -430,7 +430,7 @@ class PaymentServiceTest extends TestCase
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
->andReturn(302);
$this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 43, id: 302));
$this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 43, id: 302));
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: 40, paymentId: null, id: 43);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
@@ -487,7 +487,7 @@ class PaymentServiceTest extends TestCase
private function pending(int $id, float $amount): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id);
return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id);
}
private function intentEvent(string $type, string $intentId): \Stripe\Event
@@ -496,4 +496,85 @@ class PaymentServiceTest extends TestCase
return \Stripe\Event::constructFrom(['type' => $type, 'data' => ['object' => $intent]]);
}
/**
* The billing method resolves against the payer, so comping or card-billing a
* family is one setting on the guardian rather than one per child.
*/
public function testCreateForRegistrationResolvesTheBillingMethodAgainstThePayer(): void
{
$this->resolver->shouldReceive('resolve')->once()->with(5)->andReturn(Payment::METHOD_ETRANSFER);
$this->settings->shouldReceive('etransferEmail')->andReturn('');
$this->settings->shouldReceive('hstRate')->andReturn(0.0);
$this->payments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->studentId === 42 && $p->payerId === 5))
->andReturn(90);
$this->bookings->shouldReceive('setPaymentId')->once();
$this->payments->shouldReceive('findById')->with(90)->andReturn(
new Payment(42, 3, Payment::REG_LESSON, 12, 35.00, payerId: 5, id: 90)
);
$payment = $this->service->createForRegistration(Payment::REG_LESSON, 12, 42, 3, 35.00, 'CAD', payerId: 5);
self::assertSame(5, $payment?->payerId);
}
/**
* A credit earned by a child lands on the guardian's balance, so one child's
* cancellation can settle a sibling's next charge.
*/
public function testCancelledChildLessonCreditsTheGuardiansBalance(): void
{
$lesson = new Lesson(slotId: 10, studentId: 42, instructorId: 3, paymentId: 12, id: 77);
$paid = new Payment(42, 3, Payment::REG_LESSON, 77, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->studentId === 42 && $c->payerId === 5 && $c->amount === 30.0))
->andReturn(300);
$this->credits->shouldReceive('findById')->with(300)->andReturn(
new Credit(42, 30.00, 30.00, payerId: 5, id: 300)
);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
}
public function testApplyCreditsDrawsDownThePayersBalanceAcrossChildrensCharges(): void
{
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(60.0);
$adasCharge = new Payment(42, 3, Payment::REG_LESSON, 12, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 91);
$alansCharge = new Payment(43, 3, Payment::REG_LESSON, 13, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 92);
$this->payments->shouldReceive('addCreditApplied')->once()->with(91, 30.0);
$this->payments->shouldReceive('addCreditApplied')->once()->with(92, 30.0);
$this->payments->shouldReceive('markPaid')->twice();
$this->bookings->shouldReceive('findById')->andReturn(null);
$this->bookings->shouldReceive('updateStatus')->twice();
$this->credits->shouldReceive('consume')->once()->with(5, 60.0);
$applied = $this->service->applyCredits(5, [$adasCharge, $alansCharge]);
self::assertSame([91 => 30.0, 92 => 30.0], $applied);
}
/**
* A guardian paying for their child's lesson must reach the payment step;
* anyone else must not.
*/
public function testCreateIntentIsAllowedForBothTheStudentAndTheirPayer(): void
{
$payment = new Payment(42, 3, Payment::REG_LESSON, 12, 35.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 91);
$this->payments->shouldReceive('findByRegistration')->andReturn($payment);
self::assertNotNull($this->service->createIntent(Payment::REG_LESSON, 12, 42));
self::assertNotNull($this->service->createIntent(Payment::REG_LESSON, 12, 5));
self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 99));
}
}
+105 -10
View File
@@ -8,6 +8,7 @@ use Mockery;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
@@ -18,6 +19,7 @@ use Unsupervised\Schedular\Tests\Unit\TestCase;
class ScheduledBillingRunnerTest extends TestCase
{
private GuardianService&Mockery\MockInterface $guardians;
private PaymentService $payments;
private BookingRepository $bookings;
private EnrollmentRepository $enrollments;
@@ -49,12 +51,17 @@ class ScheduledBillingRunnerTest extends TestCase
$student->user_email = '[email protected]';
Functions\when('get_userdata')->justReturn($student);
$this->guardians = Mockery::mock(GuardianService::class);
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
$this->guardians->shouldReceive('studentName')->andReturn('Ada')->byDefault();
$this->runner = new ScheduledBillingRunner(
$this->payments,
$this->bookings,
$this->enrollments,
$this->offerings,
$this->mailer
$this->mailer,
$this->guardians
);
}
@@ -65,7 +72,7 @@ class ScheduledBillingRunnerTest extends TestCase
private function pending(int $id, string $due): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: $due, id: $id);
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: $due, id: $id);
}
private function lessonRow(int $id, string $mode, string $start, float $price, int $offeringId = 9): object
@@ -92,7 +99,7 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', '[email protected]', '2026-07-14', '2026-07-15')
->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', '[email protected]', '2026-07-14', '2026-07-15', 5)
->andReturn($this->pending(500, '2026-07-14'));
$this->mailer->shouldReceive('send')->once();
@@ -124,7 +131,7 @@ class ScheduledBillingRunnerTest extends TestCase
// One payment for the month: 3 x 30, due on the 1st, linked to the earliest.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', '[email protected]', '2026-07-01', '2026-07')
->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', '[email protected]', '2026-07-01', '2026-07', 5)
->andReturn($this->pending(600, '2026-07-01'));
// The other two lessons are pointed at the same payment so they are not re-billed.
@@ -158,11 +165,11 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07')
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07', 5)
->andReturn($this->pending(700, '2026-07-06'));
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14', 5)
->andReturn($this->pending(701, '2026-07-13'));
$this->runner->run();
@@ -181,7 +188,7 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14', 5)
->andReturn($this->pending(701, '2026-07-13'));
$this->runner->run();
@@ -205,7 +212,7 @@ class ScheduledBillingRunnerTest extends TestCase
// One payment of the monthly fee — not 4 x 20 — due on the 1st.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07', 5)
->andReturn($this->pending(800, '2026-07-01'));
$this->runner->run();
@@ -227,7 +234,7 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07', 5)
->andReturn($this->pending(800, '2026-07-01'));
$this->runner->run();
@@ -240,7 +247,7 @@ class ScheduledBillingRunnerTest extends TestCase
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
// A comp student's payment comes back paid — no due notice should be sent.
$comp = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_COMP, Payment::STATUS_PAID, dueDate: '2026-07-14', id: 900);
$comp = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_COMP, status: Payment::STATUS_PAID, dueDate: '2026-07-14', id: 900);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($comp);
$this->mailer->shouldNotReceive('send');
@@ -333,4 +340,92 @@ class ScheduledBillingRunnerTest extends TestCase
id: 9,
);
}
/**
* Two children billed on the same day belong to one payer, so the guardian
* gets a single notice covering both not one email per child and each
* line names whose lesson it is.
*/
public function testAGuardiansChildrenShareOneNoticeWithNamedLines(): void
{
$this->now('2026-07-15 09:00:00');
$adas = $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0);
$alans = $this->lessonRow(102, Offering::BILLING_WEEKLY, '2026-07-15 19:00:00', 35.0);
$adas->student_id = '42';
$alans->student_id = '43';
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([$adas, $alans]);
$this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
$this->guardians->shouldReceive('payerFor')->with(43)->andReturn(5);
$this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada');
$this->guardians->shouldReceive('studentName')->with(43)->andReturn('Alan');
$this->payments->shouldReceive('createForRegistration')
->andReturn($this->pending(500, '2026-07-14'), $this->pending(501, '2026-07-14'));
// One send, two lines, each prefixed with the child it is for.
$this->mailer->shouldReceive('send')
->once()
->with(
Mockery::type(\WP_User::class),
Mockery::on(static function (array $items): bool {
return count($items) === 2
&& str_starts_with((string) $items[0]['label'], 'Ada: ')
&& str_starts_with((string) $items[1]['label'], 'Alan: ');
}),
Mockery::type('string'),
0.0
);
$this->runner->run();
}
/**
* A student who pays for themselves gets the plain label prefixing every
* line with their own name would be noise.
*/
public function testAStudentPayingForThemselvesGetsAnUnprefixedLabel(): 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')->andReturn($this->pending(500, '2026-07-14'));
$this->mailer->shouldReceive('send')
->once()
->with(
Mockery::type(\WP_User::class),
Mockery::on(static fn (array $items): bool => str_starts_with((string) $items[0]['label'], 'Piano — ')),
Mockery::type('string'),
0.0
);
$this->runner->run();
}
/**
* The family balance is drawn against the payer, so a credit one child
* earned can settle a sibling's charge.
*/
public function testCreditsAreAppliedAgainstThePayerNotEachStudent(): void
{
$this->now('2026-07-15 09:00:00');
$adas = $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0);
$adas->student_id = '42';
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([$adas]);
$this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5);
$this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada');
$this->payments->shouldReceive('createForRegistration')->andReturn($this->pending(500, '2026-07-14'));
$this->payments->shouldReceive('applyCredits')->once()->with(5, Mockery::type('array'))->andReturn([]);
$this->mailer->shouldReceive('send')->once();
$this->runner->run();
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ class StripeGatewayTest extends TestCase
private function payment(): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_CARD, Payment::STATUS_PENDING, id: 90);
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_CARD, status: Payment::STATUS_PENDING, id: 90);
}
public function testCreateIntentReturnsNullWhenNotConfigured(): void
@@ -36,13 +36,15 @@ class AcceptanceRepositoryTest extends TestCase
&& $d['student_id'] === 5
&& $d['registration_type'] === PolicyAcceptance::REG_LESSON
&& $d['registration_id'] === 12
// No explicit acceptor: the student agreed for themselves.
&& $d['accepted_by'] === 5
&& $d['ip_address'] === '203.0.113.7';
}),
['%d', '%d', '%s', '%d', '%s', '%s']
['%d', '%d', '%d', '%s', '%d', '%s', '%s']
);
$this->db->insert_id = 1;
$acceptance = new PolicyAcceptance(9, 5, PolicyAcceptance::REG_LESSON, 12, '203.0.113.7');
$acceptance = new PolicyAcceptance(9, 5, PolicyAcceptance::REG_LESSON, 12, ipAddress: '203.0.113.7');
self::assertSame(1, $this->repo->insert($acceptance));
}
+12 -4
View File
@@ -6,10 +6,12 @@ namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Guardian\FamilyPage;
use Unsupervised\Schedular\ShortcodeRegistrar;
class ShortcodeRegistrarTest extends TestCase
@@ -18,6 +20,8 @@ class ShortcodeRegistrarTest extends TestCase
private LoginPage&Mockery\MockInterface $loginPage;
private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage;
private FamilyPage&Mockery\MockInterface $familyPage;
private AccountPage&Mockery\MockInterface $accountPage;
private ShortcodeRegistrar $registrar;
/** @var array<string, callable> */
@@ -34,12 +38,16 @@ class ShortcodeRegistrarTest extends TestCase
$this->loginPage = Mockery::mock(LoginPage::class);
$this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
$this->familyPage = Mockery::mock(FamilyPage::class);
$this->accountPage = Mockery::mock(AccountPage::class);
$this->registrar = new ShortcodeRegistrar(
$this->bookingPage,
$this->loginPage,
$this->registrationPage,
$this->groupClassPage,
$this->familyPage,
$this->accountPage,
);
$shortcodes = &$this->shortcodes;
@@ -50,7 +58,7 @@ class ShortcodeRegistrarTest extends TestCase
);
}
public function testRegisterAddsAllFourShortcodesAndHooks(): void
public function testRegisterAddsAllShortcodesAndHooks(): void
{
Actions\expectAdded('template_redirect')
->once()
@@ -62,7 +70,7 @@ class ShortcodeRegistrarTest extends TestCase
$this->registrar->register();
self::assertSame(
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes'],
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family', 'us_account'],
array_keys($this->shortcodes)
);
}
@@ -97,8 +105,8 @@ class ShortcodeRegistrarTest extends TestCase
$scripts = $this->captureEnqueuedAssets();
self::assertSame(['us-scheduler-payment'], $scripts['us-scheduler-pricing']);
self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler']);
self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler-group']);
self::assertSame(['us-scheduler-pricing', 'us-scheduler-guardian'], $scripts['us-scheduler']);
self::assertSame(['us-scheduler-pricing', 'us-scheduler-guardian'], $scripts['us-scheduler-group']);
}
/**
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.2.4
* Version: 1.3.1
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
define('USC_VERSION', '1.2.4');
define('USC_VERSION', '1.3.1');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));