Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c41d1119d | ||
|
|
8122c158cf
|
||
|
|
b772e1811e
|
||
|
|
c25260a367 | ||
|
|
96aaeff79c | ||
|
|
bbc85d88f1 | ||
|
|
171b655bb8
|
||
|
|
d9dd576630 | ||
|
|
61b00c2ed3
|
||
|
|
7eb2afc6a3
|
||
|
|
8a985f04d6
|
||
|
|
da985c7f71 | ||
|
|
7c91e1eef7
|
||
|
|
f3917d0784 | ||
|
|
b508ab92f8
|
||
|
|
2a661a10ff | ||
|
|
95df78d384 | ||
|
|
fabbd35fa7 | ||
|
|
3a954bac57
|
||
|
|
907f665876 | ||
|
|
bfdc3b3380
|
||
|
|
a276d53c1b
|
||
|
|
9344ab7193
|
||
|
|
d6a515cc93 | ||
|
|
ae07930d6d |
@@ -11,6 +11,38 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
|||||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||||
each change under the current top section as you work.
|
each change under the current top section as you work.
|
||||||
|
|
||||||
|
## [1.3.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Parent and guardian accounts.** A parent registers once and manages lessons for one or more children, who need no login of their own. The signup form gains an **"I'm registering as a parent or guardian"** tick that reveals a block per child — name, date of birth, and the studio's account-signup questions asked **per child**, since those describe the student rather than the account holder. Signup policies are recorded once per child with the guardian named as the person who agreed, which is the record that actually means something: "this guardian accepted version N on behalf of this child, at this time, from this address." A guardian can also be a student themselves and book their own lessons from the same account.
|
||||||
|
- A **"Who is this for?"** picker on the booking and group-class forms, listing **children first** and the account holder last — so the default selection is never the parent, and a lesson meant for a child is not quietly booked and billed in the parent's name. An account with only itself on the list sees no picker and behaves exactly as before. A guardian's upcoming-lessons panel covers the whole household, each row naming whose lesson it is, and they can cancel or withdraw for any of their children.
|
||||||
|
- A **Family** page for guardians (`[us_family]`, or the **Family** block) to add, edit and remove children after signup. Removing a child is refused once they have lessons or enrolments on record — that history belongs to them, and the studio unpicks it by hand rather than the page orphaning it.
|
||||||
|
- **One family, one bill.** Payments record the child the lesson was for *and* the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step all go to the parent. Account credit is held by the payer, so a credit from one child's cancelled lesson can settle a sibling's next charge, and the billing-method override (comp / card / e-transfer) is one setting on the guardian rather than one per child. The daily billing scan sends a guardian **one** notice covering every child, with each line naming whose lesson it is.
|
||||||
|
- **Students** in wp-admin gains a **Family** column linking a child to their guardian and a guardian to their children, and the student screen gains a **Family** panel. A child's row shows the guardian's email — a child's own address is a placeholder that can never receive mail — and their credit balance is labelled with whose account actually holds it.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Child accounts **cannot be signed in to**. They hold the student role so every existing lookup keeps working, but authentication is refused outright and the booking capability is withheld, so the only route to a lesson in a child's name is their guardian's authorised booking.
|
||||||
|
|
||||||
|
## [1.2.4]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Adding availability no longer fails in silence.** Entering a window shorter than the chosen lesson length — 5:30–6:00 PM with the lesson length left on its default of 60 minutes, say — saved nothing and said nothing: the page just reloaded, whether the window was one-off or set to repeat for 41 weeks. The **Lesson length** menu now offers only the lengths that actually fit the window you have entered, and the form refuses to submit when none of them do. Every other way the form could quietly do nothing now explains itself too — an unreadable date, an end time before the start, a window running past midnight into the next day — and a successful save says how many bookable slots it created. Deleting says whether the slot went, and tells you when one is refused because it is already booked. Availability added through the API is checked against exactly the same rules, which it previously enforced slightly differently.
|
||||||
|
- A student's **upcoming lessons no longer pile on top of each other**. On the booking page, the lesson name, its date and time, the status badge and the **Cancel** button could render over one another instead of sitting in a tidy row — worst with a long lesson-type name, and on narrow screens, where the row had no phone layout at all. The panel now keeps its shape whatever the theme around it does, long names wrap instead of shoving the Cancel button out of the row, and on a phone the lesson details stack above the buttons.
|
||||||
|
- The registration page **no longer dead-ends a visitor who is already signed in**. It used to greet them with "You already have an account and are logged in." and nothing else, leaving them to find their own way to the studio. They now get a link onward to the page chosen under the block's **After registration** panel, and the link names it — "Continue to Book a Lesson" rather than the vaguer wording an invited student used to see. With no page chosen, the message appears on its own as before, because sending someone who is already signed in to the sign-in screen helps nobody.
|
||||||
|
|
||||||
|
## [1.2.3]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A **monthly group class is now billed its price once per month**, however many times the class meets in that month. Previously the monthly charge multiplied the price by the number of sessions in the month — a class priced at `40.00 CAD` meeting weekly was billed `160.00 CAD` on the 1st — which no studio could quote honestly on a class card. A monthly **private lesson** is unchanged: its price is a per-lesson fee and the month is still billed one fee per lesson, which is why it is quoted per lesson. Studios running a monthly group class should check the class price now reads as the monthly fee they intend to charge.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Every price a student sees now says **when** it is due. Lesson types in the booking form read `50.00 CAD at booking`, and group-class cards read `120.00 CAD up front`, `40.00 CAD weekly` or `40.00 CAD monthly` — the offering's billing mode, in the student's words. A monthly **private lesson** is quoted per lesson (`50.00 CAD per lesson monthly`), since its monthly charge covers every lesson booked that month; a monthly group class is quoted as the monthly figure it is. A free offering still just reads **Free**.
|
||||||
|
- The **Policies** admin page can now **show you what is actually in a version**. Every row in the versions table has a **View** button that opens that version's text below the table, rendered exactly as students see it at booking and signup, whether the version is the published one, an old archived one, or a draft nobody has seen yet. The text is editable straight from the viewer, and what happens when you save depends on the version: a draft is simply updated in place, while editing a **published or archived version saves your text as a new draft version** and leaves the original exactly as students accepted it. The new draft then opens in the viewer ready to publish. Nothing a student has agreed to is ever rewritten.
|
||||||
|
- Booking a lesson and enrolling in a class now take a **second confirmation that the student agrees to pay**. Above the Confirm button the form restates the price with its cadence, spells out how it is collected ("Charged on the 1st of each month, for that month's lessons"), adds the studio's HST so the figure matches the total actually billed, and requires a tick on "I agree to pay 56.50 CAD at booking." before it will submit — separate from, and in addition to, the studio policies the student accepts above it. Reserving a time weekly quotes the per-lesson fee and the most it can add up to ("up to 12 lessons, 678.00 CAD in total"), since a week another student takes first is simply not booked. Free offerings have nothing to agree to and show no price block.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Policies are **readable where students have to accept them**. A policy typed as plain paragraphs — the normal way to write one, with no HTML — was being dropped into the booking, enrolment, and signup forms unformatted, collapsing the whole document into a single squashed line with a horizontal scrollbar and words piling on top of each other. Policy text is now formatted the same way WordPress formats post content, so blank lines become real paragraphs, and the acceptance box is styled as a proper bounded reading panel: long policies scroll vertically instead of running off the side of the page, long pasted links wrap rather than forcing the page sideways, and the "I have read and agree" tick stays in view. Policies written with HTML are unaffected. The studio registration page was also missing the plugin's stylesheet entirely, which is why the problem was at its worst there.
|
||||||
|
|
||||||
## [1.2.2]
|
## [1.2.2]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+255
-16
@@ -34,47 +34,72 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-my-lessons {
|
/*
|
||||||
|
* The upcoming-lessons panel. Every rule here is scoped under #us-booking-app —
|
||||||
|
* the same id-level specificity .us-slot above uses — because these rows sit in
|
||||||
|
* whatever layout the theme provides and carry more content than a calendar
|
||||||
|
* cell. Bare class selectors lost to theme rules on div/span/strong, which
|
||||||
|
* collapsed the flex layout and piled the details on top of the actions.
|
||||||
|
*/
|
||||||
|
#us-booking-app .us-my-lessons {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-my-lesson {
|
#us-booking-app .us-my-lesson {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-my-lesson-info {
|
/*
|
||||||
|
* `min-width: 0` lets the title column shrink below its content width — without
|
||||||
|
* it a long offering title cannot compress and shoves the status pill and
|
||||||
|
* Cancel button out of the row. The flex-basis keeps the details and the
|
||||||
|
* actions on one line while there is room, and wraps them once there is not.
|
||||||
|
*/
|
||||||
|
#us-booking-app .us-my-lesson-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
flex: 1 1 14em;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-my-lesson-title {
|
#us-booking-app .us-my-lesson-title,
|
||||||
|
#us-booking-app .us-my-lesson-when {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#us-booking-app .us-my-lesson-title {
|
||||||
font-size: 1.05em;
|
font-size: 1.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-my-lesson-duration {
|
#us-booking-app .us-my-lesson-duration {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-my-lesson-when {
|
#us-booking-app .us-my-lesson-when {
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-my-lesson-actions {
|
#us-booking-app .us-my-lesson-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-show-all-lessons {
|
#us-booking-app .us-show-all-lessons {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -82,11 +107,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-show-all-lessons:hover {
|
#us-booking-app .us-show-all-lessons:hover {
|
||||||
border-color: #888;
|
border-color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-cancel-lesson {
|
#us-booking-app .us-cancel-lesson {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -95,24 +120,26 @@
|
|||||||
color: #c00;
|
color: #c00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-cancel-lesson:hover {
|
#us-booking-app .us-cancel-lesson:hover {
|
||||||
border-color: #c00;
|
border-color: #c00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-lesson-status {
|
#us-booking-app .us-lesson-status {
|
||||||
|
display: inline-block;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #eee;
|
background: #eee;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-lesson-status-confirmed {
|
#us-booking-app .us-lesson-status-confirmed {
|
||||||
background: #e2f5e5;
|
background: #e2f5e5;
|
||||||
color: #1a7d2e;
|
color: #1a7d2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-lesson-status-pending {
|
#us-booking-app .us-lesson-status-pending {
|
||||||
background: #fdf3d7;
|
background: #fdf3d7;
|
||||||
color: #8a6d1a;
|
color: #8a6d1a;
|
||||||
}
|
}
|
||||||
@@ -241,6 +268,102 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The price and pay agreement on a booking / enrolment form. */
|
||||||
|
.us-price {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-price h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-price p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-price-amount strong {
|
||||||
|
font-size: 1.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-price-cadence {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-price-tax,
|
||||||
|
.us-price-note {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-price-agree {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cadence-carrying price on a group-class card. */
|
||||||
|
.us-class-price {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Policy acceptance — booking, enrolment, and signup all render this markup. */
|
||||||
|
.us-policy {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-policy h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The body is admin-authored HTML sitting inside whatever layout the theme
|
||||||
|
* provides, so it gets an explicit reading box rather than inheriting one.
|
||||||
|
* `overflow-wrap` breaks pasted URLs instead of letting one long token force
|
||||||
|
* the horizontal scrollbar, and the bounded height keeps a long policy from
|
||||||
|
* pushing the accept checkbox off the screen.
|
||||||
|
*/
|
||||||
|
.us-policy-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fafafa;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-policy-body p,
|
||||||
|
.us-policy-body ul,
|
||||||
|
.us-policy-body ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-policy-body ul,
|
||||||
|
.us-policy-body ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-policy-body > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-policy-accept,
|
||||||
|
.us-policies input[type="checkbox"] {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.us-week-grid {
|
.us-week-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -249,6 +372,122 @@
|
|||||||
.us-week-day {
|
.us-week-day {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A lesson row carries a title, a date/time, a status pill and a button —
|
||||||
|
* more than fits one narrow line, so stack the details above the actions
|
||||||
|
* rather than letting them wrap into each other.
|
||||||
|
*/
|
||||||
|
#us-booking-app .us-my-lesson {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#us-booking-app .us-my-lesson-info {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Who is this for?" picker — booking and enrolment. Present only on an account
|
||||||
|
* that books for more than one person, so it is styled as a normal field rather
|
||||||
|
* than a callout.
|
||||||
|
*/
|
||||||
|
.us-student-picker select {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Whose lesson a row in the upcoming panel is — only shown on a family account. */
|
||||||
|
.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-dob {
|
||||||
|
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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shown only in block-editor previews (see BlockPreview). */
|
/* Shown only in block-editor previews (see BlockPreview). */
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Availability form: keep the lesson-length choices honest.
|
||||||
|
*
|
||||||
|
* A window is stored as consecutive lesson-length slots, so one shorter than the
|
||||||
|
* chosen lesson length holds no slots at all and saves nothing. Picking 5:30–6:00
|
||||||
|
* PM while the length select sat on its default of 60 minutes used to do exactly
|
||||||
|
* that, silently. The server now rejects it with a message; this narrows the
|
||||||
|
* choices first so the mistake is hard to make.
|
||||||
|
*
|
||||||
|
* This is a convenience only — AvailabilityController and the REST endpoint both
|
||||||
|
* validate the same window server-side regardless of what happens here.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const form = document.getElementById('usc-add-availability');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const startEl = document.getElementById('start_dt');
|
||||||
|
const endEl = document.getElementById('end_dt');
|
||||||
|
const durationEl = document.getElementById('duration_minutes');
|
||||||
|
const warningEl = document.getElementById('usc-duration-warning');
|
||||||
|
const submitEl = form.querySelector('input[type="submit"], button[type="submit"]');
|
||||||
|
|
||||||
|
if (!startEl || !endEl || !durationEl) return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes between the two datetime-local inputs, or 0 when the pair is not a
|
||||||
|
* usable window yet — empty, unparseable, backwards, or spanning two days
|
||||||
|
* (which the server rejects on its own terms, with its own message).
|
||||||
|
*/
|
||||||
|
function windowMinutes() {
|
||||||
|
const start = new Date(startEl.value);
|
||||||
|
const end = new Date(endEl.value);
|
||||||
|
|
||||||
|
if (!startEl.value || !endEl.value || isNaN(start) || isNaN(end)) return 0;
|
||||||
|
if (end <= start) return 0;
|
||||||
|
if (startEl.value.slice(0, 10) !== endEl.value.slice(0, 10)) return 0;
|
||||||
|
|
||||||
|
return Math.round((end - start) / 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
const minutes = windowMinutes();
|
||||||
|
const options = Array.from(durationEl.options);
|
||||||
|
|
||||||
|
// No usable window yet: leave every choice alone rather than fighting
|
||||||
|
// someone part-way through typing a date.
|
||||||
|
if (minutes === 0) {
|
||||||
|
options.forEach((option) => {
|
||||||
|
option.hidden = false;
|
||||||
|
option.disabled = false;
|
||||||
|
});
|
||||||
|
setBlocked(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fits = [];
|
||||||
|
|
||||||
|
options.forEach((option) => {
|
||||||
|
const tooLong = Number(option.value) > minutes;
|
||||||
|
|
||||||
|
option.hidden = tooLong;
|
||||||
|
option.disabled = tooLong;
|
||||||
|
|
||||||
|
if (!tooLong) fits.push(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fits.length === 0) {
|
||||||
|
// Nothing bookable fits, so the form cannot produce a single slot.
|
||||||
|
setBlocked(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBlocked(false);
|
||||||
|
|
||||||
|
// The selection may have just been hidden — fall back to the longest
|
||||||
|
// length that still fits, which is what the instructor most likely wants.
|
||||||
|
if (durationEl.selectedOptions[0] && durationEl.selectedOptions[0].disabled) {
|
||||||
|
durationEl.value = fits.reduce(
|
||||||
|
(longest, option) => (Number(option.value) > Number(longest.value) ? option : longest),
|
||||||
|
fits[0]
|
||||||
|
).value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBlocked(blocked) {
|
||||||
|
if (warningEl) warningEl.hidden = !blocked;
|
||||||
|
if (submitEl) submitEl.disabled = blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
startEl.addEventListener('change', refresh);
|
||||||
|
startEl.addEventListener('input', refresh);
|
||||||
|
endEl.addEventListener('change', refresh);
|
||||||
|
endEl.addEventListener('input', refresh);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}());
|
||||||
@@ -282,6 +282,28 @@
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'us-scheduler/family',
|
||||||
|
title: __('Family', 'unsupervised-schedular'),
|
||||||
|
description: __('Lets a parent or guardian add, edit and remove the children they book lessons for.', 'unsupervised-schedular'),
|
||||||
|
icon: 'groups',
|
||||||
|
keywords: ['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 }),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
blocks.forEach((def) => {
|
blocks.forEach((def) => {
|
||||||
|
|||||||
+73
-10
@@ -16,6 +16,11 @@
|
|||||||
const pinnedTypeId = Number(app.dataset.lessonType) || 0;
|
const pinnedTypeId = Number(app.dataset.lessonType) || 0;
|
||||||
const filterEnabled = app.dataset.typeFilter !== '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 = {}) {
|
function apiFetch(path, options = {}) {
|
||||||
return fetch(restUrl + path, {
|
return fetch(restUrl + path, {
|
||||||
...options,
|
...options,
|
||||||
@@ -360,13 +365,27 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
|
// "Piano Lesson (60 min — 50.00 CAD at booking)" / "Trial Lesson (Free)"
|
||||||
function offeringLabel(o) {
|
function offeringLabel(o) {
|
||||||
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
||||||
const price = Number(o.price) > 0
|
return `${o.title} (${duration}${window.usPricing.priceLabel(o)})`;
|
||||||
? `$${Number(o.price).toFixed(2)} ${o.currency}`
|
}
|
||||||
: 'Free';
|
|
||||||
return `${o.title} (${duration}${price})`;
|
// How many lessons a weekly reservation can claim, mirroring
|
||||||
|
// BookingEndpoint::MAX_WEEKLY_OCCURRENCES so the quoted total is never
|
||||||
|
// higher than the server will actually charge for.
|
||||||
|
const MAX_WEEKLY_OCCURRENCES = 12;
|
||||||
|
|
||||||
|
// The open times a weekly reservation of this slot would claim: every
|
||||||
|
// still-unbooked slot of its recurring group, capped the way the server
|
||||||
|
// caps it. Some may be taken by another student first, so this is the
|
||||||
|
// upper bound on what will be booked, not a guarantee.
|
||||||
|
function weeklyOccurrences(slot) {
|
||||||
|
if (!slot.recurrence_group) return 1;
|
||||||
|
|
||||||
|
const inGroup = allSlots.filter((s) => s.recurrence_group === slot.recurrence_group).length;
|
||||||
|
|
||||||
|
return Math.min(Math.max(inGroup, 1), MAX_WEEKLY_OCCURRENCES);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRegistration(slot) {
|
function openRegistration(slot) {
|
||||||
@@ -439,10 +458,12 @@
|
|||||||
<div class="us-register">
|
<div class="us-register">
|
||||||
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}</h3>
|
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}</h3>
|
||||||
<form id="us-register-form">
|
<form id="us-register-form">
|
||||||
|
${window.usGuardian.selectorHtml(students, 'us-booking-student')}
|
||||||
${offeringFieldHtml(tied, tiedId, choices)}
|
${offeringFieldHtml(tied, tiedId, choices)}
|
||||||
<div id="us-questions"></div>
|
<div id="us-questions"></div>
|
||||||
${policies.map(policyField).join('')}
|
${policies.map(policyField).join('')}
|
||||||
${weekly}
|
${weekly}
|
||||||
|
<div id="us-price-summary"></div>
|
||||||
<p>
|
<p>
|
||||||
<button type="submit" class="us-book-btn">Confirm Booking</button>
|
<button type="submit" class="us-book-btn">Confirm Booking</button>
|
||||||
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
|
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
|
||||||
@@ -458,6 +479,27 @@
|
|||||||
let questions = [];
|
let questions = [];
|
||||||
|
|
||||||
const questionsBox = document.getElementById('us-questions');
|
const questionsBox = document.getElementById('us-questions');
|
||||||
|
const priceBox = document.getElementById('us-price-summary');
|
||||||
|
const weeklyEl = document.getElementById('us-weekly');
|
||||||
|
|
||||||
|
// What the booking will cost and the agreement to pay it, restated
|
||||||
|
// whenever the choices that decide the amount change: the lesson type
|
||||||
|
// carries the price, and a weekly reservation multiplies a per-lesson
|
||||||
|
// one-time price by every week it claims. A slot tied to a type the
|
||||||
|
// catalog no longer carries has no price to quote, so it shows nothing
|
||||||
|
// rather than a figure it cannot stand behind.
|
||||||
|
function renderPrice() {
|
||||||
|
const offering = selectedId ? catalog.find((o) => Number(o.id) === selectedId) : null;
|
||||||
|
priceBox.innerHTML = offering
|
||||||
|
? window.usPricing.summaryHtml({
|
||||||
|
price: offering.price,
|
||||||
|
currency: offering.currency,
|
||||||
|
billing_mode: offering.billing_mode,
|
||||||
|
kind: offering.kind,
|
||||||
|
occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1,
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
function loadQuestions() {
|
function loadQuestions() {
|
||||||
questions = [];
|
questions = [];
|
||||||
@@ -475,10 +517,14 @@
|
|||||||
document.getElementById('us-offering').addEventListener('change', (e) => {
|
document.getElementById('us-offering').addEventListener('change', (e) => {
|
||||||
selectedId = Number(e.target.value) || 0;
|
selectedId = Number(e.target.value) || 0;
|
||||||
loadQuestions();
|
loadQuestions();
|
||||||
|
renderPrice();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (weeklyEl) weeklyEl.addEventListener('change', renderPrice);
|
||||||
|
|
||||||
loadQuestions();
|
loadQuestions();
|
||||||
|
renderPrice();
|
||||||
|
|
||||||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||||||
@@ -487,6 +533,10 @@
|
|||||||
showError('Please choose a lesson type.');
|
showError('Please choose a lesson type.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!window.usPricing.agreed(e.target)) {
|
||||||
|
showError(window.usPricing.AGREE_REQUIRED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
submitBooking(e.target, slot, selectedId, questions);
|
submitBooking(e.target, slot, selectedId, questions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -509,6 +559,7 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
slot_id: slot.id,
|
slot_id: slot.id,
|
||||||
offering_id: offeringId,
|
offering_id: offeringId,
|
||||||
|
student_id: window.usGuardian.selectedId('us-booking-student'),
|
||||||
recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single',
|
recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single',
|
||||||
answers,
|
answers,
|
||||||
accepted_policy_version_ids: accepted,
|
accepted_policy_version_ids: accepted,
|
||||||
@@ -535,19 +586,31 @@
|
|||||||
// How many upcoming lessons to show before the "Show all" reveal.
|
// How many upcoming lessons to show before the "Show all" reveal.
|
||||||
const INITIAL_LESSON_COUNT = 5;
|
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) {
|
function lessonRowHtml(l) {
|
||||||
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
|
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>` : '';
|
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
|
||||||
|
// The two columns are divs, not spans: as spans the layout only held up
|
||||||
|
// while the stylesheet's display:flex won, and a theme rule on span
|
||||||
|
// collapsed the row onto itself.
|
||||||
return `
|
return `
|
||||||
<div class="us-my-lesson">
|
<div class="us-my-lesson">
|
||||||
<span class="us-my-lesson-info">
|
<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>
|
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||||
</span>
|
</div>
|
||||||
<span class="us-my-lesson-actions">
|
<div class="us-my-lesson-actions">
|
||||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||||||
</span>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
// enrolment controls.
|
// enrolment controls.
|
||||||
const singleOfferingId = Number(app.dataset.offering || 0);
|
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 = {}) {
|
function apiFetch(path, options = {}) {
|
||||||
return fetch(restUrl + path, {
|
return fetch(restUrl + path, {
|
||||||
...options,
|
...options,
|
||||||
@@ -152,7 +156,7 @@
|
|||||||
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
|
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
|
||||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||||
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||||
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
|
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
|
||||||
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
||||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -202,8 +206,10 @@
|
|||||||
<div class="us-register">
|
<div class="us-register">
|
||||||
<h3>${escHtml(offering.title)}</h3>
|
<h3>${escHtml(offering.title)}</h3>
|
||||||
<form id="us-enrol-form">
|
<form id="us-enrol-form">
|
||||||
|
${window.usGuardian.selectorHtml(students, 'us-enrol-student')}
|
||||||
${questions.map(questionField).join('')}
|
${questions.map(questionField).join('')}
|
||||||
${policies.map(policyField).join('')}
|
${policies.map(policyField).join('')}
|
||||||
|
${window.usPricing.summaryHtml(offering)}
|
||||||
<p>
|
<p>
|
||||||
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
|
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
|
||||||
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
|
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
|
||||||
@@ -214,6 +220,10 @@
|
|||||||
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
|
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
|
||||||
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
|
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!window.usPricing.agreed(e.target)) {
|
||||||
|
showError(window.usPricing.AGREE_REQUIRED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
submitEnrolment(e.target, offering, questions);
|
submitEnrolment(e.target, offering, questions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -234,6 +244,7 @@
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
offering_id: offering.id,
|
offering_id: offering.id,
|
||||||
|
student_id: window.usGuardian.selectedId('us-enrol-student'),
|
||||||
answers,
|
answers,
|
||||||
accepted_policy_version_ids: accepted,
|
accepted_policy_version_ids: accepted,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* "Who is this for?" picker, shared by the lesson-booking and group-class
|
||||||
|
* registration forms.
|
||||||
|
*
|
||||||
|
* The list arrives from the server already ordered children-first, with the
|
||||||
|
* account holder last, and this module preserves that order: a guardian's
|
||||||
|
* default selection is their first child, never themselves. Booking for the
|
||||||
|
* wrong child is a correctable mistake; quietly enrolling the parent in a class
|
||||||
|
* meant for their kid is not.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the server-rendered student list off a `data-students` attribute.
|
||||||
|
* Anything unparseable degrades to an empty list, which renders no picker
|
||||||
|
* and books for the signed-in user — the pre-guardian behaviour.
|
||||||
|
*/
|
||||||
|
function parseStudents(raw) {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(raw);
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picker's markup, or an empty string when there is nothing to choose:
|
||||||
|
* an account with only itself on the list never sees the question.
|
||||||
|
*/
|
||||||
|
function selectorHtml(students, id) {
|
||||||
|
if (!students || students.length < 2) return '';
|
||||||
|
|
||||||
|
const options = students.map((s) => {
|
||||||
|
// The account holder reads as "Myself" — their own name next to their
|
||||||
|
// children's is ambiguous about which row is the parent.
|
||||||
|
const label = s.is_self ? `Myself (${s.name})` : s.name;
|
||||||
|
return `<option value="${Number(s.id)}">${escHtml(label)}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<p class="us-student-picker">
|
||||||
|
<label for="${id}">Who is this for?<br>
|
||||||
|
<select id="${id}" required>${options}</select></label>
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chosen student id, or 0 when no picker was rendered — the server
|
||||||
|
* reads 0 as "the caller books for themselves".
|
||||||
|
*/
|
||||||
|
function selectedId(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return el ? Number(el.value) || 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.usGuardian = { parseStudents, selectorHtml, selectedId };
|
||||||
|
}());
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/* global usScheduler */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Cadence wording for each offering billing mode, in the phrasing a student
|
||||||
|
// sees beside a price. Mirrors Offering::VALID_BILLING_MODES.
|
||||||
|
const CADENCE = {
|
||||||
|
one_time: 'at booking',
|
||||||
|
full_term: 'up front',
|
||||||
|
weekly: 'weekly',
|
||||||
|
monthly: 'monthly',
|
||||||
|
};
|
||||||
|
|
||||||
|
// How each cadence is actually collected, spelled out beneath the price so
|
||||||
|
// the one-word cadence is never the only thing a student has to go on.
|
||||||
|
const CADENCE_NOTE = {
|
||||||
|
one_time: 'Charged once, when you book.',
|
||||||
|
full_term: 'Charged once, up front, for the whole term.',
|
||||||
|
weekly: 'Charged for each lesson, 24 hours before it starts.',
|
||||||
|
monthly: 'Charged on the 1st of each month, for that month’s lessons.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The billing modes whose price is a per-lesson fee billed again and again,
|
||||||
|
// rather than a single charge. Mirrors Offering::SCHEDULED_BILLING_MODES.
|
||||||
|
const RECURRING = ['weekly', 'monthly'];
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mode(billingMode) {
|
||||||
|
return CADENCE[billingMode] ? billingMode : 'one_time';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A monthly charge rolls up every lesson that falls in the month, so a
|
||||||
|
// private lesson's monthly price is quoted *per lesson* — the fee is
|
||||||
|
// multiplied by the lessons booked that month. A group class is enrolled in
|
||||||
|
// once, as one schedule, so its monthly figure is quoted as it stands.
|
||||||
|
function isPerLessonMonthly(billingMode, kind) {
|
||||||
|
return 'monthly' === billingMode && 'group_class' !== kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "50.00 CAD" — amount then currency code, the format used throughout the
|
||||||
|
// ledger, receipts and payment notices.
|
||||||
|
function money(amount, currency) {
|
||||||
|
return `${(Number(amount) || 0).toFixed(2)} ${String(currency || '')}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The studio's HST rate as a percentage, frozen onto every payment at
|
||||||
|
// booking time (comped students are the one exception — they are not taxed).
|
||||||
|
function taxRate() {
|
||||||
|
return Number(usScheduler.taxRate) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tax on a pre-tax amount, rounded the same way PaymentService does.
|
||||||
|
function tax(amount) {
|
||||||
|
return Math.round((Number(amount) || 0) * taxRate()) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function total(amount) {
|
||||||
|
return (Number(amount) || 0) + tax(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "50.00 CAD at booking" / "50.00 CAD per lesson monthly" / "Free" — the
|
||||||
|
// catalogue label, always carrying the cadence so a price is never shown
|
||||||
|
// without saying when it is due.
|
||||||
|
function priceLabel(offering) {
|
||||||
|
const price = Number(offering.price) || 0;
|
||||||
|
if (price <= 0) {
|
||||||
|
return 'Free';
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingMode = mode(offering.billing_mode);
|
||||||
|
const perLesson = isPerLessonMonthly(billingMode, offering.kind) ? 'per lesson ' : '';
|
||||||
|
|
||||||
|
return `${money(price, offering.currency)} ${perLesson}${CADENCE[billingMode]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The price block shown on a booking/enrolment form, followed by the
|
||||||
|
// agreement the student must tick to confirm they will pay it. A free
|
||||||
|
// offering has nothing to agree to, so it renders nothing at all.
|
||||||
|
//
|
||||||
|
// opts: { price, currency, billing_mode, kind, occurrences }
|
||||||
|
// `occurrences` is how many lessons a one-time price is charged for in this
|
||||||
|
// one registration (a weekly reservation claims several at once); it is
|
||||||
|
// ignored for the other modes, whose price is charged per period regardless.
|
||||||
|
function summaryHtml(opts) {
|
||||||
|
const price = Number(opts.price) || 0;
|
||||||
|
if (price <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingMode = mode(opts.billing_mode);
|
||||||
|
const currency = opts.currency;
|
||||||
|
const each = total(price);
|
||||||
|
const count = 'one_time' === billingMode ? Math.max(1, Number(opts.occurrences) || 1) : 1;
|
||||||
|
|
||||||
|
const taxLine = taxRate() > 0
|
||||||
|
? `<p class="us-price-tax">${escHtml(`Plus ${taxRate()}% HST — ${money(each, currency)}${count > 1 ? ' per lesson' : ''}.`)}</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="us-price">
|
||||||
|
<h4>Price</h4>
|
||||||
|
<p class="us-price-amount">
|
||||||
|
<strong>${escHtml(money(price, currency))}</strong>
|
||||||
|
<span class="us-price-cadence">${escHtml(cadenceLabel(billingMode, opts.kind))}</span>
|
||||||
|
</p>
|
||||||
|
${taxLine}
|
||||||
|
<p class="us-price-note">${escHtml(count > 1
|
||||||
|
? 'Charged once, when you book — for every week reserved.'
|
||||||
|
: CADENCE_NOTE[billingMode])}</p>
|
||||||
|
<label class="us-price-agree">
|
||||||
|
<input type="checkbox" class="us-price-accept" required>
|
||||||
|
${escHtml(agreeText(each, currency, billingMode, count, opts.kind))}
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cadence as it reads beside an amount: a private lesson billed monthly
|
||||||
|
// adds "per lesson", since the month's charge is that fee times the lessons
|
||||||
|
// it covers.
|
||||||
|
function cadenceLabel(billingMode, kind) {
|
||||||
|
return isPerLessonMonthly(billingMode, kind)
|
||||||
|
? `per lesson ${CADENCE[billingMode]}`
|
||||||
|
: CADENCE[billingMode];
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the student is ticking: the amount actually billed (tax included),
|
||||||
|
// and when. A weekly reservation is charged per lesson for every week it
|
||||||
|
// claims, and the claim can come up short when another student takes one of
|
||||||
|
// the times first — so its total is stated as a ceiling, never a promise.
|
||||||
|
function agreeText(each, currency, billingMode, count, kind) {
|
||||||
|
if (RECURRING.indexOf(billingMode) !== -1) {
|
||||||
|
// A monthly group class is enrolled in once and quoted as it stands;
|
||||||
|
// everything else recurring is a per-lesson fee.
|
||||||
|
return 'monthly' === billingMode && !isPerLessonMonthly(billingMode, kind)
|
||||||
|
? `I agree to pay ${money(each, currency)} monthly.`
|
||||||
|
: `I agree to pay ${money(each, currency)} per lesson, billed ${CADENCE[billingMode]}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 1) {
|
||||||
|
return `I agree to pay ${money(each, currency)} per lesson at booking — `
|
||||||
|
+ `up to ${count} lessons, ${money(each * count, currency)} in total.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `I agree to pay ${money(each, currency)} ${CADENCE[billingMode]}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the payment agreement has been ticked. A form without one (a free
|
||||||
|
// offering) has nothing outstanding, so it counts as agreed.
|
||||||
|
function agreed(root) {
|
||||||
|
const box = root.querySelector('.us-price-accept');
|
||||||
|
|
||||||
|
return !box || box.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by the booking and group-class flows so a price reads the same
|
||||||
|
// wherever a student meets it.
|
||||||
|
window.usPricing = {
|
||||||
|
priceLabel,
|
||||||
|
summaryHtml,
|
||||||
|
agreed,
|
||||||
|
AGREE_REQUIRED: 'Please confirm you agree to pay the amount shown.',
|
||||||
|
};
|
||||||
|
}());
|
||||||
+112
-10
@@ -1,23 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* 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
|
* Two independent behaviours, both optional — without JS every panel stays
|
||||||
* (`[data-step="1"]` account details, `[data-step="2"]` the questions) inside a
|
* visible and the single submit still works:
|
||||||
* single form marked `data-steps="1"`. This script hides step two behind a
|
*
|
||||||
* "Next" button that only advances once step one passes native validation.
|
* 1. **Two steps.** When account-signup questions are configured the form
|
||||||
* Without JS both panels stay visible and the single submit still works.
|
* 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.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function enhance(form) {
|
function enhanceSteps(form) {
|
||||||
var step1 = form.querySelector('[data-step="1"]');
|
var step1 = form.querySelector('[data-step="1"]');
|
||||||
var step2 = form.querySelector('[data-step="2"]');
|
var step2 = form.querySelector('[data-step="2"]');
|
||||||
var next = form.querySelector('.us-reg-next');
|
var next = form.querySelector('.us-reg-next');
|
||||||
var back = form.querySelector('.us-reg-back');
|
var back = form.querySelector('.us-reg-back');
|
||||||
|
|
||||||
if (!step1 || !step2 || !next) {
|
if (!step1 || !step2 || !next) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(step) {
|
function show(step) {
|
||||||
@@ -45,13 +53,107 @@
|
|||||||
show(1);
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
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++) {
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
|
|||||||
The block's **After registration** panel picks the page a student continues to once
|
The block's **After registration** panel picks the page a student continues to once
|
||||||
registration finishes, and whether they get there by hand or automatically.
|
registration finishes, and whether they get there by hand or automatically.
|
||||||
|
|
||||||
- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the "Continue to your account" link an invited student sees on the spot (`?us_registered=invite`; no link at all with no page chosen, since an already-signed-in student has no use for the login screen).
|
- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the **"Continue to _<page title>_"** link every **logged-in** visitor gets (`RegistrationPage::continueLink()`): an invited student who just finished signing up (`?us_registered=invite`), and anyone who simply arrives at the registration page already signed in. The link names the chosen page (via `get_the_title()`) so the visitor knows where it goes; an untitled page falls back to "Continue to your account" rather than reading "Continue to ". Neither gets the WordPress-login-screen fallback — with no page chosen there is no link at all, since sending someone already signed in to the login screen is the same dead end with extra steps.
|
||||||
- **Redirect automatically** (`autoRedirect`, block only) — sends the student to that page instead of showing the link, via `BlockRegistrar::maybeAutoRedirect()` on `template_redirect`. It fires only on those two finished states (`RegistrationPage::isRegistrationComplete()`), so the "check your email" step, a validation error, and an `expired` confirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. See `editor-blocks.md`.
|
- **Redirect automatically** (`autoRedirect`, block only) — sends the student to that page instead of showing the link, via `BlockRegistrar::maybeAutoRedirect()` on `template_redirect`. It fires only on those two finished states (`RegistrationPage::isRegistrationComplete()`), so the "check your email" step, a validation error, and an `expired` confirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. See `editor-blocks.md`.
|
||||||
|
|
||||||
## Token Redirect
|
## Token Redirect
|
||||||
@@ -165,3 +165,12 @@ No-op when no registration page is set.
|
|||||||
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
|
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
|
||||||
- `tests/Unit/Auth/RegistrationMailerTest.php`
|
- `tests/Unit/Auth/RegistrationMailerTest.php`
|
||||||
- `tests/Unit/Payment/StudioSettingsTest.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, date of birth, and the
|
||||||
|
account-scope questions asked **per child**). Each child becomes a login-less
|
||||||
|
`us_student` user linked to the guardian, and the signup policies are recorded
|
||||||
|
once per child with the guardian as the acceptor. Available on every signup path
|
||||||
|
— personal invite, group link, and self-approval. See
|
||||||
|
`parent-guardian-accounts.md`.
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ A slot's `duration_minutes` is matched against the offering a student picks: a
|
|||||||
`AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive
|
`AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive
|
||||||
`duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists
|
`duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists
|
||||||
one row per chunk. A trailing remainder shorter than the lesson length is
|
one row per chunk. A trailing remainder shorter than the lesson length is
|
||||||
dropped. Windows must start and end on the same day and fit at least one lesson
|
dropped. Windows must start and end on the same day and fit at least one lesson;
|
||||||
(REST responds `400 invalid_window` otherwise; the admin form is a no-op).
|
both the REST endpoint and the admin form reject one that does not, with a
|
||||||
|
message saying so (see **REST API** below).
|
||||||
`AvailabilityRepository::splitOversizedWindows()` is a data migration (run by
|
`AvailabilityRepository::splitOversizedWindows()` is a data migration (run by
|
||||||
`Installer` on activation or version change) that rewrites pre-split rows.
|
`Installer` on activation or version change) that rewrites pre-split rows.
|
||||||
|
|
||||||
@@ -44,6 +45,27 @@ Instructors access **My Availability** in wp-admin (`?page=us-availability`).
|
|||||||
- Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level
|
- Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level
|
||||||
- Current slots can be shown as a **weekly calendar** (the default, navigated with `usc_week=Y-m-d`) or a **list** (`usc_view=list`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar`
|
- Current slots can be shown as a **weekly calendar** (the default, navigated with `usc_week=Y-m-d`) or a **list** (`usc_view=list`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar`
|
||||||
|
|
||||||
|
### Feedback
|
||||||
|
Every submitted action reports its outcome as a wp-admin notice — a success
|
||||||
|
notice naming the number of slots created or deleted, or an error explaining the
|
||||||
|
refusal. `AvailabilityController::handleFormAction()` returns a
|
||||||
|
`[$notice, $error]` pair that `templates/admin/availability.php` renders.
|
||||||
|
|
||||||
|
This matters because the form used to fail **silently**: a window shorter than
|
||||||
|
the chosen lesson length splits into no slots, so nothing was written, nothing
|
||||||
|
was said, and the page simply reloaded. Submitting 5:30–6:00 PM with the length
|
||||||
|
select on its 60-minute default was the reported case. Invalid datetimes, an end
|
||||||
|
before the start, a window spanning two days, and an offering belonging to
|
||||||
|
another instructor were all silent in the same way.
|
||||||
|
|
||||||
|
### Lesson-length choices
|
||||||
|
`assets/js/availability-admin.js` (enqueued by `AdminMenu::enqueueAssets()` on
|
||||||
|
this screen only) hides any lesson length longer than the entered window, falls
|
||||||
|
back to the longest one that still fits when the current pick is hidden, and
|
||||||
|
disables the submit button when nothing fits. It is a convenience, not a
|
||||||
|
guarantee — the server validates the same window regardless. The choices come
|
||||||
|
from `AvailabilitySlot::DURATION_CHOICES`.
|
||||||
|
|
||||||
## Public Calendar
|
## Public Calendar
|
||||||
The front-end booking shortcode renders open slots from `GET /availability`
|
The front-end booking shortcode renders open slots from `GET /availability`
|
||||||
either as an agenda-style list grouped by day or as a **weekly calendar** with
|
either as an agenda-style list grouped by day or as a **weekly calendar** with
|
||||||
@@ -62,12 +84,29 @@ with the **Show Only** lesson-type filter — see `lesson-booking.md`.
|
|||||||
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
|
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
|
||||||
Slots whose start has already passed are never returned.
|
Slots whose start has already passed are never returned.
|
||||||
|
|
||||||
`POST` validates `start_dt`/`end_dt` (admin form and REST alike) via
|
`POST` runs every submitted window — admin form and REST alike — through
|
||||||
`AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML
|
`Availability\WindowValidator`, which returns either the window ready to persist
|
||||||
`datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`;
|
or a `WP_Error`. The REST endpoint returns that error directly (its `status`
|
||||||
anything else — or an end not after the start — is rejected (REST responds
|
data makes it a 400); the admin screen shows `get_error_message()` in a notice.
|
||||||
`400 invalid_datetime`; the admin form is a no-op). A valid window is stored as
|
Sharing one validator is deliberate: the two paths previously checked the same
|
||||||
|
rules separately, and the admin copy was both laxer (no offering-ownership
|
||||||
|
check) and mute (a bare `return` on every rejection).
|
||||||
|
|
||||||
|
| Rejection | Code |
|
||||||
|
|---|---|
|
||||||
|
| Start or end not a real datetime | `invalid_datetime` |
|
||||||
|
| End at or before the start | `invalid_datetime` |
|
||||||
|
| Window spans two days | `invalid_window` |
|
||||||
|
| Window shorter than the lesson length (so it holds no slots) | `invalid_window` |
|
||||||
|
| Offering missing, or owned by another instructor | `invalid_offering` |
|
||||||
|
|
||||||
|
`start_dt`/`end_dt` are normalised by `AvailabilitySlot::normalizeDateTime()`:
|
||||||
|
the canonical `Y-m-d H:i[:s]` and HTML `datetime-local` (`Y-m-d\TH:i[:s]`) forms
|
||||||
|
become `Y-m-d H:i:s`; anything else is rejected. A valid window is stored as
|
||||||
lesson-length slots and `201` returns `{ "ids": [...] }` for every row created.
|
lesson-length slots and `201` returns `{ "ids": [...] }` for every row created.
|
||||||
|
`weeks` is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES` in the
|
||||||
|
repository, so the form's `max` cannot be bypassed by posting directly. A write
|
||||||
|
that fails entirely returns `500 not_saved` rather than a `201` listing no ids.
|
||||||
|
|
||||||
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin
|
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin
|
||||||
lists.
|
lists.
|
||||||
@@ -78,6 +117,8 @@ lists.
|
|||||||
- Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar`
|
- Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar`
|
||||||
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
|
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
|
||||||
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
|
||||||
|
- Shared window validation: `Unsupervised\Schedular\Availability\WindowValidator`
|
||||||
|
- Admin form script: `assets/js/availability-admin.js`, enqueued by `AdminMenu::enqueueAssets()`
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- `tests/Unit/Availability/AvailabilityControllerTest.php`
|
- `tests/Unit/Availability/AvailabilityControllerTest.php`
|
||||||
@@ -85,3 +126,4 @@ lists.
|
|||||||
- `tests/Unit/Availability/AvailabilitySlotTest.php`
|
- `tests/Unit/Availability/AvailabilitySlotTest.php`
|
||||||
- `tests/Unit/Availability/AvailabilityEndpointTest.php`
|
- `tests/Unit/Availability/AvailabilityEndpointTest.php`
|
||||||
- `tests/Unit/Availability/WeekCalendarTest.php`
|
- `tests/Unit/Availability/WeekCalendarTest.php`
|
||||||
|
- `tests/Unit/Availability/WindowValidatorTest.php`
|
||||||
|
|||||||
@@ -121,3 +121,12 @@ refund of a shared payment.
|
|||||||
- `tests/Unit/Payment/PaymentTest.php` (`netDue`)
|
- `tests/Unit/Payment/PaymentTest.php` (`netDue`)
|
||||||
- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel)
|
- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel)
|
||||||
- `tests/Unit/Auth/StudentHistoryTest.php` (`creditBalance`, `credits`)
|
- `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`.
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ shows "You are enrolled in this class." instead of the Enrol button (the
|
|||||||
server would reject the duplicate with `409 already_enrolled` regardless — a
|
server would reject the duplicate with `409 already_enrolled` regardless — a
|
||||||
cancelled enrolment does not block re-enrolling).
|
cancelled enrolment does not block re-enrolling).
|
||||||
|
|
||||||
1. Student opens a group class from the offering catalog.
|
1. Student opens a group class from the offering catalog. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on.
|
||||||
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||||
3. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
3. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||||
4. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
4. The enrolment form restates the price (with HST) and requires a second, separate agreement to pay that amount before it will submit. See **Price Display and the Pay Agreement** in `payments.md`.
|
||||||
5. `POST /enrollments` creates the enrolment (`status = active`), records answers and policy acceptances, and links the payment — but only if the offering's `capacity` has not been reached.
|
5. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||||
6. On successful payment (or comp) a receipt is emailed.
|
6. `POST /enrollments` creates the enrolment (`status = active`), records answers and policy acceptances, and links the payment — but only if the offering's `capacity` has not been reached.
|
||||||
|
7. On successful payment (or comp) a receipt is emailed.
|
||||||
|
|
||||||
Capacity is enforced at enrolment time by counting `active` rows for the offering;
|
Capacity is enforced at enrolment time by counting `active` rows for the offering;
|
||||||
a class at capacity rejects further enrolments.
|
a class at capacity rejects further enrolments.
|
||||||
@@ -193,3 +194,9 @@ class becomes enrollable for them — they choose whether to enrol.
|
|||||||
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
|
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
|
||||||
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
||||||
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
|
- `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`.
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ Students register for a private lesson by choosing an offering, picking a time (
|
|||||||
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
||||||
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||||
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
6. Student is shown what the booking costs — the offering's price with its **cadence** (at booking / up front / weekly / monthly), plus HST — and must tick a second, separate agreement to pay that amount before the form will submit. A weekly reservation quotes the per-lesson fee and the ceiling on the total it can claim. A free offering shows no price block. See **Price Display and the Pay Agreement** in `payments.md`.
|
||||||
7. `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.
|
7. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
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. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||||
10. 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.
|
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.
|
||||||
|
|
||||||
## Lesson-Type Filter
|
## Lesson-Type Filter
|
||||||
Not every open slot can be booked as every private-lesson type — a slot tied to
|
Not every open slot can be booked as every private-lesson type — a slot tied to
|
||||||
@@ -153,6 +154,7 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
|||||||
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
|
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
|
||||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||||
|
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
|
||||||
|
|
||||||
> **Payment seam:** a priced booking is created with `status = pending` and its
|
> **Payment seam:** a priced booking is created with `status = pending` and its
|
||||||
> payment linked via `payment_id`; the lesson is confirmed when the payment is
|
> payment linked via `payment_id`; the lesson is confirmed when the payment is
|
||||||
@@ -160,6 +162,14 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
|||||||
> Unpriced bookings skip the seam entirely and are confirmed at creation.
|
> Unpriced bookings skip the seam entirely and are confirmed at creation.
|
||||||
> `GET /policies?scope=booking` returns just the booking-gate policies the form
|
> `GET /policies?scope=booking` returns just the booking-gate policies the form
|
||||||
> must collect.
|
> must collect.
|
||||||
|
>
|
||||||
|
> **Frontend CSS scoping:** every rule for the booking page's own markup is
|
||||||
|
> written under `#us-booking-app` (`assets/css/frontend.css`). These panels sit
|
||||||
|
> inside whatever layout the active theme provides, and bare class selectors lose
|
||||||
|
> to theme rules on `div`/`span`/`strong` — which flattens the flex layout and
|
||||||
|
> renders the lesson details on top of the actions. The row's two columns are
|
||||||
|
> `div`s for the same reason: the layout must not depend on overriding the
|
||||||
|
> inline default. New booking-page rules should follow both conventions.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
||||||
@@ -167,3 +177,12 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
|||||||
- `tests/Unit/Booking/LessonControllerTest.php`
|
- `tests/Unit/Booking/LessonControllerTest.php`
|
||||||
- `tests/Unit/Booking/LessonDetailTest.php`
|
- `tests/Unit/Booking/LessonDetailTest.php`
|
||||||
- `tests/Unit/Booking/BookingEndpointTest.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`.
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
|||||||
- `one_time` — charged once at booking (a single private lesson).
|
- `one_time` — charged once at booking (a single private lesson).
|
||||||
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
||||||
- `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
|
- `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
|
||||||
- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee).
|
- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for that month. A **private lesson**'s price is a per-lesson fee, so the month is billed (#lessons in the month) × fee; a **group class**'s price is the monthly fee itself, billed once for the month however many times the class meets in it.
|
||||||
|
|
||||||
|
Students see the mode as a **cadence** beside every price on the front end — *at
|
||||||
|
booking*, *up front*, *weekly*, *monthly* — and confirm it explicitly before a
|
||||||
|
booking or enrolment goes through. See **Price Display and the Pay Agreement** in
|
||||||
|
`payments.md`.
|
||||||
|
|
||||||
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
|
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
|
||||||
booking/enrolment succeeds with no payment step, and payments are created later by the
|
booking/enrolment succeeds with no payment step, and payments are created later by the
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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 date of birth is the `us_date_of_birth`
|
||||||
|
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.
|
||||||
|
|
||||||
|
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)
|
||||||
|
- **Date of birth** (optional, `us_date_of_birth` meta)
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
Order of operations in `RegistrationPage::handleSubmit()`:
|
||||||
|
|
||||||
|
1. Validate the guardian's own fields (email, password, policies).
|
||||||
|
2. Validate **every** child block — a missing child name 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.
|
||||||
|
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: **Family**) renders the guardian's manage-children screen:
|
||||||
|
list the children, add one, edit a name/date of birth, 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_date_of_birth`.
|
||||||
|
- **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 **Guardian / Children** 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 **Family** 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`.
|
||||||
@@ -98,6 +98,59 @@ After booking, the destination on a payment can be corrected per booking:
|
|||||||
| `created_at` | DATETIME | Insertion time |
|
| `created_at` | DATETIME | Insertion time |
|
||||||
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
||||||
|
|
||||||
|
## Price Display and the Pay Agreement
|
||||||
|
Every price a student is shown on the front end carries its **cadence** — the
|
||||||
|
offering's `billing_mode` in the words the student needs:
|
||||||
|
|
||||||
|
| `billing_mode` | Shown as | Explained beneath as |
|
||||||
|
|----------------|-----------------------------------|------------------------------------------------------------|
|
||||||
|
| `one_time` | `at booking` | Charged once, when you book. |
|
||||||
|
| `full_term` | `up front` | Charged once, up front, for the whole term. |
|
||||||
|
| `weekly` | `weekly` | Charged for each lesson, 24 hours before it starts. |
|
||||||
|
| `monthly` | `per lesson monthly` / `monthly` | Charged on the 1st of each month, for that month's lessons.|
|
||||||
|
|
||||||
|
So a lesson type reads `50.00 CAD at booking` in the booking form's type picker,
|
||||||
|
and a group class card reads `120.00 CAD up front`. A free offering shows `Free`.
|
||||||
|
|
||||||
|
**`monthly` reads differently per offering kind, because it *bills* differently.**
|
||||||
|
A private lesson's price is a per-lesson fee and its monthly charge is that
|
||||||
|
month's lessons × the fee, so the fee is quoted **per lesson**
|
||||||
|
(`50.00 CAD per lesson monthly`). A monthly group class is priced **per month** —
|
||||||
|
`ScheduledBillingRunner::billGroupMonthly()` charges the fee once for the month
|
||||||
|
however many times the class meets in it — so its figure is quoted as it stands
|
||||||
|
(`120.00 CAD monthly`). The display split is `isPerLessonMonthly()` in
|
||||||
|
`assets/js/pricing.js`; the billing split is the one place the monthly rule
|
||||||
|
differs between the two kinds.
|
||||||
|
|
||||||
|
Before a booking or enrolment can be submitted, the form shows the price again as
|
||||||
|
a summary block with a **required agreement checkbox** — the second confirmation,
|
||||||
|
distinct from the policy acceptances above it:
|
||||||
|
|
||||||
|
> ☐ I agree to pay 56.50 CAD at booking.
|
||||||
|
|
||||||
|
The agreed figure is the amount actually billed, so the studio **HST rate** is
|
||||||
|
added to it (`usScheduler.taxRate`, localized from `us_hst_rate`) and broken out
|
||||||
|
above the checkbox — matching the total `Payment::total()` charges. A comped
|
||||||
|
student is not taxed and is not charged at all, so for them the quoted figure is
|
||||||
|
an upper bound. A free offering has nothing to agree to and shows no block.
|
||||||
|
|
||||||
|
Cadence-specific wording:
|
||||||
|
|
||||||
|
- **Weekly reservation of a `one_time` lesson type** — the fee is charged once per
|
||||||
|
week claimed, so the agreement states the per-lesson amount and the total as a
|
||||||
|
ceiling ("up to 12 lessons, 678.00 CAD in total"). The occurrence count mirrors
|
||||||
|
`BookingEndpoint::MAX_WEEKLY_OCCURRENCES`; a slot another student takes first is
|
||||||
|
simply not claimed, so the real charge can come in under it.
|
||||||
|
- **`weekly` / `monthly`** — nothing is taken at registration, so the agreement is
|
||||||
|
to the recurring charge: "I agree to pay 56.50 CAD per lesson, billed monthly."
|
||||||
|
A monthly **group class** agrees to its monthly figure instead ("I agree to pay
|
||||||
|
138.00 CAD monthly."), matching how its price is quoted on the card.
|
||||||
|
|
||||||
|
All of this lives in `assets/js/pricing.js` (`window.usPricing`), shared by the
|
||||||
|
booking and group-class flows so a price reads the same wherever it is met. The
|
||||||
|
script is registered as `us-scheduler-pricing` and is a dependency of both
|
||||||
|
`us-scheduler` and `us-scheduler-group`.
|
||||||
|
|
||||||
## Payment Flow
|
## Payment Flow
|
||||||
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
|
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
|
||||||
2. The browser confirms the card payment with Stripe.
|
2. The browser confirms the card payment with Stripe.
|
||||||
@@ -139,9 +192,19 @@ See `payment-reporting.md` for the monthly report and CSV export endpoints.
|
|||||||
- Receipts: `Unsupervised\Schedular\Payment\ReceiptMailer`
|
- Receipts: `Unsupervised\Schedular\Payment\ReceiptMailer`
|
||||||
- Settings page: `Unsupervised\Schedular\Payment\StudioSettings`
|
- Settings page: `Unsupervised\Schedular\Payment\StudioSettings`
|
||||||
- REST endpoint: `Unsupervised\Schedular\Payment\PaymentEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\Payment\PaymentEndpoint`
|
||||||
|
- Front-end price display + pay agreement: `assets/js/pricing.js` (`window.usPricing`), registered and localized with `taxRate` by `Unsupervised\Schedular\ShortcodeRegistrar`
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
- `tests/Unit/ShortcodeRegistrarTest.php` (pricing helper registration + localized `taxRate`)
|
||||||
- `tests/Unit/Payment/PaymentRepositoryTest.php`
|
- `tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||||
- `tests/Unit/Payment/PaymentTest.php`
|
- `tests/Unit/Payment/PaymentTest.php`
|
||||||
- `tests/Unit/Payment/StripeGatewayTest.php`
|
- `tests/Unit/Payment/StripeGatewayTest.php`
|
||||||
- `tests/Unit/Payment/ReceiptMailerTest.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`.
|
||||||
|
|||||||
@@ -40,14 +40,22 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
|
|||||||
|
|
||||||
## Versioning & Acceptance Rules
|
## Versioning & Acceptance Rules
|
||||||
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
|
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
|
||||||
|
- Editing a `draft` version rewrites it in place — nobody has accepted it yet, so there is nothing to preserve and no new version is created. `PATCH /policies/{id}/versions/{vid}` allows only this case; the admin page also accepts an edit to a `published` or `archived` version and branches a new draft from it.
|
||||||
- Publishing a draft sets it `published`, stamps `published_at`, archives the prior version, and points `us_policies.current_version_id` at it.
|
- Publishing a draft sets it `published`, stamps `published_at`, archives the prior version, and points `us_policies.current_version_id` at it.
|
||||||
- The registration gate requires acceptance of the `current_version_id` of every policy. Because acceptance is tied to `policy_version_id`, a newly published version is unaccepted and must be re-accepted at the student's next booking.
|
- The registration gate requires acceptance of the `current_version_id` of every policy. Because acceptance is tied to `policy_version_id`, a newly published version is unaccepted and must be re-accepted at the student's next booking.
|
||||||
|
|
||||||
## Admin Interface
|
## Admin Interface
|
||||||
**Policies** in wp-admin (`manage_policies`, studio admin only):
|
**Policies** in wp-admin (`manage_policies`, studio admin only):
|
||||||
- Create a policy; draft and edit version bodies
|
- Create a policy; draft version bodies
|
||||||
|
- View the content of any version (`?page=us-policies&policy_id={id}&version_id={vid}`), whatever its status
|
||||||
|
- Edit from the viewer: a draft is saved in place; editing a published or archived version instead saves the text as a **new draft version** (the viewer follows to it), so text students have already accepted is never rewritten
|
||||||
- Publish a draft version; view acceptance history per version
|
- Publish a draft version; view acceptance history per version
|
||||||
|
|
||||||
|
## Rendering a Policy Body
|
||||||
|
Bodies are typed into a plain textarea, so most are written as blank-line-separated prose with no markup. `PolicyVersion::bodyHtml()` is the single render path — `wp_kses_post()` then `wpautop()`, the same treatment WordPress gives post content — so unmarked-up text arrives as real paragraphs and bodies that do carry markup are left alone. It feeds the booking/enrolment JSON (`GET /policies`), the signup form, and the admin version viewer, which therefore previews exactly what students see.
|
||||||
|
|
||||||
|
The acceptance markup (`.us-policy` / `.us-policy-body`) is styled in `assets/css/frontend.css` as a bounded, vertically scrolling reading box with `overflow-wrap: break-word`, so a long policy or a pasted URL cannot force a horizontal scrollbar or push the accept checkbox out of view. `RegistrationPage` enqueues that stylesheet for the signup gate; `BookingPage` and `GroupClassPage` already did.
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
| Method | Endpoint | Permission |
|
| Method | Endpoint | Permission |
|
||||||
|----------|-----------------------------------------------------------------|-------------------|
|
|----------|-----------------------------------------------------------------|-------------------|
|
||||||
@@ -74,3 +82,11 @@ cover every policy's current version or the registration is rejected.
|
|||||||
- `tests/Unit/Policy/PolicyVersionRepositoryTest.php`
|
- `tests/Unit/Policy/PolicyVersionRepositoryTest.php`
|
||||||
- `tests/Unit/Policy/AcceptanceRepositoryTest.php`
|
- `tests/Unit/Policy/AcceptanceRepositoryTest.php`
|
||||||
- `tests/Unit/Policy/PolicyServiceTest.php`
|
- `tests/Unit/Policy/PolicyServiceTest.php`
|
||||||
|
- `tests/Unit/Policy/PolicyControllerTest.php`
|
||||||
|
- `tests/Unit/Policy/PolicyEndpointTest.php`
|
||||||
|
|
||||||
|
## Who Accepted
|
||||||
|
`us_policy_acceptances.accepted_by` records the person who actually ticked the
|
||||||
|
box, where that differs from `student_id` — a guardian agreeing on a child's
|
||||||
|
behalf. It defaults to 0, read back as "the student agreed for themselves"
|
||||||
|
(`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`.
|
||||||
|
|||||||
@@ -89,3 +89,12 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
|
|||||||
- `tests/Unit/Registration/AnswerTest.php`
|
- `tests/Unit/Registration/AnswerTest.php`
|
||||||
- `tests/Unit/Auth/RegistrationPageTest.php`
|
- `tests/Unit/Auth/RegistrationPageTest.php`
|
||||||
- `tests/Unit/Auth/StudentHistoryTest.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`.
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ payments on a recurring schedule:
|
|||||||
|
|
||||||
- **`weekly`** — one payment per lesson, due **24 hours before** that lesson.
|
- **`weekly`** — one payment per lesson, due **24 hours before** that lesson.
|
||||||
- **`monthly`** — one payment per calendar month, due on the **1st**, covering every
|
- **`monthly`** — one payment per calendar month, due on the **1st**, covering every
|
||||||
lesson that falls in the month (4 lessons ⇒ 4 × fee).
|
lesson that falls in the month. A **private lesson**'s fee is per lesson, so the
|
||||||
|
month costs (#lessons) × fee. A **group class**'s fee is per month: the class is
|
||||||
|
billed that fee once for the month, however many times it meets in it.
|
||||||
|
|
||||||
Both apply to **private lessons** and **group classes**. At registration the
|
Both apply to **private lessons** and **group classes**. At registration the
|
||||||
booking/enrolment succeeds with `payment: null` (no payment step); the lesson is
|
booking/enrolment succeeds with `payment: null` (no payment step); the lesson is
|
||||||
@@ -29,7 +31,7 @@ method resolution, e-transfer freezing, comp auto-pay reused) with a `due_date`
|
|||||||
| **Private weekly** | lesson `start_dt` ≤ now + 24h | 1 × fee | `us_lessons.payment_id` set on the lesson |
|
| **Private weekly** | lesson `start_dt` ≤ now + 24h | 1 × fee | `us_lessons.payment_id` set on the lesson |
|
||||||
| **Private monthly** | the lesson's month's 1st ≤ today | (#lessons in month) × fee | `payment_id` set on every lesson in the month |
|
| **Private monthly** | the lesson's month's 1st ≤ today | (#lessons in month) × fee | `payment_id` set on every lesson in the month |
|
||||||
| **Group weekly** | session (from `Offering::sessionWindows()`) − 1 day ≤ now | 1 × fee | `us_payments.period_key` = session date |
|
| **Group weekly** | session (from `Offering::sessionWindows()`) − 1 day ≤ now | 1 × fee | `us_payments.period_key` = session date |
|
||||||
| **Group monthly** | the month's 1st ≤ today | (#sessions in month) × fee | `period_key` = `YYYY-MM` |
|
| **Group monthly** | the month's 1st ≤ today | 1 × fee (a monthly class is priced per month, not per session) | `period_key` = `YYYY-MM` |
|
||||||
|
|
||||||
- Private lessons dedup on `us_lessons.payment_id IS NULL` — a lesson with no payment is
|
- Private lessons dedup on `us_lessons.payment_id IS NULL` — a lesson with no payment is
|
||||||
unbilled. A monthly group links its earliest lesson via `createForRegistration` and the
|
unbilled. A monthly group links its earliest lesson via `createForRegistration` and the
|
||||||
@@ -92,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/PaymentRepositoryTest.php` (`existsForPeriod`, `due_date`/`period_key`)
|
||||||
- `tests/Unit/Payment/PaymentServiceTest.php` (`voidPending` skips scheduled)
|
- `tests/Unit/Payment/PaymentServiceTest.php` (`voidPending` skips scheduled)
|
||||||
- `tests/Unit/Booking/BookingEndpointTest.php` / `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (deferred payment)
|
- `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`.
|
||||||
|
|||||||
@@ -86,3 +86,10 @@ All actions are nonce-protected POSTs handled on the detail page:
|
|||||||
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
|
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
|
||||||
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
|
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
|
||||||
`tests/Unit/Payment/PaymentRepositoryTest.php`
|
`tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||||
|
|
||||||
|
## Family Relationships
|
||||||
|
The students list gains a **Family** column — a child links to their guardian,
|
||||||
|
a guardian lists their children — and the student screen a **Family** panel. A
|
||||||
|
child's listed email is their guardian's, since a child's own address is an
|
||||||
|
undeliverable placeholder, and the credit balance shown is the payer's, labelled
|
||||||
|
with whose account holds it. See `parent-guardian-accounts.md`.
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ includes:
|
|||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
level: 10
|
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:
|
paths:
|
||||||
- src
|
- src
|
||||||
bootstrapFiles:
|
bootstrapFiles:
|
||||||
|
|||||||
+32
-4
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
|
|||||||
|
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityController;
|
use Unsupervised\Schedular\Availability\AvailabilityController;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
|
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||||
use Unsupervised\Schedular\Auth\InstructorController;
|
use Unsupervised\Schedular\Auth\InstructorController;
|
||||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||||
@@ -14,6 +15,7 @@ use Unsupervised\Schedular\Auth\RegistrationMailer;
|
|||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Auth\StudentActions;
|
use Unsupervised\Schedular\Auth\StudentActions;
|
||||||
use Unsupervised\Schedular\Auth\StudentController;
|
use Unsupervised\Schedular\Auth\StudentController;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Auth\StudentHistory;
|
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\Booking\LessonController;
|
use Unsupervised\Schedular\Booking\LessonController;
|
||||||
@@ -42,6 +44,12 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
|
|||||||
|
|
||||||
class AdminMenu {
|
class AdminMenu {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook suffix of the availability screen, captured when the page is added so
|
||||||
|
* its script loads on that screen only.
|
||||||
|
*/
|
||||||
|
private string $availabilityHook = '';
|
||||||
|
|
||||||
private AvailabilityController $availabilityController;
|
private AvailabilityController $availabilityController;
|
||||||
private LessonController $lessonController;
|
private LessonController $lessonController;
|
||||||
private OfferingController $offeringController;
|
private OfferingController $offeringController;
|
||||||
@@ -57,8 +65,8 @@ class AdminMenu {
|
|||||||
private PaymentController $paymentController;
|
private PaymentController $paymentController;
|
||||||
private PaymentReportController $paymentReportController;
|
private PaymentReportController $paymentReportController;
|
||||||
|
|
||||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits ) {
|
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 );
|
$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->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
|
||||||
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
|
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
|
||||||
$this->questionController = new QuestionController( $questions, $offerings );
|
$this->questionController = new QuestionController( $questions, $offerings );
|
||||||
@@ -66,7 +74,7 @@ class AdminMenu {
|
|||||||
$this->registrationController = new RegistrationController( $invites );
|
$this->registrationController = new RegistrationController( $invites );
|
||||||
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
|
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
|
||||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
|
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
|
||||||
$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->instructorController = new InstructorController();
|
||||||
$this->settings = $settings;
|
$this->settings = $settings;
|
||||||
$this->accessSettings = new AccessSettings();
|
$this->accessSettings = new AccessSettings();
|
||||||
@@ -76,9 +84,29 @@ class AdminMenu {
|
|||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
add_action( 'admin_menu', [ $this, 'addPages' ] );
|
add_action( 'admin_menu', [ $this, 'addPages' ] );
|
||||||
|
add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAssets' ] );
|
||||||
add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] );
|
add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a screen's script on that screen only.
|
||||||
|
*
|
||||||
|
* @param string $hookSuffix Screen the enqueue is running for.
|
||||||
|
*/
|
||||||
|
public function enqueueAssets( string $hookSuffix ): void {
|
||||||
|
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_enqueue_script(
|
||||||
|
'us-scheduler-availability-admin',
|
||||||
|
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
|
||||||
|
[],
|
||||||
|
USC_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function addPages(): void {
|
public function addPages(): void {
|
||||||
$this->addStudioSeparators();
|
$this->addStudioSeparators();
|
||||||
|
|
||||||
@@ -94,7 +122,7 @@ class AdminMenu {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Instructor: manage their own availability.
|
// Instructor: manage their own availability.
|
||||||
add_menu_page(
|
$this->availabilityHook = (string) add_menu_page(
|
||||||
__( 'My Availability', 'unsupervised-schedular' ),
|
__( 'My Availability', 'unsupervised-schedular' ),
|
||||||
__( 'My Availability', 'unsupervised-schedular' ),
|
__( 'My Availability', 'unsupervised-schedular' ),
|
||||||
RoleManager::CAP_MANAGE_AVAILABILITY,
|
RoleManager::CAP_MANAGE_AVAILABILITY,
|
||||||
|
|||||||
+179
-21
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\Auth;
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||||
use Unsupervised\Schedular\Policy\Policy;
|
use Unsupervised\Schedular\Policy\Policy;
|
||||||
@@ -47,6 +48,7 @@ class RegistrationPage {
|
|||||||
private QuestionRepository $questions,
|
private QuestionRepository $questions,
|
||||||
private AnswerRepository $answers,
|
private AnswerRepository $answers,
|
||||||
private GroupAccessRepository $access,
|
private GroupAccessRepository $access,
|
||||||
|
private GuardianService $guardians,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,24 +67,21 @@ class RegistrationPage {
|
|||||||
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
||||||
|
|
||||||
if ( is_user_logged_in() ) {
|
if ( is_user_logged_in() ) {
|
||||||
if ( self::RESULT_INVITE === $registered ) {
|
// Both logged-in outcomes are dead ends without somewhere to go next,
|
||||||
// An invited student is done the moment they land here logged in,
|
// so both offer the same "continue" link to the configured page.
|
||||||
// so this is where their "continue" link belongs. The sign-in-page
|
wp_enqueue_style( 'us-scheduler' );
|
||||||
// fallback is deliberately not used: pointing someone who is
|
$link = $this->continueLink( $atts );
|
||||||
// already signed in at the login screen helps nobody.
|
|
||||||
$continue = $this->continueUrl( $this->successPageId( $atts ) );
|
|
||||||
$link = null === $continue
|
|
||||||
? ''
|
|
||||||
: '<p><a href="' . esc_url( $continue ) . '">'
|
|
||||||
. esc_html__( 'Continue to your account', 'unsupervised-schedular' )
|
|
||||||
. '</a></p>';
|
|
||||||
|
|
||||||
|
if ( self::RESULT_INVITE === $registered ) {
|
||||||
|
// An invited student is done the moment they land here logged in.
|
||||||
return '<div class="us-register-form"><p class="us-success">'
|
return '<div class="us-register-form"><p class="us-success">'
|
||||||
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
|
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
|
||||||
. '</p>' . $link . '</div>';
|
. '</p>' . $link . '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
|
return '<div class="us-register-form"><p>'
|
||||||
|
. esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' )
|
||||||
|
. '</p>' . $link . '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
|
||||||
@@ -116,8 +115,14 @@ class RegistrationPage {
|
|||||||
$canRegister = $open || $inviteValid;
|
$canRegister = $open || $inviteValid;
|
||||||
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
||||||
|
|
||||||
// The two-step script only matters when there is a second step to reveal.
|
// The signup form carries the same policy-acceptance markup as the booking
|
||||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
// gate, so it needs the plugin stylesheet that formats it.
|
||||||
|
wp_enqueue_style( 'us-scheduler' );
|
||||||
|
|
||||||
|
// 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' );
|
wp_enqueue_script( 'us-scheduler-register' );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,14 +274,28 @@ 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 );
|
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||||
$answers = $this->submittedAnswers();
|
|
||||||
|
|
||||||
|
// 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 child, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
|
||||||
foreach ( $accountQuestions as $question ) {
|
foreach ( $accountQuestions as $question ) {
|
||||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
|
||||||
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
return $isGuardian
|
||||||
|
? esc_html__( 'Please answer all required registration questions for each child.', 'unsupervised-schedular' )
|
||||||
|
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,8 +317,16 @@ class RegistrationPage {
|
|||||||
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->recordAcceptances( $policyForms, (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 );
|
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||||
|
}
|
||||||
|
|
||||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||||
@@ -349,6 +376,40 @@ class RegistrationPage {
|
|||||||
return $this->continueUrl( $loginPageId ) ?? wp_login_url();
|
return $this->continueUrl( $loginPageId ) ?? wp_login_url();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "continue" paragraph shown to a logged-in visitor, or an empty string
|
||||||
|
* when no destination page is configured. The link names the chosen page, so
|
||||||
|
* the visitor knows where it goes before clicking; an untitled page falls
|
||||||
|
* back to generic wording rather than reading "Continue to ".
|
||||||
|
*
|
||||||
|
* The sign-in-page fallback {@see loginUrl()} applies is deliberately not
|
||||||
|
* used here: pointing someone who is already signed in at the login screen is
|
||||||
|
* the same dead end with extra steps, so no link is better than that one.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $atts
|
||||||
|
*/
|
||||||
|
private function continueLink( array $atts ): string {
|
||||||
|
$pageId = $this->successPageId( $atts );
|
||||||
|
$continue = $this->continueUrl( $pageId );
|
||||||
|
|
||||||
|
if ( null === $continue ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = trim( Val::string( get_the_title( $pageId ) ) );
|
||||||
|
$label = '' === $title
|
||||||
|
? esc_html__( 'Continue to your account', 'unsupervised-schedular' )
|
||||||
|
: esc_html(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: title of the page the student continues to. */
|
||||||
|
__( 'Continue to %s', 'unsupervised-schedular' ),
|
||||||
|
$title
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return '<p><a href="' . esc_url( $continue ) . '">' . $label . '</a></p>';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chosen post-registration page's URL, or null when none is configured
|
* The chosen post-registration page's URL, or null when none is configured
|
||||||
* (or it has since been deleted). Unlike {@see loginUrl()} this has no
|
* (or it has since been deleted). Unlike {@see loginUrl()} this has no
|
||||||
@@ -405,6 +466,96 @@ class RegistrationPage {
|
|||||||
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
|
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|dob|answers]`. Blocks with no name are dropped rather
|
||||||
|
* than rejected — the form always renders one spare block for "add another",
|
||||||
|
* and an untouched spare is not a mistake the guardian needs telling about.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, dob: 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 = sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) );
|
||||||
|
if ( '' === trim( $name ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$answers = [];
|
||||||
|
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
|
||||||
|
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'dob' => sanitize_text_field( Val::string( wp_unslash( $child['dob'] ?? '' ) ) ),
|
||||||
|
'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, dob: 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['dob'] );
|
||||||
|
|
||||||
|
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.
|
* The account-question answers submitted with the form, keyed by question id.
|
||||||
*
|
*
|
||||||
@@ -454,9 +605,15 @@ class RegistrationPage {
|
|||||||
/**
|
/**
|
||||||
* Record account-time acceptances for each signup policy version.
|
* 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
|
* @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.
|
// 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'] ?? '' ) ) );
|
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||||
|
|
||||||
@@ -467,6 +624,7 @@ class RegistrationPage {
|
|||||||
studentId: $userId,
|
studentId: $userId,
|
||||||
registrationType: PolicyAcceptance::REG_ACCOUNT,
|
registrationType: PolicyAcceptance::REG_ACCOUNT,
|
||||||
registrationId: $userId,
|
registrationId: $userId,
|
||||||
|
acceptedBy: $acceptedBy,
|
||||||
ipAddress: '' !== $ip ? $ip : null,
|
ipAddress: '' !== $ip ? $ip : null,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Booking\BookingRepository;
|
|||||||
use Unsupervised\Schedular\Booking\Lesson;
|
use Unsupervised\Schedular\Booking\Lesson;
|
||||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||||
use Unsupervised\Schedular\Payment\Payment;
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
@@ -23,6 +24,7 @@ class StudentController {
|
|||||||
private BillingMethodResolver $resolver,
|
private BillingMethodResolver $resolver,
|
||||||
private StudentHistory $history,
|
private StudentHistory $history,
|
||||||
private StudentActions $actions,
|
private StudentActions $actions,
|
||||||
|
private GuardianService $guardians,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function renderPage(): void {
|
public function renderPage(): void {
|
||||||
@@ -43,10 +45,14 @@ class StudentController {
|
|||||||
fn( \WP_User $user ): array => [
|
fn( \WP_User $user ): array => [
|
||||||
'id' => (int) $user->ID,
|
'id' => (int) $user->ID,
|
||||||
'name' => $user->display_name,
|
'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,
|
'registered' => $user->user_registered,
|
||||||
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
|
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
|
||||||
'enrolments' => $this->enrollments->countActiveForStudent( (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(
|
array_filter(
|
||||||
get_users(
|
get_users(
|
||||||
@@ -150,10 +156,19 @@ class StudentController {
|
|||||||
$intake = $this->history->intakeAnswers( (int) $student->ID );
|
$intake = $this->history->intakeAnswers( (int) $student->ID );
|
||||||
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
|
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
|
||||||
$credits = $canBilling ? $this->history->credits( (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 );
|
$creditCurrency = $this->creditCurrency( $credits );
|
||||||
|
|
||||||
|
// 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' );
|
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||||
|
$pageSlug = 'us-students';
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
|
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class AvailabilityController {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private AvailabilityRepository $repository,
|
private AvailabilityRepository $repository,
|
||||||
private OfferingRepository $offerings,
|
private OfferingRepository $offerings,
|
||||||
|
private WindowValidator $validator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function renderPage(): void {
|
public function renderPage(): void {
|
||||||
@@ -21,9 +22,11 @@ class AvailabilityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$instructorId = get_current_user_id();
|
$instructorId = get_current_user_id();
|
||||||
|
$notice = '';
|
||||||
|
$error = '';
|
||||||
|
|
||||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
|
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
|
||||||
$this->handleFormAction( $instructorId );
|
[ $notice, $error ] = $this->handleFormAction( $instructorId );
|
||||||
}
|
}
|
||||||
|
|
||||||
$slots = $this->repository->findByInstructor( $instructorId );
|
$slots = $this->repository->findByInstructor( $instructorId );
|
||||||
@@ -44,17 +47,27 @@ class AvailabilityController {
|
|||||||
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
|
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleFormAction( int $instructorId ): void {
|
/**
|
||||||
|
* Run the submitted action and report what happened. Every branch returns a
|
||||||
|
* message: a form that silently reloads leaves the instructor unable to tell
|
||||||
|
* "saved 41 slots" from "saved nothing".
|
||||||
|
*
|
||||||
|
* @return array{string, string} Success notice and error message; each is
|
||||||
|
* empty when it does not apply.
|
||||||
|
*/
|
||||||
|
private function handleFormAction( int $instructorId ): array {
|
||||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||||
|
|
||||||
if ( 'add' === $action ) {
|
if ( 'add' === $action ) {
|
||||||
$this->addSlot( $instructorId );
|
return $this->addSlot( $instructorId );
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( 'delete' === $action ) {
|
if ( 'delete' === $action ) {
|
||||||
$this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId );
|
return $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId )
|
||||||
|
? [ __( 'Availability slot deleted.', 'unsupervised-schedular' ), '' ]
|
||||||
|
: [ '', __( 'That slot could not be deleted. It may already be booked, or belong to someone else.', 'unsupervised-schedular' ) ];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( 'bulk_delete' === $action ) {
|
if ( 'bulk_delete' === $action ) {
|
||||||
@@ -62,54 +75,116 @@ class AvailabilityController {
|
|||||||
// absint-sanitized individually below.
|
// absint-sanitized individually below.
|
||||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||||
$rawIds = $_POST['slot_ids'] ?? [];
|
$rawIds = $_POST['slot_ids'] ?? [];
|
||||||
|
$deleted = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
|
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
|
||||||
$this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId );
|
if ( $this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId ) ) {
|
||||||
|
++$deleted;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
++$failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->bulkDeleteResult( $deleted, $failed );
|
||||||
}
|
}
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
|
||||||
|
return [ '', '' ];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wording for a bulk delete, which can partly succeed.
|
||||||
|
*
|
||||||
|
* @return array{string, string}
|
||||||
|
*/
|
||||||
|
private function bulkDeleteResult( int $deleted, int $failed ): array {
|
||||||
|
$notice = $deleted > 0
|
||||||
|
? sprintf(
|
||||||
|
/* translators: %d: number of availability slots deleted. */
|
||||||
|
_n( '%d slot deleted.', '%d slots deleted.', $deleted, 'unsupervised-schedular' ),
|
||||||
|
$deleted
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$error = $failed > 0
|
||||||
|
? sprintf(
|
||||||
|
/* translators: %d: number of slots that could not be deleted. */
|
||||||
|
_n(
|
||||||
|
'%d slot could not be deleted — it may already be booked.',
|
||||||
|
'%d slots could not be deleted — they may already be booked.',
|
||||||
|
$failed,
|
||||||
|
'unsupervised-schedular'
|
||||||
|
),
|
||||||
|
$failed
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if ( 0 === $deleted && 0 === $failed ) {
|
||||||
|
$error = __( 'No slots were selected.', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
|
|
||||||
|
return [ $notice, $error ];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a slot only when it exists and belongs to the given instructor.
|
* Delete a slot only when it exists and belongs to the given instructor.
|
||||||
* The repository additionally refuses to delete booked slots.
|
* The repository additionally refuses to delete booked slots. Returns whether
|
||||||
|
* the row actually went away.
|
||||||
*/
|
*/
|
||||||
private function deleteOwnSlot( int $slotId, int $instructorId ): void {
|
private function deleteOwnSlot( int $slotId, int $instructorId ): bool {
|
||||||
if ( $slotId <= 0 ) {
|
if ( $slotId <= 0 ) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$slot = $this->repository->findById( $slotId );
|
$slot = $this->repository->findById( $slotId );
|
||||||
if ( $slot && $slot->instructorId === $instructorId ) {
|
|
||||||
$this->repository->delete( $slotId );
|
if ( null === $slot || $slot->instructorId !== $instructorId ) {
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function addSlot( int $instructorId ): void {
|
return $this->repository->delete( $slotId );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and persist a submitted window.
|
||||||
|
*
|
||||||
|
* @return array{string, string}
|
||||||
|
*/
|
||||||
|
private function addSlot( int $instructorId ): array {
|
||||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||||
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
|
$window = $this->validator->validate(
|
||||||
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
|
$instructorId,
|
||||||
|
sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ),
|
||||||
// A window must start and end on the same day (weekly repeat covers longer
|
sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ),
|
||||||
// ranges) and fit at least one lesson; it is stored as lesson-length slots.
|
absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ),
|
||||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt || substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
|
||||||
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
|
||||||
|
|
||||||
$window = new AvailabilitySlot(
|
|
||||||
instructorId: $instructorId,
|
|
||||||
startDt: $startDt,
|
|
||||||
endDt: $endDt,
|
|
||||||
durationMinutes: $duration > 0 ? $duration : 60,
|
|
||||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ( $window instanceof \WP_Error ) {
|
||||||
|
return [ '', $window->get_error_message() ];
|
||||||
|
}
|
||||||
|
|
||||||
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) );
|
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) );
|
||||||
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) );
|
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) );
|
||||||
|
|
||||||
$this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
|
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
|
||||||
|
$ids = $this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
|
||||||
|
|
||||||
|
// The window was valid, so it split into at least one slot — an empty
|
||||||
|
// result means every insert failed.
|
||||||
|
if ( [] === $ids ) {
|
||||||
|
return [ '', __( 'The availability could not be saved. Please try again.', 'unsupervised-schedular' ) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: number of bookable slots created. */
|
||||||
|
_n( 'Added %d bookable slot.', 'Added %d bookable slots.', count( $ids ), 'unsupervised-schedular' ),
|
||||||
|
count( $ids )
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\Availability;
|
namespace Unsupervised\Schedular\Availability;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
|
||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
class AvailabilityEndpoint {
|
class AvailabilityEndpoint {
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private AvailabilityRepository $repository,
|
private AvailabilityRepository $repository,
|
||||||
private OfferingRepository $offerings,
|
private WindowValidator $validator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,40 +112,18 @@ class AvailabilityEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||||
$instructorId = get_current_user_id();
|
// Validation lives in WindowValidator so this endpoint and the admin form
|
||||||
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
|
// enforce exactly the same rules.
|
||||||
$duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) );
|
$window = $this->validator->validate(
|
||||||
|
get_current_user_id(),
|
||||||
// A slot may only be tied to an offering the instructor owns, so it can
|
Val::string( $request->get_param( 'start_dt' ) ),
|
||||||
// never inherit another instructor's price or payment routing at booking.
|
Val::string( $request->get_param( 'end_dt' ) ),
|
||||||
if ( $offeringId > 0 ) {
|
absint( Val::int( $request->get_param( 'duration_minutes' ) ) ),
|
||||||
$offering = $this->offerings->findById( $offeringId );
|
absint( Val::int( $request->get_param( 'offering_id' ) ) ),
|
||||||
if ( null === $offering || $offering->instructorId !== $instructorId ) {
|
|
||||||
return new \WP_Error( 'invalid_offering', __( 'That offering is not available.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$startDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'start_dt' ) ) );
|
|
||||||
$endDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'end_dt' ) ) );
|
|
||||||
|
|
||||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
|
|
||||||
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
|
||||||
return new \WP_Error( 'invalid_window', __( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
|
||||||
}
|
|
||||||
|
|
||||||
$window = new AvailabilitySlot(
|
|
||||||
instructorId: $instructorId,
|
|
||||||
startDt: $startDt,
|
|
||||||
endDt: $endDt,
|
|
||||||
durationMinutes: $duration > 0 ? $duration : 60,
|
|
||||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( [] === $window->splitByDuration() ) {
|
if ( $window instanceof \WP_Error ) {
|
||||||
return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
return $window;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ids = $this->repository->createFromWindow(
|
$ids = $this->repository->createFromWindow(
|
||||||
@@ -155,6 +132,12 @@ class AvailabilityEndpoint {
|
|||||||
absint( Val::int( $request->get_param( 'weeks' ) ) )
|
absint( Val::int( $request->get_param( 'weeks' ) ) )
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A valid window splits into at least one slot, so nothing written means
|
||||||
|
// every insert failed.
|
||||||
|
if ( [] === $ids ) {
|
||||||
|
return new \WP_Error( 'not_saved', __( 'The availability could not be saved.', 'unsupervised-schedular' ), [ 'status' => 500 ] );
|
||||||
|
}
|
||||||
|
|
||||||
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ class AvailabilityRepository {
|
|||||||
$this->table = $db->prefix . 'us_availability';
|
$this->table = $db->prefix . 'us_availability';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert one slot row. Returns its id, or 0 when the write failed —
|
||||||
|
* `insert_id` still holds the *previous* statement's id after a failed
|
||||||
|
* insert, so returning it unconditionally made a failed write look like a
|
||||||
|
* successful one.
|
||||||
|
*/
|
||||||
public function insert( AvailabilitySlot $slot ): int {
|
public function insert( AvailabilitySlot $slot ): int {
|
||||||
$this->db->insert(
|
$written = $this->db->insert(
|
||||||
$this->table,
|
$this->table,
|
||||||
[
|
[
|
||||||
'instructor_id' => $slot->instructorId,
|
'instructor_id' => $slot->instructorId,
|
||||||
@@ -27,7 +33,7 @@ class AvailabilityRepository {
|
|||||||
[ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
[ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->db->insert_id;
|
return false === $written ? 0 : $this->db->insert_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +48,17 @@ class AvailabilityRepository {
|
|||||||
$ids = [];
|
$ids = [];
|
||||||
|
|
||||||
foreach ( $window->splitByDuration() as $slot ) {
|
foreach ( $window->splitByDuration() as $slot ) {
|
||||||
$ids = $weekly
|
if ( $weekly ) {
|
||||||
? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
|
$ids = array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) );
|
||||||
: [ ...$ids, $this->insert( $slot ) ];
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->insert( $slot );
|
||||||
|
|
||||||
|
// A failed insert returns 0; it must not reach the caller as an id.
|
||||||
|
if ( $id > 0 ) {
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ids;
|
return $ids;
|
||||||
@@ -55,10 +69,14 @@ class AvailabilityRepository {
|
|||||||
* separate row one week apart, all sharing a `recurrence_group` (the id of the
|
* separate row one week apart, all sharing a `recurrence_group` (the id of the
|
||||||
* first row).
|
* first row).
|
||||||
*
|
*
|
||||||
|
* The count is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES`. The
|
||||||
|
* form's `max` attribute says the same, but only this is binding — a
|
||||||
|
* hand-crafted POST used to be able to ask for an unbounded number of rows.
|
||||||
|
*
|
||||||
* @return list<int> Inserted slot IDs.
|
* @return list<int> Inserted slot IDs.
|
||||||
*/
|
*/
|
||||||
public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array {
|
public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array {
|
||||||
$occurrences = max( 1, $occurrences );
|
$occurrences = max( 1, min( AvailabilitySlot::MAX_WEEKLY_OCCURRENCES, $occurrences ) );
|
||||||
$start = new \DateTimeImmutable( $first->startDt );
|
$start = new \DateTimeImmutable( $first->startDt );
|
||||||
$end = new \DateTimeImmutable( $first->endDt );
|
$end = new \DateTimeImmutable( $first->endDt );
|
||||||
|
|
||||||
@@ -79,6 +97,13 @@ class AvailabilityRepository {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A failed insert returns 0. Skipping it keeps a bogus id out of the
|
||||||
|
// returned list and, more importantly, stops 0 becoming the series'
|
||||||
|
// recurrence group — which would orphan every later occurrence.
|
||||||
|
if ( $id <= 0 ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ( 0 === $groupId ) {
|
if ( 0 === $groupId ) {
|
||||||
$groupId = $id;
|
$groupId = $id;
|
||||||
$this->setRecurrenceGroup( $id, $groupId );
|
$this->setRecurrenceGroup( $id, $groupId );
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ use Unsupervised\Schedular\Val;
|
|||||||
|
|
||||||
class AvailabilitySlot {
|
class AvailabilitySlot {
|
||||||
|
|
||||||
|
/** Lesson length used when none was submitted. */
|
||||||
|
public const DEFAULT_DURATION_MINUTES = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesson lengths a window can be split into, offered by the availability
|
||||||
|
* form. The form hides the ones a given window is too short for.
|
||||||
|
*
|
||||||
|
* @var list<int>
|
||||||
|
*/
|
||||||
|
public const DURATION_CHOICES = [ 30, 60 ];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceiling on a weekly series, matching the form's `max`. Enforced in the
|
||||||
|
* repository too, so a hand-crafted POST cannot ask for ten thousand rows.
|
||||||
|
*/
|
||||||
|
public const MAX_WEEKLY_OCCURRENCES = 52;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $instructorId,
|
public readonly int $instructorId,
|
||||||
public readonly string $startDt,
|
public readonly string $startDt,
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Availability;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a submitted availability window.
|
||||||
|
*
|
||||||
|
* The admin form and the REST endpoint both accept the same window, and used to
|
||||||
|
* check it independently — the endpoint returning a specific 400 for each
|
||||||
|
* failure while the form simply returned, saving nothing and saying nothing. A
|
||||||
|
* 30-minute window submitted with the default 60-minute lesson length was the
|
||||||
|
* visible symptom: no rows, no error, no clue. Both callers now come through
|
||||||
|
* here, so neither can drift from the other again.
|
||||||
|
*
|
||||||
|
* Every rejection is a `WP_Error` carrying a message written for the person who
|
||||||
|
* submitted the form: the endpoint returns it as-is (the `status` data makes it
|
||||||
|
* a 400), and the admin screen shows `get_error_message()` in a notice.
|
||||||
|
*/
|
||||||
|
class WindowValidator {
|
||||||
|
|
||||||
|
public function __construct( private OfferingRepository $offerings ) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a submitted window and return it ready to persist.
|
||||||
|
*
|
||||||
|
* @param int $instructorId Instructor the window belongs to.
|
||||||
|
* @param string $rawStart Submitted start, in any form {@see AvailabilitySlot::normalizeDateTime()} accepts.
|
||||||
|
* @param string $rawEnd Submitted end, likewise.
|
||||||
|
* @param int $durationMinutes Lesson length the window is split into; 0 falls back to the 60-minute default.
|
||||||
|
* @param int $offeringId Offering the slots are tied to, or 0 for any private lesson.
|
||||||
|
*
|
||||||
|
* @return AvailabilitySlot|\WP_Error The window, or why it was rejected.
|
||||||
|
*/
|
||||||
|
public function validate( int $instructorId, string $rawStart, string $rawEnd, int $durationMinutes, int $offeringId ): AvailabilitySlot|\WP_Error {
|
||||||
|
$startDt = AvailabilitySlot::normalizeDateTime( $rawStart );
|
||||||
|
$endDt = AvailabilitySlot::normalizeDateTime( $rawEnd );
|
||||||
|
|
||||||
|
if ( null === $startDt || null === $endDt ) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'invalid_datetime',
|
||||||
|
__( 'Enter a valid start and end date and time.', 'unsupervised-schedular' ),
|
||||||
|
[ 'status' => 400 ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $endDt <= $startDt ) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'invalid_datetime',
|
||||||
|
__( 'The end time must be after the start time.', 'unsupervised-schedular' ),
|
||||||
|
[ 'status' => 400 ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'invalid_window',
|
||||||
|
__( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ),
|
||||||
|
[ 'status' => 400 ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slot may only be tied to an offering the instructor owns, so it can
|
||||||
|
// never inherit another instructor's price or payment routing at booking.
|
||||||
|
if ( $offeringId > 0 ) {
|
||||||
|
$offering = $this->offerings->findById( $offeringId );
|
||||||
|
|
||||||
|
if ( null === $offering || $offering->instructorId !== $instructorId ) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'invalid_offering',
|
||||||
|
__( 'That offering is not available.', 'unsupervised-schedular' ),
|
||||||
|
[ 'status' => 400 ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = $durationMinutes > 0 ? $durationMinutes : AvailabilitySlot::DEFAULT_DURATION_MINUTES;
|
||||||
|
|
||||||
|
$window = new AvailabilitySlot(
|
||||||
|
instructorId: $instructorId,
|
||||||
|
startDt: $startDt,
|
||||||
|
endDt: $endDt,
|
||||||
|
durationMinutes: $duration,
|
||||||
|
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The window is stored as lesson-length slots, so one that cannot fit a
|
||||||
|
// single lesson would persist nothing at all.
|
||||||
|
if ( [] === $window->splitByDuration() ) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'invalid_window',
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: the selected lesson length, in minutes. */
|
||||||
|
__( 'This window is shorter than the %d-minute lesson length, so it holds no bookable slots. Choose a shorter lesson length or a longer window.', 'unsupervised-schedular' ),
|
||||||
|
$duration
|
||||||
|
),
|
||||||
|
[ 'status' => 400 ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $window;
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-5
@@ -86,13 +86,13 @@ class BlockPreview {
|
|||||||
private static function upcomingLessons(): string {
|
private static function upcomingLessons(): string {
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'<div class="us-my-lessons"><h3>%s</h3>'
|
'<div class="us-my-lessons"><h3>%s</h3>'
|
||||||
. '<div class="us-my-lesson"><span class="us-my-lesson-info">'
|
. '<div class="us-my-lesson"><div class="us-my-lesson-info">'
|
||||||
. '<strong class="us-my-lesson-title">%s <span class="us-my-lesson-duration">(30 min)</span></strong>'
|
. '<strong class="us-my-lesson-title">%s <span class="us-my-lesson-duration">(30 min)</span></strong>'
|
||||||
. '<span class="us-my-lesson-when">%s</span></span>'
|
. '<span class="us-my-lesson-when">%s</span></div>'
|
||||||
. '<span class="us-my-lesson-actions">'
|
. '<div class="us-my-lesson-actions">'
|
||||||
. '<span class="us-lesson-status us-lesson-status-confirmed">%s</span>'
|
. '<span class="us-lesson-status us-lesson-status-confirmed">%s</span>'
|
||||||
. '<button type="button" class="us-cancel-lesson" disabled>%s</button>'
|
. '<button type="button" class="us-cancel-lesson" disabled>%s</button>'
|
||||||
. '</span></div></div>',
|
. '</div></div></div>',
|
||||||
esc_html__( 'Your upcoming lessons', 'unsupervised-schedular' ),
|
esc_html__( 'Your upcoming lessons', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Piano Lesson', 'unsupervised-schedular' ),
|
esc_html__( 'Piano Lesson', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Monday · 4:00 PM–4:30 PM', 'unsupervised-schedular' ),
|
esc_html__( 'Monday · 4:00 PM–4:30 PM', 'unsupervised-schedular' ),
|
||||||
@@ -118,11 +118,13 @@ class BlockPreview {
|
|||||||
: '<p>' . esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ) . '</p>';
|
: '<p>' . esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ) . '</p>';
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p>25.00 CAD</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p class="us-class-price">%s</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||||
self::note( $note ),
|
self::note( $note ),
|
||||||
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
|
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ),
|
esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ),
|
||||||
$description,
|
$description,
|
||||||
|
// Prices on the live page always carry their cadence, so the sample does too.
|
||||||
|
esc_html__( '25.00 CAD up front', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
|
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
@@ -167,6 +169,41 @@ 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</label><input type="text" id="us-child-name"></p>'
|
||||||
|
. '<p><label for="us-child-dob">%s</label><input type="date" id="us-child-dob"></p>'
|
||||||
|
. '<p><button type="button" disabled>%s</button></p>',
|
||||||
|
esc_html__( 'Add a child', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Name', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Date of birth', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Add child', '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 children here.', 'unsupervised-schedular' ) ),
|
||||||
|
esc_html__( 'Your family', 'unsupervised-schedular' ),
|
||||||
|
$children,
|
||||||
|
$add
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static function note( string $text ): string {
|
private static function note( string $text ): string {
|
||||||
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
|
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\LoginPage;
|
|||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||||
|
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so
|
* Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so
|
||||||
@@ -28,6 +29,7 @@ class BlockRegistrar {
|
|||||||
private LoginPage $loginPage,
|
private LoginPage $loginPage,
|
||||||
private RegistrationPage $registrationPage,
|
private RegistrationPage $registrationPage,
|
||||||
private GroupClassPage $groupClassPage,
|
private GroupClassPage $groupClassPage,
|
||||||
|
private FamilyPage $familyPage,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
@@ -137,6 +139,15 @@ class BlockRegistrar {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'us-scheduler/family' => [
|
||||||
|
'render' => [ $this, 'renderFamily' ],
|
||||||
|
'attributes' => [
|
||||||
|
'loginPageId' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +195,15 @@ class BlockRegistrar {
|
|||||||
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Server-side auto-redirect for blocks that opt in via their autoRedirect
|
||||||
* attribute: logged-out visitors on a page containing the booking block
|
* attribute: logged-out visitors on a page containing the booking block
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
|
|||||||
|
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Offering\Offering;
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\Payment;
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
@@ -28,6 +29,7 @@ class BookingEndpoint {
|
|||||||
private RegistrationGate $gate,
|
private RegistrationGate $gate,
|
||||||
private PaymentService $payments,
|
private PaymentService $payments,
|
||||||
private CancellationPolicy $cancellationPolicy,
|
private CancellationPolicy $cancellationPolicy,
|
||||||
|
private GuardianService $guardians,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +61,13 @@ class BookingEndpoint {
|
|||||||
'type' => 'integer',
|
'type' => 'integer',
|
||||||
'default' => 0,
|
'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' => [
|
'recurrence' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'default' => 'single',
|
'default' => 'single',
|
||||||
@@ -115,11 +124,25 @@ class BookingEndpoint {
|
|||||||
|
|
||||||
public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||||
$userId = get_current_user_id();
|
$userId = get_current_user_id();
|
||||||
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
|
|
||||||
? $this->bookings->findUpcomingForInstructor( $userId )
|
|
||||||
: $this->bookings->findUpcomingForStudent( $userId );
|
|
||||||
|
|
||||||
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,
|
'end_dt' => $slot?->endDt,
|
||||||
'offering_title' => $offering?->title,
|
'offering_title' => $offering?->title,
|
||||||
'duration_minutes' => $duration,
|
'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 {
|
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' ) );
|
$slotId = Val::int( $request->get_param( 'slot_id' ) );
|
||||||
$slot = $this->availability->findById( $slotId );
|
$slot = $this->availability->findById( $slotId );
|
||||||
|
|
||||||
@@ -214,7 +248,6 @@ class BookingEndpoint {
|
|||||||
return $gateError;
|
return $gateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
$studentId = get_current_user_id();
|
|
||||||
$notes = Val::string( $request->get_param( 'notes' ) );
|
$notes = Val::string( $request->get_param( 'notes' ) );
|
||||||
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
|
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
|
||||||
? Lesson::RECURRENCE_WEEKLY
|
? Lesson::RECURRENCE_WEEKLY
|
||||||
@@ -254,7 +287,9 @@ class BookingEndpoint {
|
|||||||
$ids = [ $anchorId ];
|
$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;
|
$payment = null;
|
||||||
$status = Lesson::STATUS_PENDING;
|
$status = Lesson::STATUS_PENDING;
|
||||||
@@ -276,7 +311,16 @@ class BookingEndpoint {
|
|||||||
? $offering->price
|
? $offering->price
|
||||||
: $offering->price * count( $ids );
|
: $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() ) {
|
if ( null !== $payment && $payment->isPaid() ) {
|
||||||
$status = Lesson::STATUS_CONFIRMED;
|
$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.
|
* 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
|
* 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
|
* 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.
|
* 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 ] );
|
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 ] );
|
return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
|
|||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
class BookingPage {
|
class BookingPage {
|
||||||
@@ -18,6 +19,8 @@ class BookingPage {
|
|||||||
/** The student's upcoming lessons only — nothing bookable. */
|
/** The student's upcoming lessons only — nothing bookable. */
|
||||||
public const MODE_UPCOMING = 'upcoming';
|
public const MODE_UPCOMING = 'upcoming';
|
||||||
|
|
||||||
|
public function __construct( private GuardianService $guardians ) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the booking shortcode/block output.
|
* Renders the booking shortcode/block output.
|
||||||
*
|
*
|
||||||
@@ -64,6 +67,11 @@ class BookingPage {
|
|||||||
$showBooking = self::MODE_UPCOMING !== $mode;
|
$showBooking = self::MODE_UPCOMING !== $mode;
|
||||||
$showUpcoming = self::MODE_BOOKING !== $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();
|
ob_start();
|
||||||
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
|
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
|
||||||
return (string) ob_get_clean();
|
return (string) ob_get_clean();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\GroupClass;
|
namespace Unsupervised\Schedular\GroupClass;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Offering\Offering;
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\Payment;
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
@@ -20,6 +21,7 @@ class EnrollmentEndpoint {
|
|||||||
private RegistrationGate $gate,
|
private RegistrationGate $gate,
|
||||||
private PaymentService $payments,
|
private PaymentService $payments,
|
||||||
private GroupAccessRepository $access,
|
private GroupAccessRepository $access,
|
||||||
|
private GuardianService $guardians,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +49,13 @@ class EnrollmentEndpoint {
|
|||||||
'required' => true,
|
'required' => true,
|
||||||
'sanitize_callback' => 'absint',
|
'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' => [
|
'answers' => [
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
'default' => [],
|
'default' => [],
|
||||||
@@ -81,13 +90,25 @@ class EnrollmentEndpoint {
|
|||||||
} elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
|
} elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
|
||||||
$enrollments = $this->enrollments->findByInstructor( $userId );
|
$enrollments = $this->enrollments->findByInstructor( $userId );
|
||||||
} else {
|
} 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 );
|
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 {
|
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' ) ) );
|
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
|
||||||
$offering = $this->offerings->findById( $offeringId );
|
$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 ] );
|
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 ) ) {
|
if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) {
|
||||||
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
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
|
// Mark the access grant used so instructor rosters distinguish invited
|
||||||
// students from enrolled ones (a no-op for public classes).
|
// students from enrolled ones (a no-op for public classes).
|
||||||
@@ -146,7 +167,16 @@ class EnrollmentEndpoint {
|
|||||||
// regardless of payment.
|
// regardless of payment.
|
||||||
$payment = null;
|
$payment = null;
|
||||||
if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) {
|
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.
|
// `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 ] );
|
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 ] );
|
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 );
|
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.
|
* Extract a question_id => value map from the request.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\GroupClass;
|
namespace Unsupervised\Schedular\GroupClass;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
class GroupClassPage {
|
class GroupClassPage {
|
||||||
|
|
||||||
|
public function __construct( private GuardianService $guardians ) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the group-class enrolment shortcode output.
|
* Renders the group-class enrolment shortcode output.
|
||||||
*
|
*
|
||||||
@@ -39,6 +42,10 @@ class GroupClassPage {
|
|||||||
|
|
||||||
$offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) );
|
$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();
|
ob_start();
|
||||||
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
|
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
|
||||||
return (string) ob_get_clean();
|
return (string) ob_get_clean();
|
||||||
|
|||||||
@@ -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 child account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the booking capability from a child account, so the only route to a
|
||||||
|
* lesson in their name is their guardian's authorised booking.
|
||||||
|
*
|
||||||
|
* @param array<string, bool> $allcaps All capabilities currently held.
|
||||||
|
* @param array<int, string> $caps Required capabilities (unused).
|
||||||
|
* @param array<int, mixed> $args Callback args (unused).
|
||||||
|
* @param mixed $user The user being checked (a WP_User in practice).
|
||||||
|
* @return array<string, bool>
|
||||||
|
*/
|
||||||
|
public function withholdBooking( array $allcaps, array $caps, array $args, mixed $user ): array {
|
||||||
|
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
||||||
|
unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allcaps;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 family', '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' );
|
||||||
|
$dateOfBirth = $this->postString( 'child_dob' );
|
||||||
|
$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, $dateOfBirth, $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_dob' ) );
|
||||||
|
|
||||||
|
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 child.', '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 => __( 'Child added.', 'unsupervised-schedular' ),
|
||||||
|
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
||||||
|
self::RESULT_REMOVED => __( 'Child 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Guardian;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One parent/guardian ↔ child link. The child is a real (login-less) WordPress
|
||||||
|
* user, so `studentId` is a `wp_users` ID exactly like every other student id in
|
||||||
|
* the schema — this row only records who books and pays on their behalf.
|
||||||
|
*/
|
||||||
|
class GuardianLink {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $guardianId,
|
||||||
|
public readonly int $studentId,
|
||||||
|
public readonly string $relationship = '',
|
||||||
|
public readonly ?string $createdAt = null,
|
||||||
|
public readonly ?int $id = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromRow( \stdClass $row ): self {
|
||||||
|
return new self(
|
||||||
|
guardianId: Val::int( $row->guardian_id ),
|
||||||
|
studentId: Val::int( $row->student_id ),
|
||||||
|
relationship: Val::string( $row->relationship ?? '' ),
|
||||||
|
createdAt: Val::stringOrNull( $row->created_at ?? null ),
|
||||||
|
id: Val::int( $row->id ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a plain array representation of the link.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array {
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'guardian_id' => $this->guardianId,
|
||||||
|
'student_id' => $this->studentId,
|
||||||
|
'relationship' => $this->relationship,
|
||||||
|
'created_at' => $this->createdAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Guardian;
|
||||||
|
|
||||||
|
class GuardianRepository {
|
||||||
|
|
||||||
|
private string $table;
|
||||||
|
|
||||||
|
public function __construct( private \wpdb $db ) {
|
||||||
|
$this->table = $db->prefix . 'us_guardians';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a child to a guardian. Returns 0 without inserting when the child
|
||||||
|
* already has a guardian: v1 is one guardian per child, and the check lives
|
||||||
|
* here so every caller (signup, the family screen, admin) gets it.
|
||||||
|
*/
|
||||||
|
public function insert( GuardianLink $link ): int {
|
||||||
|
if ( null !== $this->findByStudent( $link->studentId ) ) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->insert(
|
||||||
|
$this->table,
|
||||||
|
[
|
||||||
|
'guardian_id' => $link->guardianId,
|
||||||
|
'student_id' => $link->studentId,
|
||||||
|
'relationship' => $link->relationship,
|
||||||
|
'created_at' => current_time( 'mysql' ),
|
||||||
|
],
|
||||||
|
[ '%d', '%d', '%s', '%s' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->db->insert_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link naming this child's guardian, or null when they book for
|
||||||
|
* themselves.
|
||||||
|
*/
|
||||||
|
public function findByStudent( int $studentId ): ?GuardianLink {
|
||||||
|
$row = $this->db->get_row(
|
||||||
|
$this->db->prepare(
|
||||||
|
'SELECT * FROM %i WHERE student_id = %d LIMIT 1',
|
||||||
|
$this->table,
|
||||||
|
$studentId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $row ? GuardianLink::fromRow( $row ) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every child linked to a guardian, oldest link first — the order they are
|
||||||
|
* offered in the booking selector, so it stays stable as children are added.
|
||||||
|
*
|
||||||
|
* @return list<GuardianLink>
|
||||||
|
*/
|
||||||
|
public function findByGuardian( int $guardianId ): array {
|
||||||
|
$rows = $this->db->get_results(
|
||||||
|
$this->db->prepare(
|
||||||
|
'SELECT * FROM %i WHERE guardian_id = %d ORDER BY created_at ASC, id ASC',
|
||||||
|
$this->table,
|
||||||
|
$guardianId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map( GuardianLink::fromRow( ... ), $rows ?? [] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this exact guardian↔child pair is linked — the authorisation check
|
||||||
|
* behind every "act for this student" boundary.
|
||||||
|
*/
|
||||||
|
public function isGuardianOf( int $guardianId, int $studentId ): bool {
|
||||||
|
$found = $this->db->get_var(
|
||||||
|
$this->db->prepare(
|
||||||
|
'SELECT id FROM %i WHERE guardian_id = %d AND student_id = %d LIMIT 1',
|
||||||
|
$this->table,
|
||||||
|
$guardianId,
|
||||||
|
$studentId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return null !== $found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the link between a guardian and one of their children. Deleting the
|
||||||
|
* child user itself is the caller's decision ({@see GuardianService::removeChild()});
|
||||||
|
* this only unlinks.
|
||||||
|
*/
|
||||||
|
public function delete( int $guardianId, int $studentId ): bool {
|
||||||
|
$deleted = $this->db->delete(
|
||||||
|
$this->table,
|
||||||
|
[
|
||||||
|
'guardian_id' => $guardianId,
|
||||||
|
'student_id' => $studentId,
|
||||||
|
],
|
||||||
|
[ '%d', '%d' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $deleted > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many children a guardian has — enough to decide whether the booking
|
||||||
|
* page needs a "who is this for?" selector at all.
|
||||||
|
*/
|
||||||
|
public function countChildren( int $guardianId ): int {
|
||||||
|
$count = $this->db->get_var(
|
||||||
|
$this->db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM %i WHERE guardian_id = %d',
|
||||||
|
$this->table,
|
||||||
|
$guardianId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<?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 date of birth (`Y-m-d`), collected at signup and editable after. */
|
||||||
|
public const META_DOB = 'us_date_of_birth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 or WordPress
|
||||||
|
* refuses the insert.
|
||||||
|
*/
|
||||||
|
public function createChild( int $guardianId, string $name, string $dateOfBirth = '', string $relationship = '' ): int|\WP_Error {
|
||||||
|
$name = trim( $name );
|
||||||
|
if ( '' === $name ) {
|
||||||
|
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$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->setDateOfBirth( $userId, $dateOfBirth );
|
||||||
|
|
||||||
|
$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 child. Please contact the studio.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a child and update their date of birth. 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 $dateOfBirth = '' ): ?\WP_Error {
|
||||||
|
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||||
|
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim( $name );
|
||||||
|
if ( '' === $name ) {
|
||||||
|
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = wp_update_user(
|
||||||
|
[
|
||||||
|
'ID' => $studentId,
|
||||||
|
'display_name' => $name,
|
||||||
|
'nickname' => $name,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setDateOfBirth( $studentId, $dateOfBirth );
|
||||||
|
|
||||||
|
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 children.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'has_history',
|
||||||
|
__( 'This child 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, date_of_birth: 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 ),
|
||||||
|
'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ),
|
||||||
|
'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 date of birth, or clear it when blank or unparseable. Kept
|
||||||
|
* as `Y-m-d` so it sorts and displays consistently wherever it is read.
|
||||||
|
*/
|
||||||
|
private function setDateOfBirth( int $userId, string $dateOfBirth ): void {
|
||||||
|
$dateOfBirth = trim( $dateOfBirth );
|
||||||
|
|
||||||
|
if ( '' === $dateOfBirth ) {
|
||||||
|
delete_user_meta( $userId, self::META_DOB );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth );
|
||||||
|
if ( false === $parsed ) {
|
||||||
|
delete_user_meta( $userId, self::META_DOB );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unused placeholder address for a child's account. WordPress requires a
|
||||||
|
* unique email per user, so the random suffix is retried against
|
||||||
|
* `email_exists()` rather than assumed unique.
|
||||||
|
*/
|
||||||
|
private function childEmail(): string {
|
||||||
|
do {
|
||||||
|
$email = 'us-child-' . wp_generate_password( 12, false, false ) . '@' . self::CHILD_EMAIL_DOMAIN;
|
||||||
|
} while ( false !== email_exists( $email ) );
|
||||||
|
|
||||||
|
return strtolower( $email );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ namespace Unsupervised\Schedular;
|
|||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
|
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||||
|
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||||
|
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||||
|
|
||||||
class Installer {
|
class Installer {
|
||||||
|
|
||||||
@@ -50,5 +53,13 @@ class Installer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows();
|
( 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ class Credit {
|
|||||||
public readonly int $studentId,
|
public readonly int $studentId,
|
||||||
public readonly float $amount,
|
public readonly float $amount,
|
||||||
public readonly float $remaining,
|
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 string $currency = 'CAD',
|
||||||
public readonly ?int $sourcePaymentId = null,
|
public readonly ?int $sourcePaymentId = null,
|
||||||
public readonly ?int $sourceLessonId = null,
|
public readonly ?int $sourceLessonId = null,
|
||||||
@@ -41,6 +47,7 @@ class Credit {
|
|||||||
studentId: Val::int( $row->student_id ),
|
studentId: Val::int( $row->student_id ),
|
||||||
amount: Val::float( $row->amount ),
|
amount: Val::float( $row->amount ),
|
||||||
remaining: Val::float( $row->remaining ),
|
remaining: Val::float( $row->remaining ),
|
||||||
|
payerId: Val::int( $row->payer_id ?? 0 ),
|
||||||
currency: Val::string( $row->currency ),
|
currency: Val::string( $row->currency ),
|
||||||
sourcePaymentId: Val::intOrNull( $row->source_payment_id ?? null ),
|
sourcePaymentId: Val::intOrNull( $row->source_payment_id ?? null ),
|
||||||
sourceLessonId: Val::intOrNull( $row->source_lesson_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;
|
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.
|
* Returns a plain array representation of the credit.
|
||||||
*
|
*
|
||||||
@@ -65,6 +81,7 @@ class Credit {
|
|||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'student_id' => $this->studentId,
|
'student_id' => $this->studentId,
|
||||||
|
'payer_id' => $this->payerOrStudent(),
|
||||||
'amount' => $this->amount,
|
'amount' => $this->amount,
|
||||||
'remaining' => $this->remaining,
|
'remaining' => $this->remaining,
|
||||||
'currency' => $this->currency,
|
'currency' => $this->currency,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class CreditRepository {
|
|||||||
$this->table,
|
$this->table,
|
||||||
[
|
[
|
||||||
'student_id' => $credit->studentId,
|
'student_id' => $credit->studentId,
|
||||||
|
'payer_id' => $credit->payerOrStudent(),
|
||||||
'amount' => $credit->amount,
|
'amount' => $credit->amount,
|
||||||
'remaining' => $credit->remaining,
|
'remaining' => $credit->remaining,
|
||||||
'currency' => $credit->currency,
|
'currency' => $credit->currency,
|
||||||
@@ -25,7 +26,7 @@ class CreditRepository {
|
|||||||
'status' => $credit->status,
|
'status' => $credit->status,
|
||||||
'created_at' => current_time( 'mysql' ),
|
'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;
|
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
|
* A payer's total unused credit balance (sum of the remaining amounts of every
|
||||||
* still-available credit).
|
* 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(
|
$total = $this->db->get_var(
|
||||||
$this->db->prepare(
|
$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,
|
$this->table,
|
||||||
$studentId,
|
$payerId,
|
||||||
Credit::STATUS_AVAILABLE
|
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.
|
* consumed in.
|
||||||
*
|
*
|
||||||
* @return list<Credit>
|
* @return list<Credit>
|
||||||
*/
|
*/
|
||||||
public function findAvailableByStudent( int $studentId ): array {
|
public function findAvailableByPayer( int $payerId ): array {
|
||||||
$rows = $this->db->get_results(
|
$rows = $this->db->get_results(
|
||||||
$this->db->prepare(
|
$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,
|
$this->table,
|
||||||
$studentId,
|
$payerId,
|
||||||
Credit::STATUS_AVAILABLE
|
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>
|
* @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
|
* 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.
|
* 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 );
|
$remaining = round( $amount, 2 );
|
||||||
if ( $remaining <= 0.0 ) {
|
if ( $remaining <= 0.0 ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ( $this->findAvailableByStudent( $studentId ) as $credit ) {
|
foreach ( $this->findAvailableByPayer( $payerId ) as $credit ) {
|
||||||
if ( $remaining <= 0.0 ) {
|
if ( $remaining <= 0.0 ) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ class Payment {
|
|||||||
public readonly string $registrationType,
|
public readonly string $registrationType,
|
||||||
public readonly int $registrationId,
|
public readonly int $registrationId,
|
||||||
public readonly float $amount,
|
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 $currency = 'CAD',
|
||||||
public readonly string $method = self::METHOD_ETRANSFER,
|
public readonly string $method = self::METHOD_ETRANSFER,
|
||||||
public readonly string $status = self::STATUS_PENDING,
|
public readonly string $status = self::STATUS_PENDING,
|
||||||
@@ -64,6 +71,7 @@ class Payment {
|
|||||||
registrationType: Val::string( $row->registration_type ),
|
registrationType: Val::string( $row->registration_type ),
|
||||||
registrationId: Val::int( $row->registration_id ),
|
registrationId: Val::int( $row->registration_id ),
|
||||||
amount: Val::float( $row->amount ),
|
amount: Val::float( $row->amount ),
|
||||||
|
payerId: Val::int( $row->payer_id ?? 0 ),
|
||||||
currency: Val::string( $row->currency ),
|
currency: Val::string( $row->currency ),
|
||||||
method: Val::string( $row->method ),
|
method: Val::string( $row->method ),
|
||||||
status: Val::string( $row->status ),
|
status: Val::string( $row->status ),
|
||||||
@@ -87,6 +95,25 @@ class Payment {
|
|||||||
return self::STATUS_PAID === $this->status;
|
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 /
|
* Whether this payment was generated by the daily billing scan (weekly /
|
||||||
* monthly) rather than taken at registration. Scheduled payments carry a due
|
* monthly) rather than taken at registration. Scheduled payments carry a due
|
||||||
@@ -134,6 +161,7 @@ class Payment {
|
|||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'student_id' => $this->studentId,
|
'student_id' => $this->studentId,
|
||||||
|
'payer_id' => $this->payerOrStudent(),
|
||||||
'instructor_id' => $this->instructorId,
|
'instructor_id' => $this->instructorId,
|
||||||
'registration_type' => $this->registrationType,
|
'registration_type' => $this->registrationType,
|
||||||
'etransfer_email' => $this->etransferEmail,
|
'etransfer_email' => $this->etransferEmail,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class PaymentRepository {
|
|||||||
$this->table,
|
$this->table,
|
||||||
[
|
[
|
||||||
'student_id' => $payment->studentId,
|
'student_id' => $payment->studentId,
|
||||||
|
'payer_id' => $payment->payerOrStudent(),
|
||||||
'instructor_id' => $payment->instructorId,
|
'instructor_id' => $payment->instructorId,
|
||||||
'registration_type' => $payment->registrationType,
|
'registration_type' => $payment->registrationType,
|
||||||
'registration_id' => $payment->registrationId,
|
'registration_id' => $payment->registrationId,
|
||||||
@@ -36,12 +37,24 @@ class PaymentRepository {
|
|||||||
'paid_at' => $payment->paidAt,
|
'paid_at' => $payment->paidAt,
|
||||||
'created_at' => current_time( 'mysql' ),
|
'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;
|
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
|
* Attach the Stripe PaymentIntent id created for a card payment so the webhook
|
||||||
* can later reconcile the charge back to this row.
|
* can later reconcile the charge back to this row.
|
||||||
|
|||||||
@@ -34,13 +34,18 @@ class PaymentService {
|
|||||||
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
|
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
|
||||||
* scan (weekly / monthly) rather than taken at registration; both stay null for
|
* scan (weekly / monthly) rather than taken at registration; both stay null for
|
||||||
* the pay-now flow.
|
* 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 ) {
|
if ( $amount <= 0.0 ) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$method = $this->resolver->resolve( $studentId );
|
$payerId = $payerId > 0 ? $payerId : $studentId;
|
||||||
|
$method = $this->resolver->resolve( $payerId );
|
||||||
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
|
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
|
||||||
|
|
||||||
$etransferEmail = null !== $offeringEtransferEmail && '' !== $offeringEtransferEmail
|
$etransferEmail = null !== $offeringEtransferEmail && '' !== $offeringEtransferEmail
|
||||||
@@ -58,6 +63,7 @@ class PaymentService {
|
|||||||
registrationType: $type,
|
registrationType: $type,
|
||||||
registrationId: $registrationId,
|
registrationId: $registrationId,
|
||||||
amount: $amount,
|
amount: $amount,
|
||||||
|
payerId: $payerId,
|
||||||
currency: $currency,
|
currency: $currency,
|
||||||
method: $method,
|
method: $method,
|
||||||
status: $status,
|
status: $status,
|
||||||
@@ -72,7 +78,9 @@ class PaymentService {
|
|||||||
$this->linkPayment( $type, $registrationId, $id );
|
$this->linkPayment( $type, $registrationId, $id );
|
||||||
|
|
||||||
if ( Payment::STATUS_PAID === $status ) {
|
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 );
|
return $this->payments->findById( $id );
|
||||||
@@ -111,7 +119,7 @@ class PaymentService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->studentId );
|
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() );
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -174,11 +182,15 @@ class PaymentService {
|
|||||||
return null;
|
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(
|
$id = $this->credits->insert(
|
||||||
new Credit(
|
new Credit(
|
||||||
studentId: $payment->studentId,
|
studentId: $payment->studentId,
|
||||||
amount: $share,
|
amount: $share,
|
||||||
remaining: $share,
|
remaining: $share,
|
||||||
|
payerId: $payment->payerOrStudent(),
|
||||||
currency: $payment->currency,
|
currency: $payment->currency,
|
||||||
sourcePaymentId: $payment->id,
|
sourcePaymentId: $payment->id,
|
||||||
sourceLessonId: $lesson->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
|
* 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;
|
* 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
|
* 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
|
* 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
|
* 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
|
* @param list<Payment> $payments
|
||||||
* @return array<int, float>
|
* @return array<int, float>
|
||||||
*/
|
*/
|
||||||
public function applyCredits( int $studentId, array $payments ): array {
|
public function applyCredits( int $payerId, array $payments ): array {
|
||||||
$balance = $this->credits->availableBalance( $studentId );
|
$balance = $this->credits->availableBalance( $payerId );
|
||||||
if ( $balance <= 0.0 ) {
|
if ( $balance <= 0.0 ) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -258,7 +273,7 @@ class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ( $consumed > 0.0 ) {
|
if ( $consumed > 0.0 ) {
|
||||||
$this->credits->consume( $studentId, $consumed );
|
$this->credits->consume( $payerId, $consumed );
|
||||||
}
|
}
|
||||||
|
|
||||||
return $applied;
|
return $applied;
|
||||||
@@ -272,11 +287,19 @@ class PaymentService {
|
|||||||
* needs no further action. Returns null when the registration has no payment,
|
* 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.
|
* 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
|
* @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 );
|
$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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +357,7 @@ class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ( 'payment_intent.succeeded' === $event->type && ! $payment->isPaid() ) {
|
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() ) {
|
} elseif ( 'payment_intent.payment_failed' === $event->type && ! $payment->isPaid() ) {
|
||||||
$this->payments->updateStatus( $payment->id, Payment::STATUS_FAILED );
|
$this->payments->updateStatus( $payment->id, Payment::STATUS_FAILED );
|
||||||
}
|
}
|
||||||
@@ -342,12 +365,12 @@ class PaymentService {
|
|||||||
return true;
|
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->payments->markPaid( $paymentId, 'USC-' . $paymentId );
|
||||||
$this->confirmRegistration( $type, $registrationId );
|
$this->confirmRegistration( $type, $registrationId );
|
||||||
|
|
||||||
$paid = $this->payments->findById( $paymentId );
|
$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 ) ) {
|
if ( null !== $paid && $this->mailer->send( $paid, $user instanceof \WP_User ? $user : null ) ) {
|
||||||
$this->payments->markReceiptSent( $paymentId );
|
$this->payments->markReceiptSent( $paymentId );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ namespace Unsupervised\Schedular\Payment;
|
|||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Offering\Offering;
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the pending payments that scheduled-billing offerings (weekly /
|
* 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
|
* 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
|
* self-healing: every run re-scans from the current ledger state, so a missed
|
||||||
@@ -30,6 +31,7 @@ class ScheduledBillingRunner {
|
|||||||
private EnrollmentRepository $enrollments,
|
private EnrollmentRepository $enrollments,
|
||||||
private OfferingRepository $offerings,
|
private OfferingRepository $offerings,
|
||||||
private PaymentDueMailer $mailer,
|
private PaymentDueMailer $mailer,
|
||||||
|
private GuardianService $guardians,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
@@ -42,12 +44,13 @@ class ScheduledBillingRunner {
|
|||||||
public function run(): void {
|
public function run(): void {
|
||||||
$now = $this->now();
|
$now = $this->now();
|
||||||
|
|
||||||
// One notice bucket per student, filled as pending payments are created and
|
// One notice bucket per *payer*, filled as pending payments are created and
|
||||||
// flushed to a single email at the end, so a student billed for several
|
// 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. Each entry keeps
|
// lessons on one day is emailed once — never once per lesson, and a guardian
|
||||||
// the created payment and its label; credits are applied across the whole
|
// gets one notice covering every child rather than one per child. Each entry
|
||||||
// bucket before the notice is built, so a student's account credit offsets the
|
// keeps the created payment and its label; credits are applied across the
|
||||||
// run's charges oldest-first.
|
// whole bucket before the notice is built, so the family's account credit
|
||||||
|
// offsets the run's charges oldest-first.
|
||||||
$buckets = [];
|
$buckets = [];
|
||||||
|
|
||||||
$this->billPrivateLessons( $now, $buckets );
|
$this->billPrivateLessons( $now, $buckets );
|
||||||
@@ -251,11 +254,18 @@ class ScheduledBillingRunner {
|
|||||||
/**
|
/**
|
||||||
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
||||||
*
|
*
|
||||||
|
* A monthly group class is priced **per month**, not per session: the fee is
|
||||||
|
* charged once for the month however many times the class meets in it. This is
|
||||||
|
* what the student is quoted and agrees to on the way in ("40.00 CAD monthly"),
|
||||||
|
* and it is the one place the monthly rule differs from private lessons, whose
|
||||||
|
* per-lesson fee is multiplied by the lessons that fall in the month.
|
||||||
|
*
|
||||||
* @param list<array{start: string, end: string}> $windows
|
* @param list<array{start: string, end: string}> $windows
|
||||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||||
*/
|
*/
|
||||||
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||||
// Count this enrolment's sessions per calendar month.
|
// Count this enrolment's sessions per calendar month. The count does not
|
||||||
|
// price the month — it names it on the student's notice ("3 sessions").
|
||||||
$months = [];
|
$months = [];
|
||||||
foreach ( $windows as $window ) {
|
foreach ( $windows as $window ) {
|
||||||
$start = new \DateTimeImmutable( $window['start'] );
|
$start = new \DateTimeImmutable( $window['start'] );
|
||||||
@@ -278,7 +288,7 @@ class ScheduledBillingRunner {
|
|||||||
(int) $enrollment->id,
|
(int) $enrollment->id,
|
||||||
$enrollment->studentId,
|
$enrollment->studentId,
|
||||||
$enrollment->instructorId,
|
$enrollment->instructorId,
|
||||||
$offering->price * $count,
|
$offering->price,
|
||||||
$offering->currency,
|
$offering->currency,
|
||||||
$offering->etransferEmail,
|
$offering->etransferEmail,
|
||||||
$monthStart,
|
$monthStart,
|
||||||
@@ -296,19 +306,24 @@ class ScheduledBillingRunner {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
|
* 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
|
* Credits are applied later, once the whole bucket is known. Returns the created
|
||||||
* payment, or null when there was nothing to charge.
|
* 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
|
* @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 {
|
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 ) {
|
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||||
$buckets[ $studentId ][] = [
|
$buckets[ $payerId ][] = [
|
||||||
'payment' => $payment,
|
'payment' => $payment,
|
||||||
'label' => $label,
|
'label' => $payerId === $studentId ? $label : $this->labelFor( $studentId, $label ),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,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
|
* 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
|
* 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
|
* credit applied and the reduced total due; a charge fully covered by credit is
|
||||||
@@ -326,9 +351,9 @@ class ScheduledBillingRunner {
|
|||||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||||
*/
|
*/
|
||||||
private function sendNotices( array $buckets ): void {
|
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 );
|
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
|
||||||
$applied = $this->payments->applyCredits( $studentId, $payments );
|
$applied = $this->payments->applyCredits( $payerId, $payments );
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
$batchIds = [];
|
$batchIds = [];
|
||||||
@@ -359,7 +384,7 @@ class ScheduledBillingRunner {
|
|||||||
$reference = [] !== $batchIds ? $this->reference() : '';
|
$reference = [] !== $batchIds ? $this->reference() : '';
|
||||||
$this->payments->assignNoticeBatch( $batchIds, $reference );
|
$this->payments->assignNoticeBatch( $batchIds, $reference );
|
||||||
|
|
||||||
$user = get_userdata( $studentId );
|
$user = get_userdata( $payerId );
|
||||||
if ( $user instanceof \WP_User ) {
|
if ( $user instanceof \WP_User ) {
|
||||||
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
|
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-8
@@ -17,6 +17,10 @@ use Unsupervised\Schedular\Booking\BookingRepository;
|
|||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
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\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||||
@@ -76,6 +80,9 @@ class Plugin {
|
|||||||
$groupAccess = new GroupAccessRepository( $wpdb );
|
$groupAccess = new GroupAccessRepository( $wpdb );
|
||||||
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
|
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
|
||||||
|
|
||||||
|
$guardianRepo = new GuardianRepository( $wpdb );
|
||||||
|
$guardians = new GuardianService( $guardianRepo, $bookings, $enrollments );
|
||||||
|
|
||||||
$paymentRepo = new PaymentRepository( $wpdb );
|
$paymentRepo = new PaymentRepository( $wpdb );
|
||||||
$creditRepo = new CreditRepository( $wpdb );
|
$creditRepo = new CreditRepository( $wpdb );
|
||||||
$settings = new StudioSettings();
|
$settings = new StudioSettings();
|
||||||
@@ -87,21 +94,23 @@ class Plugin {
|
|||||||
// front-end output is identical whichever way a page embeds them.
|
// front-end output is identical whichever way a page embeds them.
|
||||||
$registrationMailer = new RegistrationMailer();
|
$registrationMailer = new RegistrationMailer();
|
||||||
|
|
||||||
$bookingPage = new BookingPage();
|
$bookingPage = new BookingPage( $guardians );
|
||||||
$loginPage = new LoginPage();
|
$loginPage = new LoginPage();
|
||||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
|
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
|
||||||
$groupClassPage = new GroupClassPage();
|
$groupClassPage = new GroupClassPage( $guardians );
|
||||||
|
$familyPage = new FamilyPage( $guardians, $questions, $answers );
|
||||||
|
|
||||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register();
|
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
|
||||||
|
|
||||||
( new UpdateChecker() )->register();
|
( new UpdateChecker() )->register();
|
||||||
( new RoleManager() )->register();
|
( new RoleManager() )->register();
|
||||||
( new RegistrationLoginGate() )->register();
|
( new RegistrationLoginGate() )->register();
|
||||||
|
( new ChildLoginGate() )->register();
|
||||||
( new StudentAdminGuard() )->register();
|
( new StudentAdminGuard() )->register();
|
||||||
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
||||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->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 ) )->register();
|
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
|
||||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
|
||||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ class AcceptanceRepository {
|
|||||||
[
|
[
|
||||||
'policy_version_id' => $acceptance->policyVersionId,
|
'policy_version_id' => $acceptance->policyVersionId,
|
||||||
'student_id' => $acceptance->studentId,
|
'student_id' => $acceptance->studentId,
|
||||||
|
'accepted_by' => $acceptance->acceptorOrStudent(),
|
||||||
'registration_type' => $acceptance->registrationType,
|
'registration_type' => $acceptance->registrationType,
|
||||||
'registration_id' => $acceptance->registrationId,
|
'registration_id' => $acceptance->registrationId,
|
||||||
'ip_address' => $acceptance->ipAddress,
|
'ip_address' => $acceptance->ipAddress,
|
||||||
'accepted_at' => current_time( 'mysql' ),
|
'accepted_at' => current_time( 'mysql' ),
|
||||||
],
|
],
|
||||||
[ '%d', '%d', '%s', '%d', '%s', '%s' ]
|
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s' ]
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->db->insert_id;
|
return $this->db->insert_id;
|
||||||
@@ -38,6 +39,19 @@ class AcceptanceRepository {
|
|||||||
return array_map( fn( PolicyAcceptance $a ): int => $this->insert( $a ), $acceptances );
|
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).
|
* Find all acceptances attached to a registration (lesson or enrolment).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ class PolicyAcceptance {
|
|||||||
public readonly int $studentId,
|
public readonly int $studentId,
|
||||||
public readonly string $registrationType,
|
public readonly string $registrationType,
|
||||||
public readonly int $registrationId,
|
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 $ipAddress = null,
|
||||||
public readonly ?string $acceptedAt = null,
|
public readonly ?string $acceptedAt = null,
|
||||||
public readonly ?int $id = null,
|
public readonly ?int $id = null,
|
||||||
@@ -35,12 +42,31 @@ class PolicyAcceptance {
|
|||||||
studentId: Val::int( $row->student_id ),
|
studentId: Val::int( $row->student_id ),
|
||||||
registrationType: Val::string( $row->registration_type ),
|
registrationType: Val::string( $row->registration_type ),
|
||||||
registrationId: Val::int( $row->registration_id ),
|
registrationId: Val::int( $row->registration_id ),
|
||||||
|
acceptedBy: Val::int( $row->accepted_by ?? 0 ),
|
||||||
ipAddress: Val::stringOrNull( $row->ip_address ),
|
ipAddress: Val::stringOrNull( $row->ip_address ),
|
||||||
acceptedAt: Val::stringOrNull( $row->accepted_at ),
|
acceptedAt: Val::stringOrNull( $row->accepted_at ),
|
||||||
id: Val::int( $row->id ),
|
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.
|
* Returns a plain array representation of the acceptance.
|
||||||
*
|
*
|
||||||
@@ -51,6 +77,7 @@ class PolicyAcceptance {
|
|||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'policy_version_id' => $this->policyVersionId,
|
'policy_version_id' => $this->policyVersionId,
|
||||||
'student_id' => $this->studentId,
|
'student_id' => $this->studentId,
|
||||||
|
'accepted_by' => $this->acceptorOrStudent(),
|
||||||
'registration_type' => $this->registrationType,
|
'registration_type' => $this->registrationType,
|
||||||
'registration_id' => $this->registrationId,
|
'registration_id' => $this->registrationId,
|
||||||
'ip_address' => $this->ipAddress,
|
'ip_address' => $this->ipAddress,
|
||||||
|
|||||||
@@ -19,20 +19,35 @@ class PolicyController {
|
|||||||
wp_die( esc_html__( 'You do not have permission to manage policies.', 'unsupervised-schedular' ) );
|
wp_die( esc_html__( 'You do not have permission to manage policies.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$notice = '';
|
||||||
|
$viewVersionId = 0;
|
||||||
|
|
||||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_policy_action' ) ) {
|
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_policy_action' ) ) {
|
||||||
$this->handleFormAction();
|
[ $notice, $viewVersionId ] = $this->handleFormAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector.
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only policy/version selectors.
|
||||||
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
|
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
|
||||||
|
if ( 0 === $viewVersionId ) {
|
||||||
|
$viewVersionId = absint( Val::int( $_GET['version_id'] ?? 0 ) );
|
||||||
|
}
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||||
|
|
||||||
$policyList = $this->policies->findAll();
|
$policyList = $this->policies->findAll();
|
||||||
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
|
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
|
||||||
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null;
|
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null;
|
||||||
|
$viewedVersion = null !== $selectedPolicy ? $this->loadVersionForPolicy( (int) $selectedPolicy->id, $viewVersionId ) : null;
|
||||||
|
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/policies.php';
|
include USC_PLUGIN_DIR . 'templates/admin/policies.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleFormAction(): void {
|
/**
|
||||||
|
* Process the posted action.
|
||||||
|
*
|
||||||
|
* @return array{string, int} Status notice, and the version to open in the
|
||||||
|
* viewer (0 to leave the current selection alone).
|
||||||
|
*/
|
||||||
|
private function handleFormAction(): array {
|
||||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||||
@@ -53,12 +68,12 @@ class PolicyController {
|
|||||||
$this->service->createPolicy( $title, $slug, $scope );
|
$this->service->createPolicy( $title, $slug, $scope );
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return [ '', 0 ];
|
||||||
}
|
}
|
||||||
|
|
||||||
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
|
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
|
||||||
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
|
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
|
||||||
return;
|
return [ '', 0 ];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( 'add_version' === $action ) {
|
if ( 'add_version' === $action ) {
|
||||||
@@ -66,6 +81,40 @@ class PolicyController {
|
|||||||
$this->service->addDraftVersion( $policyId, $body );
|
$this->service->addDraftVersion( $policyId, $body );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( 'edit_version' === $action ) {
|
||||||
|
$source = $this->loadVersionForPolicy( $policyId, absint( Val::int( $_POST['version_id'] ?? 0 ) ) );
|
||||||
|
if ( null === $source ) {
|
||||||
|
return [ '', 0 ];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) );
|
||||||
|
|
||||||
|
// A draft has never been shown to a student, so it is edited in place.
|
||||||
|
// A published (or archived) version is what students accepted, so an
|
||||||
|
// edit branches a new draft and leaves the original untouched.
|
||||||
|
if ( PolicyVersion::STATUS_DRAFT === $source->status ) {
|
||||||
|
$this->versions->updateBody( (int) $source->id, $body );
|
||||||
|
|
||||||
|
return [
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: the edited version number. */
|
||||||
|
__( 'Draft version %d was updated.', 'unsupervised-schedular' ),
|
||||||
|
$source->versionNumber
|
||||||
|
),
|
||||||
|
(int) $source->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: the version number the edit was based on. */
|
||||||
|
__( 'Your changes to version %d were saved as a new draft version.', 'unsupervised-schedular' ),
|
||||||
|
$source->versionNumber
|
||||||
|
),
|
||||||
|
$this->service->addDraftVersion( $policyId, $body ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if ( 'publish_version' === $action ) {
|
if ( 'publish_version' === $action ) {
|
||||||
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
|
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
|
||||||
if ( $versionId > 0 ) {
|
if ( $versionId > 0 ) {
|
||||||
@@ -73,5 +122,20 @@ class PolicyController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
|
||||||
|
return [ '', 0 ];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a version by id, confirming it belongs to the given policy.
|
||||||
|
*/
|
||||||
|
private function loadVersionForPolicy( int $policyId, int $versionId ): ?PolicyVersion {
|
||||||
|
if ( $versionId <= 0 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = $this->versions->findById( $versionId );
|
||||||
|
|
||||||
|
return null !== $version && $version->policyId === $policyId ? $version : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,9 +104,9 @@ class PolicyEndpoint {
|
|||||||
'policy_version_id' => $version->id,
|
'policy_version_id' => $version->id,
|
||||||
'version_number' => $version->versionNumber,
|
'version_number' => $version->versionNumber,
|
||||||
// Bodies are kses'd on every write path, but the booking JS renders
|
// Bodies are kses'd on every write path, but the booking JS renders
|
||||||
// this HTML raw — sanitise at output too so a missed write path can
|
// this HTML raw — bodyHtml() sanitises at output too, so a missed
|
||||||
// never become stored XSS.
|
// write path can never become stored XSS.
|
||||||
'body' => wp_kses_post( (string) $version->body ),
|
'body' => $version->bodyHtml(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ class PolicyVersion {
|
|||||||
return self::STATUS_PUBLISHED === $this->status;
|
return self::STATUS_PUBLISHED === $this->status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body as display-ready HTML.
|
||||||
|
*
|
||||||
|
* Policy bodies are typed into a plain textarea, so most are written as
|
||||||
|
* blank-line-separated prose with no markup at all — dropped into a page
|
||||||
|
* as-is that collapses into one unreadable run of text. Running the same
|
||||||
|
* `wpautop()` WordPress applies to post content turns those breaks into
|
||||||
|
* paragraphs, and leaves bodies that do carry markup alone.
|
||||||
|
*/
|
||||||
|
public function bodyHtml(): string {
|
||||||
|
return wpautop( wp_kses_post( (string) $this->body ) );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a plain array representation of the version.
|
* Returns a plain array representation of the version.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 . '>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,10 +58,14 @@ class RegistrationGate {
|
|||||||
/**
|
/**
|
||||||
* Persist answers and policy acceptances for a created registration.
|
* 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 array<int, string> $answers question_id => answer value
|
||||||
* @param list<int> $acceptedVersionIds Accepted policy version IDs
|
* @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 ) {
|
foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) {
|
||||||
$value = (string) ( $answers[ (int) $question->id ] ?? '' );
|
$value = (string) ( $answers[ (int) $question->id ] ?? '' );
|
||||||
if ( '' === $value ) {
|
if ( '' === $value ) {
|
||||||
@@ -90,6 +94,7 @@ class RegistrationGate {
|
|||||||
studentId: $studentId,
|
studentId: $studentId,
|
||||||
registrationType: $registrationType,
|
registrationType: $registrationType,
|
||||||
registrationId: $registrationId,
|
registrationId: $registrationId,
|
||||||
|
acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId,
|
||||||
ipAddress: $ipAddress,
|
ipAddress: $ipAddress,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ namespace Unsupervised\Schedular;
|
|||||||
|
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
|
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\PaymentEndpoint;
|
use Unsupervised\Schedular\Payment\PaymentEndpoint;
|
||||||
@@ -36,13 +38,13 @@ class RestRegistrar {
|
|||||||
private EnrollmentEndpoint $enrollmentEndpoint;
|
private EnrollmentEndpoint $enrollmentEndpoint;
|
||||||
private PaymentEndpoint $paymentEndpoint;
|
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, $offerings );
|
$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->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
|
||||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess );
|
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
|
||||||
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
|
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class Schema {
|
|||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
policy_version_id BIGINT UNSIGNED NOT NULL,
|
policy_version_id BIGINT UNSIGNED NOT NULL,
|
||||||
student_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_type VARCHAR(20) NOT NULL,
|
||||||
registration_id BIGINT UNSIGNED NOT NULL,
|
registration_id BIGINT UNSIGNED NOT NULL,
|
||||||
accepted_at DATETIME NOT NULL,
|
accepted_at DATETIME NOT NULL,
|
||||||
@@ -145,12 +146,14 @@ class Schema {
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY policy_version_id (policy_version_id),
|
KEY policy_version_id (policy_version_id),
|
||||||
KEY student_id (student_id),
|
KEY student_id (student_id),
|
||||||
|
KEY accepted_by (accepted_by),
|
||||||
KEY registration (registration_type, registration_id)
|
KEY registration (registration_type, registration_id)
|
||||||
) {$charset};",
|
) {$charset};",
|
||||||
|
|
||||||
"CREATE TABLE {$prefix}us_payments (
|
"CREATE TABLE {$prefix}us_payments (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
student_id BIGINT UNSIGNED NOT NULL,
|
student_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
instructor_id BIGINT UNSIGNED NOT NULL,
|
instructor_id BIGINT UNSIGNED NOT NULL,
|
||||||
registration_type VARCHAR(20) NOT NULL,
|
registration_type VARCHAR(20) NOT NULL,
|
||||||
registration_id BIGINT UNSIGNED NOT NULL,
|
registration_id BIGINT UNSIGNED NOT NULL,
|
||||||
@@ -172,6 +175,7 @@ class Schema {
|
|||||||
paid_at DATETIME DEFAULT NULL,
|
paid_at DATETIME DEFAULT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY student_id (student_id),
|
KEY student_id (student_id),
|
||||||
|
KEY payer_id (payer_id),
|
||||||
KEY instructor_id (instructor_id),
|
KEY instructor_id (instructor_id),
|
||||||
KEY registration (registration_type, registration_id),
|
KEY registration (registration_type, registration_id),
|
||||||
KEY status (status)
|
KEY status (status)
|
||||||
@@ -180,6 +184,7 @@ class Schema {
|
|||||||
"CREATE TABLE {$prefix}us_credits (
|
"CREATE TABLE {$prefix}us_credits (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
student_id BIGINT UNSIGNED NOT NULL,
|
student_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
remaining DECIMAL(10,2) NOT NULL DEFAULT 0,
|
remaining DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
|
currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
|
||||||
@@ -191,6 +196,7 @@ class Schema {
|
|||||||
updated_at DATETIME DEFAULT NULL,
|
updated_at DATETIME DEFAULT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY student_id (student_id),
|
KEY student_id (student_id),
|
||||||
|
KEY payer_id (payer_id),
|
||||||
KEY status (status),
|
KEY status (status),
|
||||||
KEY source_lesson_id (source_lesson_id)
|
KEY source_lesson_id (source_lesson_id)
|
||||||
) {$charset};",
|
) {$charset};",
|
||||||
@@ -229,6 +235,21 @@ class Schema {
|
|||||||
KEY status (status)
|
KEY status (status)
|
||||||
) {$charset};",
|
) {$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 (
|
"CREATE TABLE {$prefix}us_group_access (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
offering_id BIGINT UNSIGNED NOT NULL,
|
offering_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\LoginPage;
|
|||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||||
|
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||||
|
|
||||||
class ShortcodeRegistrar {
|
class ShortcodeRegistrar {
|
||||||
@@ -16,6 +17,7 @@ class ShortcodeRegistrar {
|
|||||||
private LoginPage $loginPage,
|
private LoginPage $loginPage,
|
||||||
private RegistrationPage $registrationPage,
|
private RegistrationPage $registrationPage,
|
||||||
private GroupClassPage $groupClassPage,
|
private GroupClassPage $groupClassPage,
|
||||||
|
private FamilyPage $familyPage,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
@@ -23,10 +25,14 @@ class ShortcodeRegistrar {
|
|||||||
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
|
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
|
||||||
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
|
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
|
||||||
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
|
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
|
||||||
|
add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) );
|
||||||
// Process registration submissions before output so the invite branch's
|
// Process registration submissions before output so the invite branch's
|
||||||
// auth cookie is actually sent (render() runs too late, during the_content).
|
// 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, 'maybeHandleSubmit' ] );
|
||||||
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
|
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' ] );
|
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +70,23 @@ class ShortcodeRegistrar {
|
|||||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||||
'stripeKey' => $settings->publishableKey(),
|
'stripeKey' => $settings->publishableKey(),
|
||||||
'startOfWeek' => Val::int( get_option( 'start_of_week', 1 ) ),
|
'startOfWeek' => Val::int( get_option( 'start_of_week', 1 ) ),
|
||||||
|
// The studio HST rate, so a price quoted to a student on the way in
|
||||||
|
// matches the total they are actually billed.
|
||||||
|
'taxRate' => $settings->hstRate(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Attach the shared config to the payment helper so it is defined before the
|
// Attach the shared config to the payment helper so it is defined before the
|
||||||
// booking/group scripts (which depend on it) run.
|
// booking/group scripts (which depend on it) run.
|
||||||
wp_localize_script( 'us-scheduler-payment', 'usScheduler', $data );
|
wp_localize_script( 'us-scheduler-payment', 'usScheduler', $data );
|
||||||
|
|
||||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
// Price formatting and the pay agreement, shared by booking and enrolment.
|
||||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
wp_register_script( 'us-scheduler-pricing', USC_PLUGIN_URL . 'assets/js/pricing.js', [ 'us-scheduler-payment' ], 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 );
|
||||||
|
|
||||||
|
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 (no dependencies).
|
// 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-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var list<array{date: string, slots: list<\Unsupervised\Schedular\Availability\AvailabilitySlot>}> $weekDays
|
* @var list<array{date: string, slots: list<\Unsupervised\Schedular\Availability\AvailabilitySlot>}> $weekDays
|
||||||
* @var string $prevWeek
|
* @var string $prevWeek
|
||||||
* @var string $nextWeek
|
* @var string $nextWeek
|
||||||
|
* @var string $notice Success message from the submitted action; empty when none.
|
||||||
|
* @var string $error Failure message from the submitted action; empty when none.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
|
|
||||||
$baseUrl = admin_url('admin.php?page=us-availability');
|
$baseUrl = admin_url('admin.php?page=us-availability');
|
||||||
|
|
||||||
$deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void {
|
$deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void {
|
||||||
@@ -33,9 +37,16 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1>
|
<h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1>
|
||||||
|
|
||||||
|
<?php if ('' !== $notice) : ?>
|
||||||
|
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ('' !== $error) : ?>
|
||||||
|
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Add Availability', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Add Availability', 'unsupervised-schedular'); ?></h2>
|
||||||
<p><?php esc_html_e('The window must start and end on the same day. It is split into bookable slots of the chosen lesson length — for example, 9:00 AM–4:00 PM with 60-minute lessons creates seven slots.', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('The window must start and end on the same day. It is split into bookable slots of the chosen lesson length — for example, 9:00 AM–4:00 PM with 60-minute lessons creates seven slots.', 'unsupervised-schedular'); ?></p>
|
||||||
<form method="post">
|
<form method="post" id="usc-add-availability">
|
||||||
<?php wp_nonce_field('usc_availability_action'); ?>
|
<?php wp_nonce_field('usc_availability_action'); ?>
|
||||||
<input type="hidden" name="usc_action" value="add">
|
<input type="hidden" name="usc_action" value="add">
|
||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
@@ -51,9 +62,20 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
|
|||||||
<th><label for="duration_minutes"><?php esc_html_e('Lesson length (minutes)', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="duration_minutes"><?php esc_html_e('Lesson length (minutes)', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<select name="duration_minutes" id="duration_minutes">
|
<select name="duration_minutes" id="duration_minutes">
|
||||||
<option value="30">30</option>
|
<?php foreach (AvailabilitySlot::DURATION_CHOICES as $choice) : ?>
|
||||||
<option value="60" selected>60</option>
|
<option value="<?php echo esc_attr((string) $choice); ?>" <?php selected($choice, AvailabilitySlot::DEFAULT_DURATION_MINUTES); ?>>
|
||||||
|
<?php echo esc_html((string) $choice); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
<?php
|
||||||
|
// Filled and revealed by availability-admin.js when the entered
|
||||||
|
// window is too short for every lesson length. The wording lives
|
||||||
|
// here so it stays translatable.
|
||||||
|
?>
|
||||||
|
<p class="description usc-duration-warning" id="usc-duration-warning" hidden>
|
||||||
|
<?php esc_html_e('This window is too short for any lesson length. Lengthen the window or it cannot be saved.', 'unsupervised-schedular'); ?>
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -73,7 +95,7 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
|
|||||||
<label><input type="radio" name="recurrence" value="single" checked> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
|
<label><input type="radio" name="recurrence" value="single" checked> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
|
||||||
|
|
||||||
<label><input type="radio" name="recurrence" value="weekly"> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
|
<label><input type="radio" name="recurrence" value="weekly"> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
|
||||||
<input type="number" name="weeks" min="1" max="52" value="12" style="width:5em;"> <?php esc_html_e('weeks', 'unsupervised-schedular'); ?>
|
<input type="number" name="weeks" min="1" max="<?php echo esc_attr((string) AvailabilitySlot::MAX_WEEKLY_OCCURRENCES); ?>" value="12" style="width:5em;"> <?php esc_html_e('weeks', 'unsupervised-schedular'); ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var list<\Unsupervised\Schedular\Policy\Policy> $policyList
|
* @var list<\Unsupervised\Schedular\Policy\Policy> $policyList
|
||||||
* @var \Unsupervised\Schedular\Policy\Policy|null $selectedPolicy
|
* @var \Unsupervised\Schedular\Policy\Policy|null $selectedPolicy
|
||||||
* @var list<\Unsupervised\Schedular\Policy\PolicyVersion>|null $policyVersions
|
* @var list<\Unsupervised\Schedular\Policy\PolicyVersion>|null $policyVersions
|
||||||
|
* @var \Unsupervised\Schedular\Policy\PolicyVersion|null $viewedVersion Version opened in the viewer, if any.
|
||||||
|
* @var string $notice Status message from the last save.
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></h1>
|
<h1><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></h1>
|
||||||
|
|
||||||
|
<?php if ('' !== $notice) : ?>
|
||||||
|
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Add Policy', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Add Policy', 'unsupervised-schedular'); ?></h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||||
@@ -98,6 +104,18 @@ if (! defined('ABSPATH')) {
|
|||||||
<td><?php echo esc_html($version->status); ?></td>
|
<td><?php echo esc_html($version->status); ?></td>
|
||||||
<td><?php echo $version->publishedAt ? esc_html($version->publishedAt) : '—'; ?></td>
|
<td><?php echo $version->publishedAt ? esc_html($version->publishedAt) : '—'; ?></td>
|
||||||
<td>
|
<td>
|
||||||
|
<a href="<?php echo esc_url(
|
||||||
|
add_query_arg(
|
||||||
|
[
|
||||||
|
'page' => 'us-policies',
|
||||||
|
'policy_id' => (string) $selectedPolicy->id,
|
||||||
|
'version_id' => (string) $version->id,
|
||||||
|
],
|
||||||
|
admin_url('admin.php')
|
||||||
|
)
|
||||||
|
); ?>" class="button button-small">
|
||||||
|
<?php esc_html_e('View', 'unsupervised-schedular'); ?>
|
||||||
|
</a>
|
||||||
<?php if (PolicyVersion::STATUS_PUBLISHED !== $version->status) : ?>
|
<?php if (PolicyVersion::STATUS_PUBLISHED !== $version->status) : ?>
|
||||||
<form method="post" style="display:inline;">
|
<form method="post" style="display:inline;">
|
||||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||||
@@ -117,5 +135,48 @@ if (! defined('ABSPATH')) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (null !== $viewedVersion) : ?>
|
||||||
|
<hr>
|
||||||
|
<h3>
|
||||||
|
<?php echo esc_html(sprintf(
|
||||||
|
/* translators: 1: version number, 2: version status. */
|
||||||
|
__('Version %1$d (%2$s)', 'unsupervised-schedular'),
|
||||||
|
$viewedVersion->versionNumber,
|
||||||
|
$viewedVersion->status
|
||||||
|
)); ?>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<?php // Rendered exactly as the booking and signup gates render it, so this doubles as a preview. ?>
|
||||||
|
<div class="us-policy-version-body" style="background:#fff;border:1px solid #c3c4c7;padding:0 1em;max-width:50em;overflow-wrap:break-word;">
|
||||||
|
<?php echo wp_kses_post($viewedVersion->bodyHtml()); ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php $editingDraft = PolicyVersion::STATUS_DRAFT === $viewedVersion->status; ?>
|
||||||
|
<h4><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></h4>
|
||||||
|
<p class="description">
|
||||||
|
<?php
|
||||||
|
echo esc_html(
|
||||||
|
$editingDraft
|
||||||
|
? __('This version is still a draft, so your changes are saved to it directly.', 'unsupervised-schedular')
|
||||||
|
: __('This version has been published, so saving an edit creates a new draft version from this text and leaves the published one untouched.', 'unsupervised-schedular')
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||||
|
<input type="hidden" name="usc_action" value="edit_version">
|
||||||
|
<input type="hidden" name="policy_id" value="<?php echo esc_attr((string) $selectedPolicy->id); ?>">
|
||||||
|
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $viewedVersion->id); ?>">
|
||||||
|
<textarea name="body" rows="12" class="large-text"><?php echo esc_textarea((string) $viewedVersion->body); ?></textarea>
|
||||||
|
<?php
|
||||||
|
submit_button(
|
||||||
|
$editingDraft
|
||||||
|
? esc_html__('Save Draft', 'unsupervised-schedular')
|
||||||
|
: esc_html__('Save as New Draft', 'unsupervised-schedular')
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var list<array{question: string, answer: string, context: string}> $intake
|
* @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, 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 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 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, date_of_birth: 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 string $backUrl
|
||||||
* @var bool $canBilling
|
* @var bool $canBilling
|
||||||
* @var string $billingOverride
|
* @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); ?>
|
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<?php if ($guardian !== null || ! empty($children)) : ?>
|
||||||
|
<h2><?php esc_html_e('Family', '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 child 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['date_of_birth'] !== '') : ?>
|
||||||
|
<span class="description"><?php echo esc_html($child['date_of_birth']); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Registration Information', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Registration Information', 'unsupervised-schedular'); ?></h2>
|
||||||
<?php if (empty($registrationInfo)) : ?>
|
<?php if (empty($registrationInfo)) : ?>
|
||||||
<p><?php esc_html_e('No registration questions are configured.', 'unsupervised-schedular'); ?></p>
|
<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>'
|
'<strong>' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '</strong>'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
|
<?php if ($payer['id'] !== (int) $student->ID) : ?>
|
||||||
|
<span class="description">
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %s: name of the parent/guardian whose account holds the balance. */
|
||||||
|
esc_html__('Held on %s’s account — the family 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>
|
<span class="description"><?php esc_html_e('Credit from cancelled paid lessons is applied automatically to upcoming scheduled billing.', 'unsupervised-schedular'); ?></span>
|
||||||
</p>
|
</p>
|
||||||
<?php if (! empty($credits)) : ?>
|
<?php if (! empty($credits)) : ?>
|
||||||
|
|||||||
@@ -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, date_of_birth: string, relationship: string}>}> $students
|
||||||
* @var string $pageSlug
|
* @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__('Child of %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">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('Students', 'unsupervised-schedular'); ?></h1>
|
<h1><?php esc_html_e('Students', 'unsupervised-schedular'); ?></h1>
|
||||||
@@ -21,6 +51,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Family', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Registered', '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('Upcoming lessons', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Active enrolments', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Active enrolments', 'unsupervised-schedular'); ?></th>
|
||||||
@@ -32,6 +63,12 @@ if (! defined('ABSPATH')) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="<?php echo esc_url($detailUrl); ?>"><?php echo esc_html($student['name']); ?></a></td>
|
<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 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($student['registered']); ?></td>
|
||||||
<td><?php echo esc_html((string) $student['upcoming']); ?></td>
|
<td><?php echo esc_html((string) $student['upcoming']); ?></td>
|
||||||
<td><?php echo esc_html((string) $student['enrolments']); ?></td>
|
<td><?php echo esc_html((string) $student['enrolments']); ?></td>
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ if (! defined('ABSPATH')) {
|
|||||||
/** @var bool $showTypeFilter Whether the "Show Only" lesson-type filter is offered. */
|
/** @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 $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 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) : ?>
|
<?php if ($showUpcoming) : ?>
|
||||||
<div id="us-my-lessons"></div>
|
<div id="us-my-lessons"></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -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, date_of_birth: 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 family', '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 children 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'); ?></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-dob-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="date" name="child_dob" id="us-edit-dob-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['date_of_birth']); ?>">
|
||||||
|
</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['date_of_birth'] !== '') : ?>
|
||||||
|
<span class="us-family-child-dob"><?php echo esc_html($child['date_of_birth']); ?></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 child', 'unsupervised-schedular'); ?></h4>
|
||||||
|
<p>
|
||||||
|
<label for="us-child-name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="text" name="child_name" id="us-child-name" required>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="us-child-dob"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="date" name="child_dob" id="us-child-dob">
|
||||||
|
</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 child', '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 child', 'unsupervised-schedular'); ?></button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -6,8 +6,13 @@ if (! defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @var int $offeringId Offering id when the page is restricted to a single class; 0 for the full catalog. */
|
/** @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) . '"' : ''; ?>>
|
||||||
<div id="us-group-list">
|
<div id="us-group-list">
|
||||||
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Unsupervised\Schedular\Registration\Question;
|
use Unsupervised\Schedular\Registration\Question;
|
||||||
|
use Unsupervised\Schedular\Registration\QuestionField;
|
||||||
|
|
||||||
if (! defined('ABSPATH')) {
|
if (! defined('ABSPATH')) {
|
||||||
exit;
|
exit;
|
||||||
@@ -22,39 +23,6 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var list<Question> $accountQuestions Studio-wide questions answered as step two.
|
* @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">
|
<div class="us-register-form">
|
||||||
<?php if ($successType === 'confirm') : ?>
|
<?php if ($successType === 'confirm') : ?>
|
||||||
@@ -102,13 +70,55 @@ $renderQuestionField = static function (Question $question): void {
|
|||||||
<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="8" required>
|
||||||
</p>
|
</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 children", '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 child 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 child" button clones. */ ?>
|
||||||
|
<div class="us-child" data-child-index="0">
|
||||||
|
<p>
|
||||||
|
<label for="us-child-0-name"><?php esc_html_e("Child's name", 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="text" name="children[0][name]" id="us-child-0-name">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="us-child-0-dob"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="date" name="children[0][dob]" id="us-child-0-dob">
|
||||||
|
</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 child', 'unsupervised-schedular'); ?></button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<?php if (! empty($policyForms)) : ?>
|
<?php if (! empty($policyForms)) : ?>
|
||||||
<fieldset class="us-policies">
|
<fieldset class="us-policies">
|
||||||
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
|
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
|
||||||
<?php foreach ($policyForms as $form) : ?>
|
<?php foreach ($policyForms as $form) : ?>
|
||||||
<div class="us-policy">
|
<div class="us-policy">
|
||||||
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
||||||
<div class="us-policy-body"><?php echo wp_kses_post((string) $form['version']->body); ?></div>
|
<div class="us-policy-body"><?php echo wp_kses_post($form['version']->bodyHtml()); ?></div>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
||||||
<?php
|
<?php
|
||||||
@@ -124,6 +134,17 @@ $renderQuestionField = static function (Question $question): void {
|
|||||||
<?php if ($hasQuestions) : ?>
|
<?php if ($hasQuestions) : ?>
|
||||||
<p>
|
<p>
|
||||||
<button type="button" class="us-reg-next"><?php esc_html_e('Next', 'unsupervised-schedular'); ?></button>
|
<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>
|
</p>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<p>
|
<p>
|
||||||
@@ -137,7 +158,10 @@ $renderQuestionField = static function (Question $question): void {
|
|||||||
<fieldset class="us-reg-questions">
|
<fieldset class="us-reg-questions">
|
||||||
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
|
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
|
||||||
<?php foreach ($accountQuestions as $question) : ?>
|
<?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; ?>
|
<?php endforeach; ?>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ use Unsupervised\Schedular\Auth\InviteRepository;
|
|||||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||||
use Unsupervised\Schedular\Policy\Policy;
|
use Unsupervised\Schedular\Policy\Policy;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||||
@@ -37,6 +39,8 @@ class RegistrationPageTest extends TestCase
|
|||||||
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
||||||
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
||||||
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
|
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
|
||||||
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||||
|
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||||
|
|
||||||
$invites = Mockery::mock(InviteRepository::class);
|
$invites = Mockery::mock(InviteRepository::class);
|
||||||
$policies = Mockery::mock(PolicyRepository::class);
|
$policies = Mockery::mock(PolicyRepository::class);
|
||||||
@@ -61,6 +65,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
|
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
|
||||||
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
|
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
|
||||||
|
$this->ctx['guardians'] = Mockery::mock(GuardianService::class);
|
||||||
|
|
||||||
$this->ctx['page'] = new RegistrationPage(
|
$this->ctx['page'] = new RegistrationPage(
|
||||||
$invites,
|
$invites,
|
||||||
@@ -72,6 +77,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
$questions,
|
$questions,
|
||||||
$answers,
|
$answers,
|
||||||
$access,
|
$access,
|
||||||
|
$this->ctx['guardians'],
|
||||||
);
|
);
|
||||||
|
|
||||||
$_POST = [];
|
$_POST = [];
|
||||||
@@ -442,6 +448,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
$this->ctx['questions'],
|
$this->ctx['questions'],
|
||||||
$this->ctx['answers'],
|
$this->ctx['answers'],
|
||||||
$this->ctx['access'],
|
$this->ctx['access'],
|
||||||
|
$this->ctx['guardians'],
|
||||||
]
|
]
|
||||||
)->makePartial()->shouldAllowMockingProtectedMethods();
|
)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||||
|
|
||||||
@@ -495,11 +502,61 @@ class RegistrationPageTest extends TestCase
|
|||||||
Functions\when('is_user_logged_in')->justReturn(true);
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||||
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
|
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
|
||||||
|
Functions\when('get_the_title')->justReturn('Book a Lesson');
|
||||||
|
|
||||||
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||||
|
|
||||||
self::assertStringContainsString('now logged in', $html);
|
self::assertStringContainsString('now logged in', $html);
|
||||||
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
||||||
|
// The link names its destination rather than saying "your account".
|
||||||
|
self::assertStringContainsString('Continue to Book a Lesson', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testContinueLinkFallsBackToGenericWordingForAnUntitledPage(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||||
|
Functions\when('get_permalink')->justReturn('http://home.test/welcome/');
|
||||||
|
Functions\when('get_the_title')->justReturn(' ');
|
||||||
|
|
||||||
|
// An untitled page must not produce a link reading "Continue to ".
|
||||||
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Continue to your account', $html);
|
||||||
|
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAlreadyLoggedInVisitorIsLinkedToTheChosenPage(): void
|
||||||
|
{
|
||||||
|
// No us_registered flag: someone who simply happens to be signed in and
|
||||||
|
// lands on the registration page. They still need a way onward.
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||||
|
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
|
||||||
|
Functions\when('get_the_title')->justReturn('Book a Lesson');
|
||||||
|
|
||||||
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('already have an account', $html);
|
||||||
|
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
||||||
|
self::assertStringContainsString('Continue to Book a Lesson', $html);
|
||||||
|
// Not the just-registered message — that branch needs its own flag.
|
||||||
|
self::assertStringNotContainsString('us-success', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAlreadyLoggedInVisitorGetsNoLinkWithoutAChosenPage(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||||
|
Functions\when('get_permalink')->justReturn(false);
|
||||||
|
|
||||||
|
// A deleted page resolves to false, which must not become a broken link.
|
||||||
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('already have an account', $html);
|
||||||
|
self::assertStringNotContainsString('<a href', $html);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('<a href', $this->ctx['page']->render([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testContinueUrlIsNullWithoutAResolvablePage(): void
|
public function testContinueUrlIsNullWithoutAResolvablePage(): void
|
||||||
@@ -548,4 +605,192 @@ class RegistrationPageTest extends TestCase
|
|||||||
self::assertStringContainsString('Ask the front desk for a link.', $html);
|
self::assertStringContainsString('Ask the front desk for a link.', $html);
|
||||||
self::assertStringNotContainsString('by invitation only', $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' => 'password123',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_is_guardian' => '1',
|
||||||
|
'children' => [
|
||||||
|
['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']],
|
||||||
|
['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']],
|
||||||
|
// An untouched spare block is dropped, not rejected.
|
||||||
|
['name' => ' ', 'dob' => '', '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-04-02')->andReturn(101);
|
||||||
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGuardianSignupWithNoChildrenIsRejected(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'password123',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_is_guardian' => '1',
|
||||||
|
'children' => [['name' => '', 'dob' => '', '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 child', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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' => 'password123',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_is_guardian' => '1',
|
||||||
|
'children' => [
|
||||||
|
['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']],
|
||||||
|
['name' => 'Alan', 'dob' => '', '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 child', $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' => 'password123',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_is_guardian' => '1',
|
||||||
|
'children' => [
|
||||||
|
['name' => 'Ada', 'dob' => '', 'answers' => []],
|
||||||
|
['name' => 'Alan', 'dob' => '', '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', '')->andReturn(101);
|
||||||
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')
|
||||||
|
->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' => 'password123',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_is_guardian' => '1',
|
||||||
|
'accept' => [3],
|
||||||
|
'children' => [['name' => 'Ada', 'dob' => '', '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' => 'password123', '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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class StudentHistoryTest extends TestCase
|
|||||||
public function testPolicyAcceptancesResolvePolicyTitleAndVersion(): void
|
public function testPolicyAcceptancesResolvePolicyTitleAndVersion(): void
|
||||||
{
|
{
|
||||||
$this->acceptances->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
$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)
|
$this->policyVersions->shouldReceive('findById')->with(9)
|
||||||
->andReturn(new PolicyVersion(2, 3, null, PolicyVersion::STATUS_PUBLISHED, id: 9));
|
->andReturn(new PolicyVersion(2, 3, null, PolicyVersion::STATUS_PUBLISHED, id: 9));
|
||||||
@@ -177,9 +177,9 @@ class StudentHistoryTest extends TestCase
|
|||||||
Payment::REG_LESSON,
|
Payment::REG_LESSON,
|
||||||
12,
|
12,
|
||||||
100.00,
|
100.00,
|
||||||
'CAD',
|
currency: 'CAD',
|
||||||
Payment::METHOD_CARD,
|
method: Payment::METHOD_CARD,
|
||||||
Payment::STATUS_PAID,
|
status: Payment::STATUS_PAID,
|
||||||
taxRate: 13.0,
|
taxRate: 13.0,
|
||||||
taxAmount: 13.00,
|
taxAmount: 13.00,
|
||||||
receiptNumber: 'USC-7',
|
receiptNumber: 'USC-7',
|
||||||
@@ -231,7 +231,7 @@ class StudentHistoryTest extends TestCase
|
|||||||
public function testCreditsBuildDisplayRows(): void
|
public function testCreditsBuildDisplayRows(): void
|
||||||
{
|
{
|
||||||
$this->credits->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
$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);
|
$rows = $this->history->credits(5);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Mockery;
|
|||||||
use Unsupervised\Schedular\Availability\AvailabilityController;
|
use Unsupervised\Schedular\Availability\AvailabilityController;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
|
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ class AvailabilityControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->repository = Mockery::mock(AvailabilityRepository::class);
|
$this->repository = Mockery::mock(AvailabilityRepository::class);
|
||||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||||
$this->controller = new AvailabilityController($this->repository, $this->offerings);
|
$this->controller = new AvailabilityController($this->repository, $this->offerings, new WindowValidator($this->offerings));
|
||||||
|
|
||||||
$_POST = [];
|
$_POST = [];
|
||||||
$_GET = [];
|
$_GET = [];
|
||||||
@@ -39,6 +40,7 @@ class AvailabilityControllerTest extends TestCase
|
|||||||
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||||
Functions\when('get_option')->justReturn(1);
|
Functions\when('get_option')->justReturn(1);
|
||||||
Functions\when('current_time')->justReturn('2026-07-06');
|
Functions\when('current_time')->justReturn('2026-07-06');
|
||||||
|
Functions\when('selected')->justReturn('');
|
||||||
Functions\when('admin_url')->justReturn('admin.php?page=us-availability');
|
Functions\when('admin_url')->justReturn('admin.php?page=us-availability');
|
||||||
Functions\when('add_query_arg')->justReturn('admin.php?page=us-availability&usc_view=week');
|
Functions\when('add_query_arg')->justReturn('admin.php?page=us-availability&usc_view=week');
|
||||||
Functions\when('wp_nonce_field')->justReturn('');
|
Functions\when('wp_nonce_field')->justReturn('');
|
||||||
@@ -131,6 +133,159 @@ class AvailabilityControllerTest extends TestCase
|
|||||||
self::assertStringNotContainsString('name="slot_ids[]" form="usc-bulk-delete-form" value="6"', $html);
|
self::assertStringNotContainsString('name="slot_ids[]" form="usc-bulk-delete-form" value="6"', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reported bug: a 30-minute window submitted with the lesson length left
|
||||||
|
* on 60 saved nothing and said nothing. It must now say why.
|
||||||
|
*/
|
||||||
|
public function testAddShowsAnErrorWhenTheWindowIsShorterThanTheLessonLength(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'add',
|
||||||
|
'start_dt' => '2026-09-10T17:30',
|
||||||
|
'end_dt' => '2026-09-10T18:00',
|
||||||
|
'duration_minutes' => '60',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldNotReceive('createFromWindow');
|
||||||
|
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('notice-error', $html);
|
||||||
|
self::assertStringContainsString('60-minute lesson length', $html);
|
||||||
|
self::assertStringNotContainsString('notice-success', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{array<string, string>, string}> */
|
||||||
|
public static function invalidWindows(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'unparseable start' => [['start_dt' => 'whenever', 'end_dt' => '2026-09-10T18:00'], 'valid start and end'],
|
||||||
|
'end before start' => [['start_dt' => '2026-09-10T18:00', 'end_dt' => '2026-09-10T17:00'], 'after the start time'],
|
||||||
|
'spans two days' => [['start_dt' => '2026-09-10T23:00', 'end_dt' => '2026-09-11T01:00'], 'same day'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each of these used to be a bare `return` — the page reloaded unchanged and
|
||||||
|
* the instructor had no way to tell the save had failed.
|
||||||
|
*
|
||||||
|
* @dataProvider invalidWindows
|
||||||
|
*
|
||||||
|
* @param array<string, string> $fields
|
||||||
|
*/
|
||||||
|
public function testAddReportsEveryRejectedWindow(array $fields, string $expected): void
|
||||||
|
{
|
||||||
|
$_POST = array_merge([ 'usc_action' => 'add', 'duration_minutes' => '30' ], $fields);
|
||||||
|
|
||||||
|
$this->repository->shouldNotReceive('createFromWindow');
|
||||||
|
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('notice-error', $html);
|
||||||
|
self::assertStringContainsString($expected, $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddRejectsAnOfferingTheInstructorDoesNotOwn(): void
|
||||||
|
{
|
||||||
|
// The REST endpoint always checked this; the admin form never did, so a
|
||||||
|
// crafted POST could tie a slot to another instructor's offering.
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'add',
|
||||||
|
'start_dt' => '2026-09-10T17:00',
|
||||||
|
'end_dt' => '2026-09-10T18:00',
|
||||||
|
'duration_minutes' => '60',
|
||||||
|
'offering_id' => '8',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn(null);
|
||||||
|
$this->repository->shouldNotReceive('createFromWindow');
|
||||||
|
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('notice-error', $html);
|
||||||
|
self::assertStringContainsString('not available', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddReportsHowManySlotsWereCreated(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'add',
|
||||||
|
'start_dt' => '2026-09-10T17:00',
|
||||||
|
'end_dt' => '2026-09-10T19:00',
|
||||||
|
'duration_minutes' => '60',
|
||||||
|
'recurrence' => 'weekly',
|
||||||
|
'weeks' => '41',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('createFromWindow')->once()
|
||||||
|
->with(Mockery::type(AvailabilitySlot::class), true, 41)
|
||||||
|
->andReturn(range(1, 82));
|
||||||
|
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('notice-success', $html);
|
||||||
|
self::assertStringContainsString('Added 82 bookable slots.', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddReportsAFailedWrite(): void
|
||||||
|
{
|
||||||
|
// A valid window always splits into at least one slot, so an empty result
|
||||||
|
// means the inserts themselves failed.
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'add',
|
||||||
|
'start_dt' => '2026-09-10T17:00',
|
||||||
|
'end_dt' => '2026-09-10T18:00',
|
||||||
|
'duration_minutes' => '60',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('createFromWindow')->once()->andReturn([]);
|
||||||
|
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('notice-error', $html);
|
||||||
|
self::assertStringContainsString('could not be saved', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSingleDeleteReportsAFailureToDelete(): void
|
||||||
|
{
|
||||||
|
$_POST = [ 'usc_action' => 'delete', 'slot_id' => '7' ];
|
||||||
|
|
||||||
|
$other = new AvailabilitySlot(instructorId: 4, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 7);
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('findById')->once()->with(7)->andReturn($other);
|
||||||
|
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('notice-error', $html);
|
||||||
|
self::assertStringContainsString('could not be deleted', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBulkDeleteReportsBothHalvesOfAPartialResult(): void
|
||||||
|
{
|
||||||
|
$_POST = [ 'usc_action' => 'bulk_delete', 'slot_ids' => ['5', '7'] ];
|
||||||
|
|
||||||
|
$owned = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 5);
|
||||||
|
$booked = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 10:00:00', endDt: '2026-07-08 11:00:00', isBooked: true, id: 7);
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('findById')->once()->with(5)->andReturn($owned);
|
||||||
|
$this->repository->shouldReceive('findById')->once()->with(7)->andReturn($booked);
|
||||||
|
$this->repository->shouldReceive('delete')->once()->with(5)->andReturn(true);
|
||||||
|
// The repository refuses a booked row, reporting it by returning false.
|
||||||
|
$this->repository->shouldReceive('delete')->once()->with(7)->andReturn(false);
|
||||||
|
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('1 slot deleted.', $html);
|
||||||
|
self::assertStringContainsString('1 slot could not be deleted', $html);
|
||||||
|
}
|
||||||
|
|
||||||
private function render(): string
|
private function render(): string
|
||||||
{
|
{
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Mockery;
|
|||||||
use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
|
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ class AvailabilityEndpointTest extends TestCase
|
|||||||
|
|
||||||
$this->repository = Mockery::mock(AvailabilityRepository::class);
|
$this->repository = Mockery::mock(AvailabilityRepository::class);
|
||||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||||
$this->endpoint = new AvailabilityEndpoint($this->repository, $this->offerings);
|
$this->endpoint = new AvailabilityEndpoint($this->repository, new WindowValidator($this->offerings));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateRejectsWindowSpanningMultipleDays(): void
|
public function testCreateRejectsWindowSpanningMultipleDays(): void
|
||||||
|
|||||||
@@ -49,6 +49,101 @@ class AvailabilityRepositoryTest extends TestCase
|
|||||||
self::assertSame(42, $result);
|
self::assertSame(42, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testInsertReturnsZeroWhenTheWriteFails(): void
|
||||||
|
{
|
||||||
|
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
|
||||||
|
|
||||||
|
// wpdb::insert returns false on error, but insert_id still holds the
|
||||||
|
// previous statement's id — returning it made a failed write look like a
|
||||||
|
// successful one.
|
||||||
|
$this->db->shouldReceive('insert')->once()->andReturn(false);
|
||||||
|
$this->db->insert_id = 42;
|
||||||
|
|
||||||
|
$slot = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30);
|
||||||
|
|
||||||
|
self::assertSame(0, $this->repo->insert($slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateFromWindowOmitsChunksThatFailedToInsert(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
|
||||||
|
|
||||||
|
// Three chunks; the middle write fails.
|
||||||
|
$results = [null, false, null];
|
||||||
|
$ids = [11, 13];
|
||||||
|
|
||||||
|
$this->db->shouldReceive('insert')
|
||||||
|
->times(3)
|
||||||
|
->andReturnUsing(function () use (&$results, &$ids) {
|
||||||
|
$outcome = array_shift($results);
|
||||||
|
|
||||||
|
if (false !== $outcome) {
|
||||||
|
$this->db->insert_id = array_shift($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $outcome;
|
||||||
|
});
|
||||||
|
|
||||||
|
$window = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:30:00', 30);
|
||||||
|
|
||||||
|
self::assertSame([11, 13], $this->repo->createFromWindow($window));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWeeklySeriesIsClampedToTheMaximum(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
|
||||||
|
|
||||||
|
// The form's max is advisory; a hand-crafted POST could ask for any
|
||||||
|
// number, so the ceiling is enforced here.
|
||||||
|
$next = 1;
|
||||||
|
$this->db->shouldReceive('insert')
|
||||||
|
->times(AvailabilitySlot::MAX_WEEKLY_OCCURRENCES)
|
||||||
|
->andReturnUsing(function () use (&$next) {
|
||||||
|
$this->db->insert_id = $next++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
$this->db->shouldReceive('update')->once();
|
||||||
|
|
||||||
|
$first = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 60);
|
||||||
|
|
||||||
|
self::assertCount(
|
||||||
|
AvailabilitySlot::MAX_WEEKLY_OCCURRENCES,
|
||||||
|
$this->repo->createWeeklySeries($first, 10000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWeeklySeriesGroupsOnTheFirstRowThatActuallyWrote(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
|
||||||
|
|
||||||
|
// The first insert fails. A failed row must not become the recurrence
|
||||||
|
// group (its id is 0), which would orphan every later occurrence.
|
||||||
|
$results = [false, null, null];
|
||||||
|
$ids = [21, 22];
|
||||||
|
|
||||||
|
$this->db->shouldReceive('insert')
|
||||||
|
->times(3)
|
||||||
|
->andReturnUsing(function () use (&$results, &$ids) {
|
||||||
|
$outcome = array_shift($results);
|
||||||
|
|
||||||
|
if (false !== $outcome) {
|
||||||
|
$this->db->insert_id = array_shift($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $outcome;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The group is set from row 21 — the first that survived.
|
||||||
|
$this->db->shouldReceive('update')
|
||||||
|
->once()
|
||||||
|
->with('wp_us_availability', ['recurrence_group' => 21], ['id' => 21], ['%d'], ['%d']);
|
||||||
|
|
||||||
|
$first = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 60);
|
||||||
|
|
||||||
|
self::assertSame([21, 22], $this->repo->createWeeklySeries($first, 3));
|
||||||
|
}
|
||||||
|
|
||||||
public function testCreateWeeklySeriesInsertsWeeklyAndSharesGroup(): void
|
public function testCreateWeeklySeriesInsertsWeeklyAndSharesGroup(): void
|
||||||
{
|
{
|
||||||
Functions\when('current_time')->justReturn('2026-04-07 12:00:00');
|
Functions\when('current_time')->justReturn('2026-04-07 12:00:00');
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Availability;
|
||||||
|
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
|
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||||
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class WindowValidatorTest extends TestCase
|
||||||
|
{
|
||||||
|
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||||
|
private WindowValidator $validator;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||||
|
$this->validator = new WindowValidator($this->offerings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reported bug: 5:30–6:00 PM submitted with the length select left on
|
||||||
|
* its 60-minute default. The window fits no lesson, so it used to persist
|
||||||
|
* nothing at all and say nothing.
|
||||||
|
*/
|
||||||
|
public function testRejectsAWindowShorterThanTheLessonLength(): void
|
||||||
|
{
|
||||||
|
$result = $this->validator->validate(3, '2026-09-10 17:30', '2026-09-10 18:00', 60, 0);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_window', $result->get_error_code());
|
||||||
|
// The message names the length actually chosen, so the fix is obvious.
|
||||||
|
self::assertStringContainsString('60-minute', $result->get_error_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAcceptsThatSameWindowAtAFittingLessonLength(): void
|
||||||
|
{
|
||||||
|
$result = $this->validator->validate(3, '2026-09-10 17:30', '2026-09-10 18:00', 30, 0);
|
||||||
|
|
||||||
|
self::assertInstanceOf(AvailabilitySlot::class, $result);
|
||||||
|
self::assertSame('2026-09-10 17:30:00', $result->startDt);
|
||||||
|
self::assertSame('2026-09-10 18:00:00', $result->endDt);
|
||||||
|
self::assertSame(30, $result->durationMinutes);
|
||||||
|
self::assertNull($result->offeringId);
|
||||||
|
self::assertCount(1, $result->splitByDuration());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, string}> */
|
||||||
|
public static function badDateTimes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'empty start' => ['', '2026-09-10 18:00'],
|
||||||
|
'empty end' => ['2026-09-10 17:00', ''],
|
||||||
|
'unparseable start' => ['tomorrow', '2026-09-10 18:00'],
|
||||||
|
'unparseable end' => ['2026-09-10 17:00', 'not a date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider badDateTimes */
|
||||||
|
public function testRejectsUnusableDateTimes(string $start, string $end): void
|
||||||
|
{
|
||||||
|
$result = $this->validator->validate(3, $start, $end, 30, 0);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_datetime', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsAnEndAtOrBeforeTheStart(): void
|
||||||
|
{
|
||||||
|
$backwards = $this->validator->validate(3, '2026-09-10 18:00', '2026-09-10 17:00', 30, 0);
|
||||||
|
$identical = $this->validator->validate(3, '2026-09-10 18:00', '2026-09-10 18:00', 30, 0);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $backwards);
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $identical);
|
||||||
|
self::assertSame('invalid_datetime', $backwards->get_error_code());
|
||||||
|
self::assertSame('invalid_datetime', $identical->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsAWindowSpanningTwoDays(): void
|
||||||
|
{
|
||||||
|
$result = $this->validator->validate(3, '2026-09-10 23:00', '2026-09-11 01:00', 30, 0);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_window', $result->get_error_code());
|
||||||
|
self::assertStringContainsString('same day', $result->get_error_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsAnOfferingOwnedBySomeoneElse(): void
|
||||||
|
{
|
||||||
|
// Instructor 3 posting instructor 9's offering: accepting it would let a
|
||||||
|
// slot inherit another instructor's price and payment routing.
|
||||||
|
$this->offerings->shouldReceive('findById')->once()->with(8)
|
||||||
|
->andReturn(new Offering(instructorId: 9, title: 'Theirs', kind: Offering::KIND_PRIVATE_LESSON, id: 8));
|
||||||
|
|
||||||
|
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_offering', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsAnOfferingThatDoesNotExist(): void
|
||||||
|
{
|
||||||
|
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn(null);
|
||||||
|
|
||||||
|
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_offering', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsAnOfferingTheInstructorOwns(): void
|
||||||
|
{
|
||||||
|
$this->offerings->shouldReceive('findById')->once()->with(8)
|
||||||
|
->andReturn(new Offering(instructorId: 3, title: 'Mine', kind: Offering::KIND_PRIVATE_LESSON, id: 8));
|
||||||
|
|
||||||
|
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
|
||||||
|
|
||||||
|
self::assertInstanceOf(AvailabilitySlot::class, $result);
|
||||||
|
self::assertSame(8, $result->offeringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFallsBackToTheDefaultLessonLength(): void
|
||||||
|
{
|
||||||
|
$result = $this->validator->validate(3, '2026-09-10 09:00', '2026-09-10 10:00', 0, 0);
|
||||||
|
|
||||||
|
self::assertInstanceOf(AvailabilitySlot::class, $result);
|
||||||
|
self::assertSame(AvailabilitySlot::DEFAULT_DURATION_MINUTES, $result->durationMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAcceptsTheDatetimeLocalFormTheFormActuallySubmits(): void
|
||||||
|
{
|
||||||
|
// The browser posts `Y-m-d\TH:i`, not the canonical space-separated form.
|
||||||
|
$result = $this->validator->validate(3, '2026-09-10T17:00', '2026-09-10T18:00', 60, 0);
|
||||||
|
|
||||||
|
self::assertInstanceOf(AvailabilitySlot::class, $result);
|
||||||
|
self::assertSame('2026-09-10 17:00:00', $result->startDt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Auth\RegistrationPage;
|
|||||||
use Unsupervised\Schedular\BlockRegistrar;
|
use Unsupervised\Schedular\BlockRegistrar;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||||
|
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test double exposing editor-preview mode as a switch (the real detection
|
* Test double exposing editor-preview mode as a switch (the real detection
|
||||||
@@ -41,6 +42,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
private LoginPage&Mockery\MockInterface $loginPage;
|
private LoginPage&Mockery\MockInterface $loginPage;
|
||||||
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
||||||
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
||||||
|
private FamilyPage&Mockery\MockInterface $familyPage;
|
||||||
private TestableBlockRegistrar $registrar;
|
private TestableBlockRegistrar $registrar;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -51,6 +53,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
$this->loginPage = Mockery::mock(LoginPage::class);
|
$this->loginPage = Mockery::mock(LoginPage::class);
|
||||||
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
||||||
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
||||||
|
$this->familyPage = Mockery::mock(FamilyPage::class);
|
||||||
|
|
||||||
// Most requests are not a just-finished registration; the tests that
|
// Most requests are not a just-finished registration; the tests that
|
||||||
// exercise that path override this.
|
// exercise that path override this.
|
||||||
@@ -63,6 +66,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
$this->loginPage,
|
$this->loginPage,
|
||||||
$this->registrationPage,
|
$this->registrationPage,
|
||||||
$this->groupClassPage,
|
$this->groupClassPage,
|
||||||
|
$this->familyPage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
$this->registrar->register();
|
$this->registrar->register();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRegisterBlocksRegistersAllFourBlocksWithAssets(): void
|
public function testRegisterBlocksRegistersAllBlocksWithAssets(): void
|
||||||
{
|
{
|
||||||
Functions\expect('wp_register_script')
|
Functions\expect('wp_register_script')
|
||||||
->once()
|
->once()
|
||||||
@@ -111,6 +115,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
'us-scheduler/student-login',
|
'us-scheduler/student-login',
|
||||||
'us-scheduler/student-register',
|
'us-scheduler/student-register',
|
||||||
'us-scheduler/group-classes',
|
'us-scheduler/group-classes',
|
||||||
|
'us-scheduler/family',
|
||||||
],
|
],
|
||||||
array_keys($registered)
|
array_keys($registered)
|
||||||
);
|
);
|
||||||
@@ -207,6 +212,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
$this->loginPage,
|
$this->loginPage,
|
||||||
$this->registrationPage,
|
$this->registrationPage,
|
||||||
$this->groupClassPage,
|
$this->groupClassPage,
|
||||||
|
$this->familyPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
|
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
|
||||||
|
|||||||
@@ -11,16 +11,19 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
|
|||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||||
use Unsupervised\Schedular\Booking\Lesson;
|
use Unsupervised\Schedular\Booking\Lesson;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Offering\Offering;
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\Payment;
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
use Unsupervised\Schedular\Payment\PaymentService;
|
use Unsupervised\Schedular\Payment\PaymentService;
|
||||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class BookingEndpointTest extends TestCase
|
class BookingEndpointTest extends TestCase
|
||||||
{
|
{
|
||||||
|
private GuardianService&Mockery\MockInterface $guardians;
|
||||||
private AvailabilityRepository $availability;
|
private AvailabilityRepository $availability;
|
||||||
private BookingRepository $bookings;
|
private BookingRepository $bookings;
|
||||||
private OfferingRepository $offerings;
|
private OfferingRepository $offerings;
|
||||||
@@ -52,6 +55,14 @@ class BookingEndpointTest extends TestCase
|
|||||||
// cancellation paths simply allow the call.
|
// cancellation paths simply allow the call.
|
||||||
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
|
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
|
||||||
|
|
||||||
|
$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->endpoint = new BookingEndpoint(
|
||||||
$this->availability,
|
$this->availability,
|
||||||
$this->bookings,
|
$this->bookings,
|
||||||
@@ -59,6 +70,7 @@ class BookingEndpointTest extends TestCase
|
|||||||
$this->gate,
|
$this->gate,
|
||||||
$this->payments,
|
$this->payments,
|
||||||
new CancellationPolicy($this->settings),
|
new CancellationPolicy($this->settings),
|
||||||
|
$this->guardians,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +194,7 @@ class BookingEndpointTest extends TestCase
|
|||||||
$this->gate->shouldReceive('record')->once();
|
$this->gate->shouldReceive('record')->once();
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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(
|
->andReturn(new Payment(
|
||||||
studentId: 5,
|
studentId: 5,
|
||||||
instructorId: 3,
|
instructorId: 3,
|
||||||
@@ -260,7 +272,7 @@ class BookingEndpointTest extends TestCase
|
|||||||
$this->gate->shouldReceive('record')->once();
|
$this->gate->shouldReceive('record')->once();
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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(
|
->andReturn(new Payment(
|
||||||
studentId: 5,
|
studentId: 5,
|
||||||
instructorId: 3,
|
instructorId: 3,
|
||||||
@@ -336,8 +348,8 @@ class BookingEndpointTest extends TestCase
|
|||||||
// Three claimed occurrences at a per-lesson (one_time) price of 50 → 150.
|
// Three claimed occurrences at a per-lesson (one_time) price of 50 → 150.
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->once()
|
||||||
->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null)
|
->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, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
|
->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');
|
$this->bookings->shouldNotReceive('updateStatus');
|
||||||
|
|
||||||
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']);
|
$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.
|
// A full_term price already covers the whole reservation.
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->once()
|
||||||
->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null)
|
->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, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
|
->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');
|
$this->bookings->shouldNotReceive('updateStatus');
|
||||||
|
|
||||||
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']);
|
$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.
|
// Charged now, for a single lesson's fee, as a normal (non-scheduled) payment.
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->once()
|
||||||
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null)
|
->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, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
|
->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');
|
$this->bookings->shouldNotReceive('updateStatus');
|
||||||
|
|
||||||
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
|
$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('Piano Lesson', $data[0]['offering_title']);
|
||||||
self::assertSame(60, $data[0]['duration_minutes']);
|
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class BookingPageTest extends TestCase
|
class BookingPageTest extends TestCase
|
||||||
{
|
{
|
||||||
private BookingPage $page;
|
private BookingPage $page;
|
||||||
|
private GuardianService&Mockery\MockInterface $guardians;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
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));
|
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('"is_self":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\EnrollmentEndpoint;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Offering\Offering;
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\Payment;
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
use Unsupervised\Schedular\Payment\PaymentService;
|
use Unsupervised\Schedular\Payment\PaymentService;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class EnrollmentEndpointTest extends TestCase
|
class EnrollmentEndpointTest extends TestCase
|
||||||
{
|
{
|
||||||
|
private GuardianService&Mockery\MockInterface $guardians;
|
||||||
private EnrollmentRepository $enrollments;
|
private EnrollmentRepository $enrollments;
|
||||||
private OfferingRepository $offerings;
|
private OfferingRepository $offerings;
|
||||||
private RegistrationGate $gate;
|
private RegistrationGate $gate;
|
||||||
@@ -41,12 +44,19 @@ class EnrollmentEndpointTest extends TestCase
|
|||||||
$this->payments = Mockery::mock(PaymentService::class);
|
$this->payments = Mockery::mock(PaymentService::class);
|
||||||
$this->access = Mockery::mock(GroupAccessRepository::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->endpoint = new EnrollmentEndpoint(
|
||||||
$this->enrollments,
|
$this->enrollments,
|
||||||
$this->offerings,
|
$this->offerings,
|
||||||
$this->gate,
|
$this->gate,
|
||||||
$this->payments,
|
$this->payments,
|
||||||
$this->access,
|
$this->access,
|
||||||
|
$this->guardians,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +99,7 @@ class EnrollmentEndpointTest extends TestCase
|
|||||||
$this->expectSuccessfulEnrollment();
|
$this->expectSuccessfulEnrollment();
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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(
|
->andReturn(new Payment(
|
||||||
studentId: 5,
|
studentId: 5,
|
||||||
instructorId: 3,
|
instructorId: 3,
|
||||||
@@ -252,4 +262,82 @@ class EnrollmentEndpointTest extends TestCase
|
|||||||
self::assertSame(200, $result->get_status());
|
self::assertSame(200, $result->get_status());
|
||||||
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,29 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class GroupClassPageTest extends TestCase
|
class GroupClassPageTest extends TestCase
|
||||||
{
|
{
|
||||||
private GroupClassPage $page;
|
private GroupClassPage $page;
|
||||||
|
private GuardianService&Mockery\MockInterface $guardians;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
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('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
Functions\when('current_user_can')->justReturn(true);
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||||
Functions\when('wp_enqueue_script')->justReturn(null);
|
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<?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/');
|
||||||
|
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 family', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderListsTheGuardiansChildren(): void
|
||||||
|
{
|
||||||
|
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
|
||||||
|
['id' => 42, 'name' => 'Ada', 'date_of_birth' => '2015-04-02', 'relationship' => 'Parent'],
|
||||||
|
]);
|
||||||
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Ada', $html);
|
||||||
|
self::assertStringContainsString('2015-04-02', $html);
|
||||||
|
self::assertStringContainsString('Add a child', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'us_family_action' => 'add',
|
||||||
|
'child_name' => 'Ada',
|
||||||
|
'child_dob' => '2015-04-02',
|
||||||
|
'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-04-02', '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 child 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_dob' => '2015-04-02',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->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 child 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('Child added.', $this->page->render([]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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-04-02', '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-04-02', $this->meta[42][GuardianService::META_DOB]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateChildClearsAnUnparseableDateOfBirth(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_insert_user')->justReturn(42);
|
||||||
|
$this->guardians->shouldReceive('insert')->once()->andReturn(7);
|
||||||
|
|
||||||
|
$this->service->createChild(5, 'Ada', 'not-a-date');
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 testUpdateChildRenamesAndStoresTheDateOfBirth(): 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-04-02'));
|
||||||
|
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ class CreditRepositoryTest extends TestCase
|
|||||||
);
|
);
|
||||||
$this->db->insert_id = 300;
|
$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));
|
self::assertSame(300, $this->repo->insert($credit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,4 +108,67 @@ class CreditRepositoryTest extends TestCase
|
|||||||
|
|
||||||
$this->repo->consume(5, 0.0);
|
$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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
|
|
||||||
private function payment(string $method, string $status, int $id): Payment
|
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
|
public function testFreeRegistrationCreatesNoPayment(): void
|
||||||
@@ -85,7 +85,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
// A scheduled (weekly/monthly) payment can cover several lessons and may be
|
// A scheduled (weekly/monthly) payment can cover several lessons and may be
|
||||||
// collected: cancelling one lesson must never void it or trigger a rebill.
|
// 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->shouldReceive('findById')->with(60)->andReturn($scheduled);
|
||||||
$this->payments->shouldNotReceive('updateStatus');
|
$this->payments->shouldNotReceive('updateStatus');
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
|
|
||||||
public function testCreateIntentForEtransferReturnsDisplayDataWithoutStripe(): void
|
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->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn($payment);
|
||||||
|
|
||||||
$this->stripe->shouldNotReceive('createIntent');
|
$this->stripe->shouldNotReceive('createIntent');
|
||||||
@@ -348,7 +348,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
public function testCreditForCancelledLessonCreditsWholeTotalOfSingleLessonPayment(): void
|
public function testCreditForCancelledLessonCreditsWholeTotalOfSingleLessonPayment(): void
|
||||||
{
|
{
|
||||||
// A paid single-lesson payment: the whole total (incl. tax) is credited.
|
// 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->payments->shouldReceive('findById')->with(12)->andReturn($paid);
|
||||||
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
|
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
|
||||||
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false);
|
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false);
|
||||||
@@ -361,7 +361,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
&& $c->sourceLessonId === 77))
|
&& $c->sourceLessonId === 77))
|
||||||
->andReturn(300);
|
->andReturn(300);
|
||||||
$this->credits->shouldReceive('findById')->with(300)->andReturn(
|
$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);
|
$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
|
public function testCreditForCancelledLessonSplitsSharedMonthlyPayment(): void
|
||||||
{
|
{
|
||||||
// A monthly scheduled charge covering 3 lessons: one cancellation credits a third.
|
// 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->payments->shouldReceive('findById')->with(12)->andReturn($paid);
|
||||||
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(3);
|
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(3);
|
||||||
$this->credits->shouldReceive('existsForLesson')->with(202)->andReturn(false);
|
$this->credits->shouldReceive('existsForLesson')->with(202)->andReturn(false);
|
||||||
@@ -380,7 +380,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
->once()
|
->once()
|
||||||
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
|
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
|
||||||
->andReturn(301);
|
->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);
|
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 202);
|
||||||
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
|
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
|
||||||
@@ -388,7 +388,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
|
|
||||||
public function testCreditForCancelledLessonSkipsUnpaidPayment(): void
|
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->payments->shouldReceive('findById')->with(12)->andReturn($pending);
|
||||||
$this->credits->shouldNotReceive('insert');
|
$this->credits->shouldNotReceive('insert');
|
||||||
|
|
||||||
@@ -406,7 +406,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
|
|
||||||
public function testCreditForCancelledLessonSkipsAlreadyCredited(): void
|
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->payments->shouldReceive('findById')->with(12)->andReturn($paid);
|
||||||
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
|
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
|
||||||
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(true);
|
$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
|
// A non-anchor series lesson has no payment_id of its own; the anchor's
|
||||||
// upfront (unscheduled) payment covers the whole 4-lesson series.
|
// 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('findByRegistration')->with(Payment::REG_LESSON, 40)->andReturn($anchorPayment);
|
||||||
$this->payments->shouldReceive('findById')->with(12)->andReturn($anchorPayment);
|
$this->payments->shouldReceive('findById')->with(12)->andReturn($anchorPayment);
|
||||||
$this->bookings->shouldReceive('countBySeries')->with(40)->andReturn(4);
|
$this->bookings->shouldReceive('countBySeries')->with(40)->andReturn(4);
|
||||||
@@ -430,7 +430,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
->once()
|
->once()
|
||||||
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
|
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
|
||||||
->andReturn(302);
|
->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);
|
$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));
|
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
|
||||||
@@ -487,7 +487,7 @@ class PaymentServiceTest extends TestCase
|
|||||||
|
|
||||||
private function pending(int $id, float $amount): Payment
|
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
|
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]]);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Mockery;
|
|||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Offering\Offering;
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
use Unsupervised\Schedular\Payment\Payment;
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
@@ -18,6 +19,7 @@ use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|||||||
|
|
||||||
class ScheduledBillingRunnerTest extends TestCase
|
class ScheduledBillingRunnerTest extends TestCase
|
||||||
{
|
{
|
||||||
|
private GuardianService&Mockery\MockInterface $guardians;
|
||||||
private PaymentService $payments;
|
private PaymentService $payments;
|
||||||
private BookingRepository $bookings;
|
private BookingRepository $bookings;
|
||||||
private EnrollmentRepository $enrollments;
|
private EnrollmentRepository $enrollments;
|
||||||
@@ -49,12 +51,17 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
$student->user_email = '[email protected]';
|
$student->user_email = '[email protected]';
|
||||||
Functions\when('get_userdata')->justReturn($student);
|
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->runner = new ScheduledBillingRunner(
|
||||||
$this->payments,
|
$this->payments,
|
||||||
$this->bookings,
|
$this->bookings,
|
||||||
$this->enrollments,
|
$this->enrollments,
|
||||||
$this->offerings,
|
$this->offerings,
|
||||||
$this->mailer
|
$this->mailer,
|
||||||
|
$this->guardians
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +72,7 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
|
|
||||||
private function pending(int $id, string $due): Payment
|
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
|
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')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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'));
|
->andReturn($this->pending(500, '2026-07-14'));
|
||||||
|
|
||||||
$this->mailer->shouldReceive('send')->once();
|
$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.
|
// One payment for the month: 3 x 30, due on the 1st, linked to the earliest.
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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'));
|
->andReturn($this->pending(600, '2026-07-01'));
|
||||||
|
|
||||||
// The other two lessons are pointed at the same payment so they are not re-billed.
|
// 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')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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'));
|
->andReturn($this->pending(700, '2026-07-06'));
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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'));
|
->andReturn($this->pending(701, '2026-07-13'));
|
||||||
|
|
||||||
$this->runner->run();
|
$this->runner->run();
|
||||||
@@ -181,13 +188,18 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
|
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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'));
|
->andReturn($this->pending(701, '2026-07-13'));
|
||||||
|
|
||||||
$this->runner->run();
|
$this->runner->run();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGroupMonthlyBillsMonthTotal(): void
|
/**
|
||||||
|
* A monthly group class is priced per month, not per session: the same fee
|
||||||
|
* is charged whether the class meets four times in the month or once. This
|
||||||
|
* is what the class card quotes and what the student agrees to pay.
|
||||||
|
*/
|
||||||
|
public function testGroupMonthlyBillsTheMonthlyFeeOnceHoweverManySessions(): void
|
||||||
{
|
{
|
||||||
$this->now('2026-07-15 09:00:00');
|
$this->now('2026-07-15 09:00:00');
|
||||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||||
@@ -197,10 +209,32 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
|
|
||||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
||||||
|
|
||||||
// One payment: 4 sessions x 20, due on the 1st.
|
// One payment of the monthly fee — not 4 x 20 — due on the 1st.
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->once()
|
||||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 80.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-month fee does not shrink for a short month either — a month with
|
||||||
|
* a single session is billed the same as a month with four.
|
||||||
|
*/
|
||||||
|
public function testGroupMonthlyBillsTheSameFeeForAMonthWithOneSession(): void
|
||||||
|
{
|
||||||
|
$this->now('2026-07-15 09:00:00');
|
||||||
|
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||||
|
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||||
|
// A single July session.
|
||||||
|
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_MONTHLY, '2026-07-07', '2026-07-07'));
|
||||||
|
|
||||||
|
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
||||||
|
|
||||||
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
|
->once()
|
||||||
|
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07', 5)
|
||||||
->andReturn($this->pending(800, '2026-07-01'));
|
->andReturn($this->pending(800, '2026-07-01'));
|
||||||
|
|
||||||
$this->runner->run();
|
$this->runner->run();
|
||||||
@@ -213,7 +247,7 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
|
->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.
|
// 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->payments->shouldReceive('createForRegistration')->once()->andReturn($comp);
|
||||||
|
|
||||||
$this->mailer->shouldNotReceive('send');
|
$this->mailer->shouldNotReceive('send');
|
||||||
@@ -306,4 +340,92 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
id: 9,
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class StripeGatewayTest extends TestCase
|
|||||||
|
|
||||||
private function payment(): Payment
|
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
|
public function testCreateIntentReturnsNullWhenNotConfigured(): void
|
||||||
|
|||||||
@@ -36,13 +36,15 @@ class AcceptanceRepositoryTest extends TestCase
|
|||||||
&& $d['student_id'] === 5
|
&& $d['student_id'] === 5
|
||||||
&& $d['registration_type'] === PolicyAcceptance::REG_LESSON
|
&& $d['registration_type'] === PolicyAcceptance::REG_LESSON
|
||||||
&& $d['registration_id'] === 12
|
&& $d['registration_id'] === 12
|
||||||
|
// No explicit acceptor: the student agreed for themselves.
|
||||||
|
&& $d['accepted_by'] === 5
|
||||||
&& $d['ip_address'] === '203.0.113.7';
|
&& $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;
|
$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));
|
self::assertSame(1, $this->repo->insert($acceptance));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Policy;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Policy\Policy;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyController;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyService;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class PolicyControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private PolicyRepository&Mockery\MockInterface $policies;
|
||||||
|
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||||
|
private PolicyService&Mockery\MockInterface $service;
|
||||||
|
private PolicyController $controller;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||||
|
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||||
|
$this->service = Mockery::mock(PolicyService::class);
|
||||||
|
$this->controller = new PolicyController($this->policies, $this->versions, $this->service);
|
||||||
|
|
||||||
|
$_POST = [];
|
||||||
|
$_GET = [];
|
||||||
|
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('check_admin_referer')->justReturn(true);
|
||||||
|
Functions\when('wp_unslash')->returnArg();
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
Functions\when('sanitize_title')->returnArg();
|
||||||
|
Functions\when('wp_kses_post')->returnArg();
|
||||||
|
$this->stubAutop();
|
||||||
|
Functions\when('sanitize_key')->alias(
|
||||||
|
static fn ($key) => strtolower((string) preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $key))
|
||||||
|
);
|
||||||
|
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||||
|
Functions\when('selected')->justReturn('');
|
||||||
|
Functions\when('wp_nonce_field')->justReturn('');
|
||||||
|
Functions\when('admin_url')->justReturn('admin.php');
|
||||||
|
Functions\when('add_query_arg')->alias(
|
||||||
|
static fn (array $args, string $url) => $url . '?' . http_build_query($args)
|
||||||
|
);
|
||||||
|
Functions\when('submit_button')->alias(static function (string $text = ''): void {
|
||||||
|
echo $text; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- test stub
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testViewingAVersionRendersItsBodyAndAnEditForm(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||||
|
$version = new PolicyVersion(
|
||||||
|
policyId: 4,
|
||||||
|
versionNumber: 2,
|
||||||
|
body: '<p>Cancel 24 hours ahead.</p>',
|
||||||
|
status: PolicyVersion::STATUS_PUBLISHED,
|
||||||
|
publishedAt: '2026-07-01 09:00:00',
|
||||||
|
id: 7,
|
||||||
|
);
|
||||||
|
|
||||||
|
$_GET = [
|
||||||
|
'policy_id' => '4',
|
||||||
|
'version_id' => '7',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$version]);
|
||||||
|
$this->versions->shouldReceive('findById')->with(7)->andReturn($version);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Cancel 24 hours ahead.', $html);
|
||||||
|
self::assertStringContainsString('value="edit_version"', $html);
|
||||||
|
self::assertStringContainsString('Save as New Draft', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVersionListLinksToTheViewer(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||||
|
$version = new PolicyVersion(policyId: 4, versionNumber: 2, body: 'text', id: 7);
|
||||||
|
|
||||||
|
$_GET = ['policy_id' => '4'];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$version]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('page=us-policies&policy_id=4&version_id=7', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAVersionBelongingToAnotherPolicyIsNotShown(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||||
|
$other = new PolicyVersion(policyId: 9, versionNumber: 1, body: 'Someone else\'s policy', id: 7);
|
||||||
|
|
||||||
|
$_GET = [
|
||||||
|
'policy_id' => '4',
|
||||||
|
'version_id' => '7',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
|
||||||
|
$this->versions->shouldReceive('findById')->with(7)->andReturn($other);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('Someone else\'s policy', $html);
|
||||||
|
self::assertStringNotContainsString('value="edit_version"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditingAPublishedVersionCreatesANewDraftAndLeavesTheSourceUntouched(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||||
|
$published = new PolicyVersion(
|
||||||
|
policyId: 4,
|
||||||
|
versionNumber: 2,
|
||||||
|
body: '<p>Old text.</p>',
|
||||||
|
status: PolicyVersion::STATUS_PUBLISHED,
|
||||||
|
id: 7,
|
||||||
|
);
|
||||||
|
$draft = new PolicyVersion(policyId: 4, versionNumber: 3, body: '<p>New text.</p>', id: 11);
|
||||||
|
|
||||||
|
$_GET = ['policy_id' => '4'];
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'edit_version',
|
||||||
|
'policy_id' => '4',
|
||||||
|
'version_id' => '7',
|
||||||
|
'body' => '<p>New text.</p>',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft, $published]);
|
||||||
|
$this->versions->shouldReceive('findById')->with(7)->andReturn($published);
|
||||||
|
$this->versions->shouldReceive('findById')->with(11)->andReturn($draft);
|
||||||
|
|
||||||
|
// A new draft is written; the edited version itself is never updated.
|
||||||
|
$this->service->shouldReceive('addDraftVersion')->once()->with(4, '<p>New text.</p>')->andReturn(11);
|
||||||
|
$this->versions->shouldNotReceive('updateBody');
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
// The viewer follows through to the draft that was just created.
|
||||||
|
self::assertStringContainsString('New text.', $html);
|
||||||
|
self::assertStringContainsString('saved as a new draft version', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditingADraftVersionUpdatesItInPlace(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||||
|
$draft = new PolicyVersion(
|
||||||
|
policyId: 4,
|
||||||
|
versionNumber: 3,
|
||||||
|
body: '<p>Old draft text.</p>',
|
||||||
|
status: PolicyVersion::STATUS_DRAFT,
|
||||||
|
id: 11,
|
||||||
|
);
|
||||||
|
|
||||||
|
$_GET = ['policy_id' => '4'];
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'edit_version',
|
||||||
|
'policy_id' => '4',
|
||||||
|
'version_id' => '11',
|
||||||
|
'body' => '<p>Revised draft text.</p>',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft]);
|
||||||
|
$this->versions->shouldReceive('findById')->with(11)->andReturn($draft);
|
||||||
|
|
||||||
|
// An unpublished draft is rewritten rather than branched.
|
||||||
|
$this->versions->shouldReceive('updateBody')->once()->with(11, '<p>Revised draft text.</p>')->andReturn(true);
|
||||||
|
$this->service->shouldNotReceive('addDraftVersion');
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Draft version 3 was updated', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditingAnArchivedVersionCreatesANewDraft(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 9, id: 4);
|
||||||
|
$archived = new PolicyVersion(
|
||||||
|
policyId: 4,
|
||||||
|
versionNumber: 1,
|
||||||
|
body: '<p>Superseded text.</p>',
|
||||||
|
status: PolicyVersion::STATUS_ARCHIVED,
|
||||||
|
id: 7,
|
||||||
|
);
|
||||||
|
$draft = new PolicyVersion(policyId: 4, versionNumber: 4, body: '<p>Reinstated text.</p>', id: 12);
|
||||||
|
|
||||||
|
$_GET = ['policy_id' => '4'];
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'edit_version',
|
||||||
|
'policy_id' => '4',
|
||||||
|
'version_id' => '7',
|
||||||
|
'body' => '<p>Reinstated text.</p>',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft, $archived]);
|
||||||
|
$this->versions->shouldReceive('findById')->with(7)->andReturn($archived);
|
||||||
|
$this->versions->shouldReceive('findById')->with(12)->andReturn($draft);
|
||||||
|
|
||||||
|
$this->service->shouldReceive('addDraftVersion')->once()->with(4, '<p>Reinstated text.</p>')->andReturn(12);
|
||||||
|
$this->versions->shouldNotReceive('updateBody');
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditingAVersionOfAnotherPolicyIsRejected(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||||
|
$other = new PolicyVersion(policyId: 9, versionNumber: 1, body: 'Other', id: 7);
|
||||||
|
|
||||||
|
$_GET = ['policy_id' => '4'];
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'edit_version',
|
||||||
|
'policy_id' => '4',
|
||||||
|
'version_id' => '7',
|
||||||
|
'body' => 'Injected text',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
|
||||||
|
$this->versions->shouldReceive('findById')->with(7)->andReturn($other);
|
||||||
|
|
||||||
|
$this->service->shouldNotReceive('addDraftVersion');
|
||||||
|
$this->versions->shouldNotReceive('updateBody');
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
$this->controller->renderPage();
|
||||||
|
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ use Unsupervised\Schedular\Policy\Policy;
|
|||||||
use Unsupervised\Schedular\Policy\PolicyEndpoint;
|
use Unsupervised\Schedular\Policy\PolicyEndpoint;
|
||||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||||
use Unsupervised\Schedular\Policy\PolicyService;
|
use Unsupervised\Schedular\Policy\PolicyService;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class PolicyEndpointTest extends TestCase
|
class PolicyEndpointTest extends TestCase
|
||||||
{
|
{
|
||||||
private PolicyRepository&Mockery\MockInterface $policies;
|
private PolicyRepository&Mockery\MockInterface $policies;
|
||||||
|
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||||
private PolicyService&Mockery\MockInterface $service;
|
private PolicyService&Mockery\MockInterface $service;
|
||||||
private PolicyEndpoint $endpoint;
|
private PolicyEndpoint $endpoint;
|
||||||
|
|
||||||
@@ -24,16 +26,43 @@ class PolicyEndpointTest extends TestCase
|
|||||||
|
|
||||||
Functions\when('sanitize_text_field')->returnArg();
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
Functions\when('sanitize_title')->returnArg();
|
Functions\when('sanitize_title')->returnArg();
|
||||||
|
Functions\when('wp_kses_post')->returnArg();
|
||||||
|
$this->stubAutop();
|
||||||
|
|
||||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||||
|
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||||
$this->service = Mockery::mock(PolicyService::class);
|
$this->service = Mockery::mock(PolicyService::class);
|
||||||
$this->endpoint = new PolicyEndpoint(
|
$this->endpoint = new PolicyEndpoint(
|
||||||
$this->policies,
|
$this->policies,
|
||||||
Mockery::mock(PolicyVersionRepository::class),
|
$this->versions,
|
||||||
$this->service,
|
$this->service,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testIndexReturnsPlainTextBodiesAsParagraphs(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||||
|
$version = new PolicyVersion(
|
||||||
|
policyId: 4,
|
||||||
|
versionNumber: 2,
|
||||||
|
body: "Give 24 hours notice.\n\nLate cancellations are billed in full.",
|
||||||
|
status: PolicyVersion::STATUS_PUBLISHED,
|
||||||
|
id: 7,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->versions->shouldReceive('findById')->with(7)->andReturn($version);
|
||||||
|
|
||||||
|
$body = $this->endpoint->index(new \WP_REST_Request([]))->get_data();
|
||||||
|
|
||||||
|
// The admin typed no markup, so the gate still receives real paragraphs
|
||||||
|
// rather than one unbroken run of text.
|
||||||
|
self::assertSame(
|
||||||
|
'<p>Give 24 hours notice.</p><p>Late cancellations are billed in full.</p>',
|
||||||
|
$body[0]['body']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testCreateRejectsTitleLongerThanColumnLimit(): void
|
public function testCreateRejectsTitleLongerThanColumnLimit(): void
|
||||||
{
|
{
|
||||||
$this->service->shouldNotReceive('createPolicy');
|
$this->service->shouldNotReceive('createPolicy');
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ class PolicyValueObjectsTest extends TestCase
|
|||||||
self::assertArrayHasKey('acceptance_scope', $policy->toArray());
|
self::assertArrayHasKey('acceptance_scope', $policy->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAnEmptyBodyRendersAsNothingRatherThanAnEmptyParagraph(): void
|
||||||
|
{
|
||||||
|
\Brain\Monkey\Functions\when('wp_kses_post')->returnArg();
|
||||||
|
$this->stubAutop();
|
||||||
|
|
||||||
|
$version = new PolicyVersion(policyId: 4, versionNumber: 1, body: null);
|
||||||
|
|
||||||
|
self::assertSame('', $version->bodyHtml());
|
||||||
|
}
|
||||||
|
|
||||||
public function testPolicyHandlesNullCurrentVersion(): void
|
public function testPolicyHandlesNullCurrentVersion(): void
|
||||||
{
|
{
|
||||||
$policy = Policy::fromRow((object) [
|
$policy = Policy::fromRow((object) [
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Auth\LoginPage;
|
|||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||||
|
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||||
use Unsupervised\Schedular\ShortcodeRegistrar;
|
use Unsupervised\Schedular\ShortcodeRegistrar;
|
||||||
|
|
||||||
class ShortcodeRegistrarTest extends TestCase
|
class ShortcodeRegistrarTest extends TestCase
|
||||||
@@ -18,11 +19,15 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
private LoginPage&Mockery\MockInterface $loginPage;
|
private LoginPage&Mockery\MockInterface $loginPage;
|
||||||
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
||||||
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
||||||
|
private FamilyPage&Mockery\MockInterface $familyPage;
|
||||||
private ShortcodeRegistrar $registrar;
|
private ShortcodeRegistrar $registrar;
|
||||||
|
|
||||||
/** @var array<string, callable> */
|
/** @var array<string, callable> */
|
||||||
private array $shortcodes = [];
|
private array $shortcodes = [];
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private array $localized = [];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
@@ -31,12 +36,14 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
$this->loginPage = Mockery::mock(LoginPage::class);
|
$this->loginPage = Mockery::mock(LoginPage::class);
|
||||||
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
||||||
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
||||||
|
$this->familyPage = Mockery::mock(FamilyPage::class);
|
||||||
|
|
||||||
$this->registrar = new ShortcodeRegistrar(
|
$this->registrar = new ShortcodeRegistrar(
|
||||||
$this->bookingPage,
|
$this->bookingPage,
|
||||||
$this->loginPage,
|
$this->loginPage,
|
||||||
$this->registrationPage,
|
$this->registrationPage,
|
||||||
$this->groupClassPage,
|
$this->groupClassPage,
|
||||||
|
$this->familyPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
$shortcodes = &$this->shortcodes;
|
$shortcodes = &$this->shortcodes;
|
||||||
@@ -47,7 +54,7 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRegisterAddsAllFourShortcodesAndHooks(): void
|
public function testRegisterAddsAllShortcodesAndHooks(): void
|
||||||
{
|
{
|
||||||
Actions\expectAdded('template_redirect')
|
Actions\expectAdded('template_redirect')
|
||||||
->once()
|
->once()
|
||||||
@@ -59,7 +66,7 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
$this->registrar->register();
|
$this->registrar->register();
|
||||||
|
|
||||||
self::assertSame(
|
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'],
|
||||||
array_keys($this->shortcodes)
|
array_keys($this->shortcodes)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,6 +91,69 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
self::assertSame('group', $this->shortcodes['us_group_classes'](''));
|
self::assertSame('group', $this->shortcodes['us_group_classes'](''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The booking and group-class scripts both read prices through the shared
|
||||||
|
* pricing helper, so it must be registered ahead of them (and behind the
|
||||||
|
* payment helper, which carries the localized config it reads).
|
||||||
|
*/
|
||||||
|
public function testPricingHelperIsRegisteredAheadOfTheBookingAndGroupScripts(): void
|
||||||
|
{
|
||||||
|
$scripts = $this->captureEnqueuedAssets();
|
||||||
|
|
||||||
|
self::assertSame(['us-scheduler-payment'], $scripts['us-scheduler-pricing']);
|
||||||
|
self::assertSame(['us-scheduler-pricing', 'us-scheduler-guardian'], $scripts['us-scheduler']);
|
||||||
|
self::assertSame(['us-scheduler-pricing', 'us-scheduler-guardian'], $scripts['us-scheduler-group']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The studio HST rate reaches the front end so a price quoted on a booking
|
||||||
|
* form matches the total the student is actually billed.
|
||||||
|
*/
|
||||||
|
public function testStudioTaxRateIsLocalizedToTheFrontEnd(): void
|
||||||
|
{
|
||||||
|
$this->captureEnqueuedAssets();
|
||||||
|
|
||||||
|
self::assertSame(13.0, $this->localized['taxRate']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>> Registered script handle => dependencies.
|
||||||
|
*/
|
||||||
|
private function captureEnqueuedAssets(): array
|
||||||
|
{
|
||||||
|
$scripts = [];
|
||||||
|
$localized = &$this->localized;
|
||||||
|
|
||||||
|
Functions\when('wp_register_style')->justReturn(true);
|
||||||
|
Functions\when('rest_url')->justReturn('https://example.test/wp-json/us-scheduler/v1/');
|
||||||
|
Functions\when('wp_create_nonce')->justReturn('nonce');
|
||||||
|
Functions\when('get_option')->alias(
|
||||||
|
static fn (string $name, mixed $default = false): mixed => match ($name) {
|
||||||
|
'us_hst_rate' => '13',
|
||||||
|
'start_of_week' => 1,
|
||||||
|
default => $default,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\when('wp_register_script')->alias(
|
||||||
|
static function (string $handle, string $src, array $deps = []) use (&$scripts): bool {
|
||||||
|
$scripts[$handle] = $deps;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\when('wp_localize_script')->alias(
|
||||||
|
static function (string $handle, string $object, array $data) use (&$localized): bool {
|
||||||
|
$localized = $data;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->registrar->enqueueAssets();
|
||||||
|
|
||||||
|
return $scripts;
|
||||||
|
}
|
||||||
|
|
||||||
public function testShortcodeAttributesArePassedThroughUnchanged(): void
|
public function testShortcodeAttributesArePassedThroughUnchanged(): void
|
||||||
{
|
{
|
||||||
$this->registrar->register();
|
$this->registrar->register();
|
||||||
|
|||||||
@@ -24,4 +24,17 @@ abstract class TestCase extends BaseTestCase
|
|||||||
Monkey\tearDown();
|
Monkey\tearDown();
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub `wpautop()` with a minimal blank-line-to-paragraph transform — enough
|
||||||
|
* to assert that unmarked-up text reaches the page as real paragraphs.
|
||||||
|
*/
|
||||||
|
protected function stubAutop(): void
|
||||||
|
{
|
||||||
|
Monkey\Functions\when('wpautop')->alias(static function (string $text): string {
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
return '' === $text ? '' : '<p>' . implode('</p><p>', (array) preg_split('/\n\s*\n/', $text)) . '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: Unsupervised Scheduler
|
* Plugin Name: Unsupervised Scheduler
|
||||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||||
* Description: Instructor/student lesson scheduling for WordPress.
|
* Description: Instructor/student lesson scheduling for WordPress.
|
||||||
* Version: 1.2.2
|
* Version: 1.3.0
|
||||||
* Requires at least: 6.2
|
* Requires at least: 6.2
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Author: Unsupervised
|
* Author: Unsupervised
|
||||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define('USC_VERSION', '1.2.2');
|
define('USC_VERSION', '1.3.0');
|
||||||
define('USC_PLUGIN_FILE', __FILE__);
|
define('USC_PLUGIN_FILE', __FILE__);
|
||||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user