Compare commits
13
Commits
d8d842b1ef
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146f63ebdd
|
||
|
|
12765d8f13
|
||
|
|
56fc0bd57d
|
||
|
|
988fa647d5
|
||
|
|
694366a6c8
|
||
|
|
a2c609803e
|
||
|
|
ad4e4ae357
|
||
|
|
1a166eb4e3
|
||
|
|
362980d008
|
||
|
|
e3ab7973d6
|
||
|
|
1758c253a1
|
||
|
|
67017409d7
|
||
|
|
e7de627752
|
@@ -11,9 +11,23 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||
each change under the current top section as you work.
|
||||
|
||||
## [1.6.1]
|
||||
|
||||
### Added
|
||||
- **Instructors can now be emailed when someone books their lesson or enrols in their group class.** Until now an instructor had to open their schedule to discover a new booking; now each one can ask to be told the moment it happens. Turn it on from **My Availability → Notifications** — it is off by default, and it is each instructor's own choice, so one can opt in while another keeps their inbox quiet. Once on, it covers both private lessons and group classes, and it does not matter who did the booking: a student (or a parent for their child) booking themselves, or the studio booking on their behalf from wp-admin, all reach the instructor the same way. A term booked all at once says how many lessons it covers. The notice is a courtesy only — a booking or enrolment always goes through whether or not the email lands.
|
||||
|
||||
## [1.6.0]
|
||||
|
||||
### Added
|
||||
- **You can now read, rewrite and preview the "Payment due" email, on Studio Settings → Payment Due Email.** The notice a family gets when the daily scan finds lessons to pay for was fixed wording baked into the plugin; now its subject and body sit in an editor you can change to match how your studio talks to its students. Drop in `{student_name}`, `{items}`, `{total_due}` and the rest wherever you want them, and a preview below fills those tokens with sample values and updates as you type, so you see the actual email a scan would send before you save. Leave a field blank to fall back to the built-in wording, or use the reset button to restore all of it at once. Nothing about how or when the email is sent changes — only what it says — and until you touch it, students receive exactly the notice they always did.
|
||||
|
||||
## [1.5.8]
|
||||
|
||||
### Added
|
||||
- **A student's account credit balance now shows at the top of their detail page.** Credit from a cancelled paid lesson was already recorded and listed further down the page, but you had to scroll to the Account credit section to find out a student was owed anything. When there is a balance to report it now appears up top the moment you open the page, so you can see at a glance that this student's future billing will be offset — and, for a child, that the balance sits on their guardian's account. The full breakdown of where the credit came from stays where it was.
|
||||
|
||||
### Fixed
|
||||
- **Rebooking a cancelled paid lesson in the same month no longer charges the family twice.** Cancelling a paid lesson credits the account for it, and that credit is meant to cover the next lesson booked in its place. But a lesson booked back into a month already billed is charged there and then, and that charge skipped the step where credit is applied — so the family was billed in full for the replacement while the credit for the cancelled lesson sat unused, in effect paying twice for the one slot. Account credit is now applied to a charge raised at booking, so the credit settles the rebooking the same way it settles a scheduled charge; a lesson fully covered by credit is confirmed with nothing left to pay.
|
||||
- **A student is no longer emailed the same "Payment due" notice twice.** The daily billing scan runs whenever the site gets traffic, and on a busy day two copies of it could end up running at the same time. Neither knew about the other, so each would send its own notice for the same charge — one payment on the books, but the family saw two identical requests to pay and reasonably read it as being billed twice. Each payment is now stamped the moment its notice goes out, and a second run that reaches the same payment sees the stamp and stays quiet, so exactly one notice is sent no matter how the scan is triggered. Payments already noticed before this update are marked as such on upgrade, so nobody gets a fresh round of reminders for charges they were already told about.
|
||||
- **Switching a group class to monthly billing no longer charges students who already paid up front a second time.** When a class was set up to be paid once at sign-up and later changed to bill monthly, the daily scan did not recognise the payment already taken at enrolment — it carried no billing month — and raised a fresh charge for the current month on top of it. Families who had already paid were billed again, sometimes for a month they had covered. Changing a class to monthly now marks each enrolled student's up-front payment as covering the current month, so the scan bills them from the following month on and never doubles up on the month already paid. (Enrolments made after the switch, and classes that were always monthly, were never affected.)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Payment due email editor: live preview.
|
||||
*
|
||||
* Posts the subject/body/item-line the admin is editing to the read-only preview
|
||||
* REST endpoint and swaps the rendered result into the preview panel, debounced
|
||||
* as they type. The server always renders from the same sample values, so this
|
||||
* mirrors exactly what a real billing scan would send. Purely a convenience — the
|
||||
* page already shows a server-rendered preview of the saved template without it.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const config = window.uscPaymentEmailPreview;
|
||||
if (!config || !config.url) return;
|
||||
|
||||
const subjectEl = document.getElementById('usc-pe-subject');
|
||||
const bodyEl = document.getElementById('usc-pe-body');
|
||||
const itemLineEl = document.getElementById('usc-pe-item-line');
|
||||
const outSubject = document.getElementById('usc-pe-preview-subject');
|
||||
const outBody = document.getElementById('usc-pe-preview-body');
|
||||
|
||||
if (!subjectEl || !bodyEl || !itemLineEl || !outSubject || !outBody) return;
|
||||
|
||||
let timer = null;
|
||||
|
||||
function refresh() {
|
||||
fetch(config.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': config.nonce
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subject: subjectEl.value,
|
||||
body: bodyEl.value,
|
||||
item_line: itemLineEl.value
|
||||
})
|
||||
})
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error('preview failed');
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
outSubject.textContent = data.subject || '';
|
||||
outBody.textContent = data.body || '';
|
||||
})
|
||||
.catch(function () {
|
||||
// Leave the last good preview in place on error.
|
||||
});
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(refresh, 300);
|
||||
}
|
||||
|
||||
[subjectEl, bodyEl, itemLineEl].forEach(function (el) {
|
||||
el.addEventListener('input', schedule);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,84 @@
|
||||
# Feature: Instructor Booking Notifications
|
||||
|
||||
## Overview
|
||||
An instructor can ask to be emailed whenever someone books one of their private
|
||||
lessons or enrols in one of their group classes. It is **off by default** and set
|
||||
per instructor — the people it emails decide whether they want it.
|
||||
|
||||
The notice fires whichever way the booking or enrolment was made: a student
|
||||
(or a guardian for their child) doing it themselves through the front-end, or the
|
||||
studio doing it on their behalf from wp-admin. There is no separate "the studio
|
||||
booked it" case to forget — every path funnels through one place per registration
|
||||
type, and the opt-in check lives in the mailer, not at each call site, so no path
|
||||
can drift on who gets mailed.
|
||||
|
||||
## Preference `us_notify_on_booking` (user meta)
|
||||
`'1'` or `'0'`, stored against the instructor's WordPress user. Absent — the
|
||||
default for every account — reads as off. Stored as `'0'` rather than deleted when
|
||||
an instructor turns it off, so a deliberate "no" is told apart from never having
|
||||
chosen.
|
||||
|
||||
Default off because the notification is a new capability: turning it on for every
|
||||
instructor on an existing site the day it ships would mail people who never asked.
|
||||
|
||||
## Admin Interface
|
||||
**My Availability → Notifications** (`manage_availability` — every instructor sees
|
||||
their own availability page):
|
||||
|
||||
- **Email me when someone books a lesson or enrols in one of my group classes** —
|
||||
a single checkbox, off by default, saved on its own form (`usc_action=save_notify`).
|
||||
|
||||
The page an instructor sets availability on is the one they already visit to shape
|
||||
their teaching schedule, so the preference about that schedule lives beside it.
|
||||
|
||||
## What triggers a notice
|
||||
| Registration | Path | Where the notice fires |
|
||||
|---|---|---|
|
||||
| Private lesson | student/guardian REST **and** studio wp-admin form | `Booking\LessonBooker::settle()` — the single step both paths reach once a slot is claimed |
|
||||
| Group class | student/guardian REST | `GroupClass\EnrollmentEndpoint::enroll()`, after the roster row is written |
|
||||
| Group class | studio "Add students directly" (wp-admin) | `GroupClass\GroupClassController::addDirect()`, per student added |
|
||||
|
||||
A weekly lesson reservation reports the number of occurrences claimed, so a single
|
||||
booking and a term booked at once each read correctly.
|
||||
|
||||
The notice is an opt-in courtesy, never a step a booking or enrolment depends on:
|
||||
a missing or failed send can never fail a booking that otherwise succeeded, and
|
||||
the mailer returns false (without sending) when the instructor has not opted in,
|
||||
their account is gone, or it carries no email.
|
||||
|
||||
## Implementation
|
||||
- `Unsupervised\Schedular\Auth\InstructorNotificationPref` — the per-instructor
|
||||
user-meta preference (`wants()` / `set()`), default off
|
||||
- `Unsupervised\Schedular\Auth\InstructorNotificationMailer` — `notifyLessonBooked()`
|
||||
and `notifyEnrollment()`; the opt-in check and recipient resolution live here
|
||||
- `Unsupervised\Schedular\Booking\LessonBooker::settle()` — fires the lesson notice
|
||||
for both booking paths
|
||||
- `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint::enroll()` — student/guardian
|
||||
enrolment notice
|
||||
- `Unsupervised\Schedular\GroupClass\GroupClassController::addDirect()` — studio
|
||||
enrolment notice
|
||||
- `Unsupervised\Schedular\Availability\AvailabilityController` — reads the preference
|
||||
for the page and saves the toggle (`save_notify`)
|
||||
- `templates/admin/availability.php` — the Notifications checkbox
|
||||
|
||||
All three consumers take the mailer (and the controller its preference) as a
|
||||
constructor dependency defaulting to a fresh instance, so existing wiring in
|
||||
`Plugin`, `RestRegistrar` and `AdminMenu` is unchanged.
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Auth/InstructorNotificationPrefTest.php` — default-off, opt-in read,
|
||||
string-boolean write, non-user guards
|
||||
- `tests/Unit/Auth/InstructorNotificationMailerTest.php` — sends to an opted-in
|
||||
instructor, weekly occurrence count, and sends nothing when opted out / account
|
||||
gone / no email
|
||||
- `tests/Unit/GroupClass/EnrollmentEndpointTest.php` — a successful enrolment
|
||||
notifies the class instructor
|
||||
- `tests/Unit/Availability/AvailabilityControllerTest.php` — the toggle saves an
|
||||
opt-in and an opt-out
|
||||
- The booking-path tests (`BookingEndpointTest`, `AdminBookingTest`) inject a mock
|
||||
mailer, keeping them about booking
|
||||
|
||||
## Related
|
||||
- `lesson-booking.md` — the booking core the lesson notice hangs off
|
||||
- `group-classes.md` — the enrolment paths the class notice hangs off
|
||||
- `user-roles.md` — the instructor role and `manage_availability` capability
|
||||
+36
-8
@@ -32,6 +32,7 @@ use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentController;
|
||||
use Unsupervised\Schedular\Payment\PaymentEmailController;
|
||||
use Unsupervised\Schedular\Payment\PaymentReportController;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
@@ -56,6 +57,12 @@ class AdminMenu {
|
||||
*/
|
||||
private string $availabilityHook = '';
|
||||
|
||||
/**
|
||||
* Hook suffix of the payment-email screen, captured when the page is added so
|
||||
* its live-preview script loads on that screen only.
|
||||
*/
|
||||
private string $paymentEmailHook = '';
|
||||
|
||||
private AvailabilityController $availabilityController;
|
||||
private LessonController $lessonController;
|
||||
private OfferingController $offeringController;
|
||||
@@ -69,6 +76,7 @@ class AdminMenu {
|
||||
private StudioSettings $settings;
|
||||
private AccessSettings $accessSettings;
|
||||
private PaymentController $paymentController;
|
||||
private PaymentEmailController $paymentEmailController;
|
||||
private PaymentReportController $paymentReportController;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians, LessonBooker $booker, RegistrationGate $gate, BillingModeReconciler $billingModeReconciler ) {
|
||||
@@ -90,6 +98,7 @@ class AdminMenu {
|
||||
$this->settings = $settings;
|
||||
$this->accessSettings = new AccessSettings();
|
||||
$this->paymentController = new PaymentController( $payments, $paymentService );
|
||||
$this->paymentEmailController = new PaymentEmailController();
|
||||
$this->paymentReportController = new PaymentReportController( $payments );
|
||||
}
|
||||
|
||||
@@ -105,17 +114,26 @@ class AdminMenu {
|
||||
* @param string $hookSuffix Screen the enqueue is running for.
|
||||
*/
|
||||
public function enqueueAssets( string $hookSuffix ): void {
|
||||
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) {
|
||||
if ( '' !== $this->availabilityHook && $hookSuffix === $this->availabilityHook ) {
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-availability-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
|
||||
[],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-availability-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
|
||||
[],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
if ( '' !== $this->paymentEmailHook && $hookSuffix === $this->paymentEmailHook ) {
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-payment-email-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/payment-email-admin.js',
|
||||
[],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function addPages(): void {
|
||||
@@ -263,6 +281,16 @@ class AdminMenu {
|
||||
30
|
||||
);
|
||||
|
||||
// Studio admin: view, edit and preview the payment-due email template.
|
||||
$this->paymentEmailHook = (string) add_submenu_page(
|
||||
'us-settings',
|
||||
__( 'Payment Due Email', 'unsupervised-schedular' ),
|
||||
__( 'Payment Due Email', 'unsupervised-schedular' ),
|
||||
RoleManager::CAP_MANAGE_BILLING,
|
||||
'us-payment-email',
|
||||
[ $this->paymentEmailController, 'renderPage' ]
|
||||
);
|
||||
|
||||
// Site owner: whether WordPress administrators are studio admins / instructors.
|
||||
// Gated on the core manage_options capability — never the plugin's own grants —
|
||||
// so an administrator can always reach it to re-enable a disabled grant.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* Emails an instructor when someone books one of their lessons or enrols in one
|
||||
* of their group classes — but only when that instructor has opted in
|
||||
* ({@see InstructorNotificationPref}).
|
||||
*
|
||||
* The opt-in check lives here, not at each call site, so the several places a
|
||||
* booking or enrolment can be made — the student REST flows and the two wp-admin
|
||||
* "add for a student" forms — cannot drift apart on who gets mailed: they hand
|
||||
* over the instructor and what happened, and this decides whether to send. Every
|
||||
* method returns false without sending when the instructor has not opted in,
|
||||
* their account is gone, or it has no email on file, so a missing notification is
|
||||
* never mistaken for a failed booking by the caller.
|
||||
*/
|
||||
class InstructorNotificationMailer {
|
||||
|
||||
public function __construct(
|
||||
private InstructorNotificationPref $pref = new InstructorNotificationPref(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Tell the instructor a lesson of theirs has just been booked. `$count` is the
|
||||
* number of occurrences a weekly reservation claimed, so a single booking and
|
||||
* a term booked at once read correctly.
|
||||
*/
|
||||
public function notifyLessonBooked( int $instructorId, string $studentName, string $offeringTitle, string $when, int $count = 1 ): bool {
|
||||
$instructor = $this->recipient( $instructorId );
|
||||
if ( ! $instructor instanceof \WP_User ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: lesson type. */
|
||||
__( 'New booking: %s', 'unsupervised-schedular' ),
|
||||
$offeringTitle
|
||||
);
|
||||
|
||||
$body = $count > 1
|
||||
? sprintf(
|
||||
/* translators: 1: student name, 2: lesson type, 3: number of weekly occurrences, 4: first lesson date and time. */
|
||||
__( '%1$s has booked %2$s with you — %3$d weekly lessons from %4$s.', 'unsupervised-schedular' ),
|
||||
$studentName,
|
||||
$offeringTitle,
|
||||
$count,
|
||||
$when
|
||||
)
|
||||
: sprintf(
|
||||
/* translators: 1: student name, 2: lesson type, 3: lesson date and time. */
|
||||
__( '%1$s has booked %2$s with you on %3$s.', 'unsupervised-schedular' ),
|
||||
$studentName,
|
||||
$offeringTitle,
|
||||
$when
|
||||
);
|
||||
|
||||
return (bool) wp_mail( (string) $instructor->user_email, $subject, $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the instructor a student has just enrolled in one of their group
|
||||
* classes.
|
||||
*/
|
||||
public function notifyEnrollment( int $instructorId, string $studentName, string $classTitle ): bool {
|
||||
$instructor = $this->recipient( $instructorId );
|
||||
if ( ! $instructor instanceof \WP_User ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: class title. */
|
||||
__( 'New enrolment: %s', 'unsupervised-schedular' ),
|
||||
$classTitle
|
||||
);
|
||||
|
||||
$body = sprintf(
|
||||
/* translators: 1: student name, 2: class title. */
|
||||
__( '%1$s has enrolled in your group class "%2$s".', 'unsupervised-schedular' ),
|
||||
$studentName,
|
||||
$classTitle
|
||||
);
|
||||
|
||||
return (bool) wp_mail( (string) $instructor->user_email, $subject, $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* The instructor to mail, or null when there is nobody to mail: they have not
|
||||
* opted in, their account has gone, or it carries no email address.
|
||||
*/
|
||||
private function recipient( int $instructorId ): ?\WP_User {
|
||||
if ( ! $this->pref->wants( $instructorId ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = get_userdata( $instructorId );
|
||||
if ( ! $user instanceof \WP_User || '' === (string) $user->user_email ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* An instructor's own choice of whether the studio emails them when someone
|
||||
* enrols in one of their group classes or books one of their lessons.
|
||||
*
|
||||
* It is a per-instructor preference, kept in user meta rather than a studio-wide
|
||||
* option, because the people it emails are the ones who should decide: one
|
||||
* instructor wants a heads-up for every booking, another already lives in the
|
||||
* roster and wants no extra mail. The instructor sets it from their own
|
||||
* {@see \Unsupervised\Schedular\Availability\AvailabilityController My
|
||||
* Availability} page.
|
||||
*
|
||||
* It defaults **off**. The notification is a new capability, and turning it on
|
||||
* for every instructor on an existing site the day it ships would mail people
|
||||
* who never asked to be mailed; an instructor who wants it opts in.
|
||||
*/
|
||||
class InstructorNotificationPref {
|
||||
|
||||
/**
|
||||
* User-meta key holding the per-instructor toggle. Stored as `'1'` / `'0'`,
|
||||
* mirroring the `us_*` string-boolean convention the plugin's options use.
|
||||
*/
|
||||
public const META_NOTIFY = 'us_notify_on_booking';
|
||||
|
||||
/**
|
||||
* Whether the given instructor has asked to be emailed about new enrolments
|
||||
* and bookings. Absent meta — the default for every account — reads as off.
|
||||
*/
|
||||
public function wants( int $instructorId ): bool {
|
||||
if ( $instructorId <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return '1' === Val::string( get_user_meta( $instructorId, self::META_NOTIFY, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an instructor's choice. Stored as `'0'` rather than deleted so a
|
||||
* deliberate "no" is told apart from an account that never chose.
|
||||
*/
|
||||
public function set( int $instructorId, bool $wants ): void {
|
||||
if ( $instructorId <= 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
update_user_meta( $instructorId, self::META_NOTIFY, $wants ? '1' : '0' );
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Availability;
|
||||
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationPref;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
@@ -14,6 +15,7 @@ class AvailabilityController {
|
||||
private AvailabilityRepository $repository,
|
||||
private OfferingRepository $offerings,
|
||||
private WindowValidator $validator,
|
||||
private InstructorNotificationPref $notifyPref = new InstructorNotificationPref(),
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
@@ -31,6 +33,7 @@ class AvailabilityController {
|
||||
|
||||
$slots = $this->repository->findByInstructor( $instructorId );
|
||||
$offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true );
|
||||
$notifyOnBooking = $this->notifyPref->wants( $instructorId );
|
||||
|
||||
// View-state query params only (which view, which week) — nothing is
|
||||
// mutated from them, so no nonce applies.
|
||||
@@ -64,6 +67,12 @@ class AvailabilityController {
|
||||
return $this->addSlot( $instructorId );
|
||||
}
|
||||
|
||||
if ( 'save_notify' === $action ) {
|
||||
$this->notifyPref->set( $instructorId, isset( $_POST['notify_on_booking'] ) );
|
||||
|
||||
return [ __( 'Notification preference saved.', 'unsupervised-schedular' ), '' ];
|
||||
}
|
||||
|
||||
if ( 'delete' === $action ) {
|
||||
return $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId )
|
||||
? [ __( 'Availability slot deleted.', 'unsupervised-schedular' ), '' ]
|
||||
|
||||
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
@@ -41,6 +43,7 @@ class LessonBooker {
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentService $payments,
|
||||
private GuardianService $guardians,
|
||||
private InstructorNotificationMailer $instructorMailer = new InstructorNotificationMailer(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -167,6 +170,13 @@ class LessonBooker {
|
||||
* @return array{status: string, payment: ?Payment}
|
||||
*/
|
||||
public function settle( array $ids, int $anchorId, AvailabilitySlot $slot, Offering $offering, int $studentId, bool $noCharge = false ): array {
|
||||
// Both booking paths — the student's own and the studio's on their behalf —
|
||||
// funnel through here once the slot is claimed, so telling the instructor
|
||||
// once here notifies them however the lesson came to be booked. It is an
|
||||
// opt-in courtesy, never a step the booking depends on, so it never fails a
|
||||
// booking that otherwise succeeded.
|
||||
$this->notifyInstructor( $slot, $offering, $studentId, count( $ids ) );
|
||||
|
||||
// Scheduled billing (weekly / monthly) normally defers payment to the daily
|
||||
// scan, but a single lesson booked once its scheduled due date has already
|
||||
// passed — e.g. an extra lesson added to a month that was already billed — is
|
||||
@@ -184,6 +194,7 @@ class LessonBooker {
|
||||
? $offering->price
|
||||
: $offering->price * count( $ids );
|
||||
|
||||
$payerId = $this->guardians->payerFor( $studentId );
|
||||
$payment = $this->payments->createForRegistration(
|
||||
Payment::REG_LESSON,
|
||||
$anchorId,
|
||||
@@ -192,9 +203,15 @@ class LessonBooker {
|
||||
$amount,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
payerId: $this->guardians->payerFor( $studentId )
|
||||
payerId: $payerId
|
||||
);
|
||||
|
||||
// Apply any available credit to a charge raised at booking.
|
||||
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$this->payments->applyCredits( $payerId, [ $payment ] );
|
||||
$payment = $this->payments->findPayment( (int) $payment->id ) ?? $payment;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
|
||||
'payment' => $payment,
|
||||
@@ -216,6 +233,20 @@ class LessonBooker {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the instructor the heads-up their preference asks for, resolving the
|
||||
* student's public name and the lesson's date the same way the studio's own
|
||||
* booking notice does. The mailer itself decides whether the instructor wants
|
||||
* it; here we only build what it needs to say.
|
||||
*/
|
||||
private function notifyInstructor( AvailabilitySlot $slot, Offering $offering, int $studentId, int $count ): void {
|
||||
$student = get_userdata( $studentId );
|
||||
$studentName = UserName::format( $student instanceof \WP_User ? $student : null, $studentId );
|
||||
$when = Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) );
|
||||
|
||||
$this->instructorMailer->notifyLessonBooked( $slot->instructorId, $studentName, $offering->title, $when, $count );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled-billing offering's due date for a lesson has already
|
||||
* gone by — monthly bills on the first of the lesson's month, weekly the day
|
||||
|
||||
@@ -3,7 +3,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
@@ -22,6 +24,7 @@ class EnrollmentEndpoint {
|
||||
private PaymentService $payments,
|
||||
private GroupAccessRepository $access,
|
||||
private GuardianService $guardians,
|
||||
private InstructorNotificationMailer $instructorMailer = new InstructorNotificationMailer(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -156,6 +159,15 @@ class EnrollmentEndpoint {
|
||||
// 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() );
|
||||
|
||||
// Heads-up to the instructor if they asked for one. An opt-in courtesy, not
|
||||
// a step the enrolment depends on, so it never fails an enrolment that took.
|
||||
$student = get_userdata( $studentId );
|
||||
$this->instructorMailer->notifyEnrollment(
|
||||
$offering->instructorId,
|
||||
UserName::format( $student instanceof \WP_User ? $student : null, $studentId ),
|
||||
$offering->title
|
||||
);
|
||||
|
||||
// Mark the access grant used so instructor rosters distinguish invited
|
||||
// students from enrolled ones (a no-op for public classes).
|
||||
if ( $offering->isInviteOnly() ) {
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
||||
use Unsupervised\Schedular\Auth\Invite;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
@@ -31,6 +32,7 @@ class GroupClassController {
|
||||
private RegistrationMailer $mailer,
|
||||
private IntakeAudit $audit,
|
||||
private IntakeRecording $intake,
|
||||
private InstructorNotificationMailer $instructorMailer = new InstructorNotificationMailer(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -505,6 +507,17 @@ class GroupClassController {
|
||||
}
|
||||
|
||||
$this->access->markEnrolled( (int) $offering->id, $studentId );
|
||||
|
||||
// Heads-up to the instructor if they opted in — the same courtesy a
|
||||
// student's own enrolment sends, so an enrolment the studio makes on
|
||||
// their behalf reaches them the same way.
|
||||
$student = get_userdata( $studentId );
|
||||
$this->instructorMailer->notifyEnrollment(
|
||||
$offering->instructorId,
|
||||
UserName::format( $student instanceof \WP_User ? $student : null, $studentId ),
|
||||
$offering->title
|
||||
);
|
||||
|
||||
++$added;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* The editable payment-due email: its subject and body are stored as WordPress
|
||||
* options (falling back to built-in defaults) and rendered by substituting a
|
||||
* small set of `{token}` placeholders with the values the daily billing scan
|
||||
* gathered for a student.
|
||||
*
|
||||
* The body carries an {items} block — one line per charge — and optional
|
||||
* {credit}, {etransfer} and {reference} blocks that the renderer collapses to
|
||||
* nothing when they do not apply, so a studio admin never has to hand-edit
|
||||
* conditional prose.
|
||||
*/
|
||||
class PaymentDueEmailTemplate {
|
||||
|
||||
public const OPT_SUBJECT = 'us_payment_due_email_subject';
|
||||
public const OPT_BODY = 'us_payment_due_email_body';
|
||||
public const OPT_ITEM_LINE = 'us_payment_due_email_item_line';
|
||||
|
||||
/**
|
||||
* Tokens the admin may drop into the subject/body, mapped to a short
|
||||
* translated description shown beside the editor. `{items}` expands to the
|
||||
* itemised list rendered from the item-line template; the credit/etransfer/
|
||||
* reference tokens are whole lines that vanish when not applicable.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function tokens(): array {
|
||||
return [
|
||||
'{student_name}' => __( "The student's display name.", 'unsupervised-schedular' ),
|
||||
'{items}' => __( 'The itemised list of charges (one line each).', 'unsupervised-schedular' ),
|
||||
'{total_due}' => __( 'The grand total due, e.g. CAD 75.00.', 'unsupervised-schedular' ),
|
||||
'{credit}' => __( 'Account-credit line; empty when no credit applies.', 'unsupervised-schedular' ),
|
||||
'{etransfer}' => __( 'E-transfer destination line; empty when nothing is owed.', 'unsupervised-schedular' ),
|
||||
'{reference}' => __( 'Payment reference line; empty when no reference is set.', 'unsupervised-schedular' ),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens the item-line template understands, one charge at a time.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function itemTokens(): array {
|
||||
return [
|
||||
'{label}' => __( 'The charge description, e.g. Piano.', 'unsupervised-schedular' ),
|
||||
'{due_date}' => __( 'The due date, e.g. Jul 15, 2026.', 'unsupervised-schedular' ),
|
||||
'{currency}' => __( 'The currency code, e.g. CAD.', 'unsupervised-schedular' ),
|
||||
'{amount}' => __( 'The charge amount, e.g. 35.00.', 'unsupervised-schedular' ),
|
||||
];
|
||||
}
|
||||
|
||||
public static function defaultSubject(): string {
|
||||
return __( 'Payment due', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
public static function defaultBody(): string {
|
||||
return __(
|
||||
"You have upcoming payments due:\n\n{items}{credit}\n\nTotal due: {total_due}{etransfer}{reference}",
|
||||
'unsupervised-schedular'
|
||||
);
|
||||
}
|
||||
|
||||
public static function defaultItemLine(): string {
|
||||
/* translators: this is a template with tokens; keep the {tokens} intact. */
|
||||
return __( '- {label} (due {due_date}): {currency} {amount}', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
public function subject(): string {
|
||||
$stored = Val::string( get_option( self::OPT_SUBJECT, '' ) );
|
||||
|
||||
return '' !== $stored ? $stored : self::defaultSubject();
|
||||
}
|
||||
|
||||
public function body(): string {
|
||||
$stored = Val::string( get_option( self::OPT_BODY, '' ) );
|
||||
|
||||
return '' !== $stored ? $stored : self::defaultBody();
|
||||
}
|
||||
|
||||
public function itemLine(): string {
|
||||
$stored = Val::string( get_option( self::OPT_ITEM_LINE, '' ) );
|
||||
|
||||
return '' !== $stored ? $stored : self::defaultItemLine();
|
||||
}
|
||||
|
||||
public function saveSubject( string $subject ): void {
|
||||
update_option( self::OPT_SUBJECT, $subject );
|
||||
}
|
||||
|
||||
public function saveBody( string $body ): void {
|
||||
update_option( self::OPT_BODY, $body );
|
||||
}
|
||||
|
||||
public function saveItemLine( string $itemLine ): void {
|
||||
update_option( self::OPT_ITEM_LINE, $itemLine );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stored subject template with the given token values.
|
||||
*
|
||||
* @param array<string, string> $tokens Token => replacement (keys include the braces).
|
||||
*/
|
||||
public function renderSubject( array $tokens ): string {
|
||||
return strtr( $this->subject(), $tokens );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stored body template with the given token values.
|
||||
*
|
||||
* @param array<string, string> $tokens Token => replacement (keys include the braces).
|
||||
*/
|
||||
public function renderBody( array $tokens ): string {
|
||||
return strtr( $this->body(), $tokens );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one itemised charge line from the stored item-line template.
|
||||
*
|
||||
* @param array<string, string> $tokens Item token => replacement (keys include the braces).
|
||||
*/
|
||||
public function renderItemLine( array $tokens ): string {
|
||||
return strtr( $this->itemLine(), $tokens );
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,16 @@ namespace Unsupervised\Schedular\Payment;
|
||||
* scan generated for them in one run, so a student billed for several lessons on
|
||||
* the same day receives one email with a line per item and a grand total — never
|
||||
* one email per lesson.
|
||||
*
|
||||
* The subject and body come from {@see PaymentDueEmailTemplate}, an
|
||||
* admin-editable template of `{token}` placeholders; this class gathers the
|
||||
* values for those tokens (items list, totals, credit/e-transfer/reference
|
||||
* lines) and asks the template to render them.
|
||||
*/
|
||||
class PaymentDueMailer {
|
||||
|
||||
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
|
||||
|
||||
/**
|
||||
* Send one student their consolidated due-payment notice for the current scan.
|
||||
* The optional `$reference` is the shared notice-batch code the student can quote
|
||||
@@ -25,6 +32,29 @@ class PaymentDueMailer {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = $this->buildTokens(
|
||||
(string) $student->display_name,
|
||||
$items,
|
||||
$reference,
|
||||
$creditApplied
|
||||
);
|
||||
|
||||
return (bool) wp_mail(
|
||||
$student->user_email,
|
||||
$this->template->renderSubject( $tokens ),
|
||||
$this->template->renderBody( $tokens )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full token map the subject/body templates are rendered against,
|
||||
* from the same data the daily scan hands the mailer. Exposed so the admin
|
||||
* preview can render the exact email a real scan would produce.
|
||||
*
|
||||
* @param list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}> $items
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function buildTokens( string $studentName, array $items, string $reference = '', float $creditApplied = 0.0 ): array {
|
||||
$currency = (string) $items[0]['currency'];
|
||||
$total = 0.0;
|
||||
$lines = [];
|
||||
@@ -34,13 +64,13 @@ class PaymentDueMailer {
|
||||
$amount = (float) $item['amount'];
|
||||
$total += $amount;
|
||||
|
||||
$lines[] = sprintf(
|
||||
/* translators: 1: item description, 2: due date, 3: currency, 4: amount */
|
||||
__( '- %1$s (due %2$s): %3$s %4$s', 'unsupervised-schedular' ),
|
||||
(string) $item['label'],
|
||||
$this->formatDate( $item['due_date'] ?? null ),
|
||||
$currency,
|
||||
number_format( $amount, 2 )
|
||||
$lines[] = $this->template->renderItemLine(
|
||||
[
|
||||
'{label}' => (string) $item['label'],
|
||||
'{due_date}' => $this->formatDate( $item['due_date'] ?? null ),
|
||||
'{currency}' => $currency,
|
||||
'{amount}' => number_format( $amount, 2 ),
|
||||
]
|
||||
);
|
||||
|
||||
$etransfer = (string) ( $item['etransfer_email'] ?? '' );
|
||||
@@ -53,11 +83,9 @@ class PaymentDueMailer {
|
||||
$creditApplied = round( min( $creditApplied, $total ), 2 );
|
||||
$dueTotal = round( $total - $creditApplied, 2 );
|
||||
|
||||
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
|
||||
. implode( "\n", $lines );
|
||||
|
||||
$credit = '';
|
||||
if ( $creditApplied > 0.0 ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
$credit = "\n\n" . sprintf(
|
||||
/* translators: 1: currency, 2: credit amount */
|
||||
__( 'Account credit applied: -%1$s %2$s', 'unsupervised-schedular' ),
|
||||
$currency,
|
||||
@@ -65,30 +93,32 @@ class PaymentDueMailer {
|
||||
);
|
||||
}
|
||||
|
||||
$body .= "\n\n" . sprintf(
|
||||
/* translators: 1: currency, 2: total amount */
|
||||
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
|
||||
$currency,
|
||||
number_format( $dueTotal, 2 )
|
||||
);
|
||||
|
||||
$etransfer = '';
|
||||
if ( $dueTotal > 0.0 && [] !== $emails ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
$etransfer = "\n\n" . sprintf(
|
||||
/* translators: %s: e-transfer destination email address(es) */
|
||||
__( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ),
|
||||
implode( ', ', array_keys( $emails ) )
|
||||
);
|
||||
}
|
||||
|
||||
$referenceLine = '';
|
||||
if ( '' !== $reference ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
$referenceLine = "\n\n" . sprintf(
|
||||
/* translators: %s: payment reference code */
|
||||
__( 'Please include this reference with your payment: %s', 'unsupervised-schedular' ),
|
||||
$reference
|
||||
);
|
||||
}
|
||||
|
||||
return (bool) wp_mail( $student->user_email, __( 'Payment due', 'unsupervised-schedular' ), $body );
|
||||
return [
|
||||
'{student_name}' => $studentName,
|
||||
'{items}' => implode( "\n", $lines ),
|
||||
'{total_due}' => $currency . ' ' . number_format( $dueTotal, 2 ),
|
||||
'{credit}' => $credit,
|
||||
'{etransfer}' => $etransfer,
|
||||
'{reference}' => $referenceLine,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Admin screen for viewing and editing the payment-due email template, with a
|
||||
* live preview rendered from sample values. Save is a plain POST (nonce +
|
||||
* capability checked); the preview updates client-side against a REST endpoint
|
||||
* so an admin sees the effect of an edit before saving it.
|
||||
*/
|
||||
class PaymentEmailController {
|
||||
|
||||
public const NONCE_ACTION = 'usc_payment_email_action';
|
||||
|
||||
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( self::NONCE_ACTION ) ) {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified immediately above.
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
|
||||
if ( 'reset' === $action ) {
|
||||
$this->reset();
|
||||
$notice = __( 'Template reset to the built-in default.', 'unsupervised-schedular' );
|
||||
} else {
|
||||
$this->save();
|
||||
$notice = __( 'Payment due email template saved.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
|
||||
$subject = $this->template->subject();
|
||||
$body = $this->template->body();
|
||||
$itemLine = $this->template->itemLine();
|
||||
$tokens = PaymentDueEmailTemplate::tokens();
|
||||
$itemTokens = PaymentDueEmailTemplate::itemTokens();
|
||||
$previewNonce = wp_create_nonce( 'wp_rest' );
|
||||
$previewUrl = rest_url( 'us-scheduler/v1/payment-email/preview' );
|
||||
|
||||
// Server-render the initial preview from sample values so the panel is
|
||||
// populated before any JavaScript runs (and if it never does).
|
||||
$preview = self::renderSample( $this->template, $subject, $body, $itemLine );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/payment-email.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the given template text against a fixed set of sample values, using
|
||||
* the real mailer so the preview matches a genuine scan exactly. The passed
|
||||
* subject/body/item-line override the stored ones so an unsaved edit can be
|
||||
* previewed.
|
||||
*
|
||||
* @return array{subject: string, body: string}
|
||||
*/
|
||||
public static function renderSample( PaymentDueEmailTemplate $stored, string $subject, string $body, string $itemLine ): array {
|
||||
// A throwaway template returning the supplied (possibly unsaved) text.
|
||||
$draft = new class( $subject, $body, $itemLine ) extends PaymentDueEmailTemplate {
|
||||
public function __construct(
|
||||
private string $draftSubject,
|
||||
private string $draftBody,
|
||||
private string $draftItemLine,
|
||||
) {}
|
||||
|
||||
public function subject(): string {
|
||||
return '' !== $this->draftSubject ? $this->draftSubject : self::defaultSubject();
|
||||
}
|
||||
|
||||
public function body(): string {
|
||||
return '' !== $this->draftBody ? $this->draftBody : self::defaultBody();
|
||||
}
|
||||
|
||||
public function itemLine(): string {
|
||||
return '' !== $this->draftItemLine ? $this->draftItemLine : self::defaultItemLine();
|
||||
}
|
||||
};
|
||||
|
||||
$mailer = new PaymentDueMailer( $draft );
|
||||
$tokens = $mailer->buildTokens( self::sampleStudentName(), self::sampleItems(), self::sampleReference(), self::sampleCredit() );
|
||||
|
||||
return [
|
||||
'subject' => $draft->renderSubject( $tokens ),
|
||||
'body' => $draft->renderBody( $tokens ),
|
||||
];
|
||||
}
|
||||
|
||||
public static function sampleStudentName(): string {
|
||||
return __( 'Alex Student', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
public static function sampleReference(): string {
|
||||
return 'REF12345';
|
||||
}
|
||||
|
||||
public static function sampleCredit(): float {
|
||||
return 20.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample charges the preview is rendered against — two lessons on
|
||||
* different dates so the {items} block and grand total are both exercised.
|
||||
*
|
||||
* @return list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>
|
||||
*/
|
||||
public static function sampleItems(): array {
|
||||
return [
|
||||
[
|
||||
'label' => __( 'Piano lesson', 'unsupervised-schedular' ),
|
||||
'amount' => 35.0,
|
||||
'currency' => 'CAD',
|
||||
'due_date' => '2026-07-15',
|
||||
'etransfer_email' => '[email protected]',
|
||||
],
|
||||
[
|
||||
'label' => __( 'Guitar lesson', 'unsupervised-schedular' ),
|
||||
'amount' => 40.0,
|
||||
'currency' => 'CAD',
|
||||
'due_date' => '2026-07-22',
|
||||
'etransfer_email' => '[email protected]',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function save(): void {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$this->template->saveSubject( sanitize_text_field( Val::string( wp_unslash( $_POST['subject'] ?? '' ) ) ) );
|
||||
$this->template->saveBody( sanitize_textarea_field( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) ) );
|
||||
$this->template->saveItemLine( sanitize_text_field( Val::string( wp_unslash( $_POST['item_line'] ?? '' ) ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
private function reset(): void {
|
||||
$this->template->saveSubject( '' );
|
||||
$this->template->saveBody( '' );
|
||||
$this->template->saveItemLine( '' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Renders a live preview of the payment-due email from template text the admin
|
||||
* is editing (not yet saved) against fixed sample values, so the settings screen
|
||||
* can show the resulting email as the admin types. Read-only: it never writes
|
||||
* the template.
|
||||
*/
|
||||
class PaymentEmailPreviewEndpoint {
|
||||
|
||||
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
|
||||
|
||||
/**
|
||||
* Registers this endpoint's REST routes.
|
||||
*
|
||||
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
|
||||
*/
|
||||
public function registerRoutes( string $route_namespace ): void {
|
||||
register_rest_route(
|
||||
$route_namespace,
|
||||
'/payment-email/preview',
|
||||
[
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [ $this, 'preview' ],
|
||||
'permission_callback' => [ $this, 'canManage' ],
|
||||
'args' => [
|
||||
'subject' => [ 'type' => 'string' ],
|
||||
'body' => [ 'type' => 'string' ],
|
||||
'item_line' => [ 'type' => 'string' ],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the submitted (draft) template text against the sample values.
|
||||
*/
|
||||
public function preview( \WP_REST_Request $request ): \WP_REST_Response {
|
||||
$subject = Val::string( $request->get_param( 'subject' ) );
|
||||
$body = Val::string( $request->get_param( 'body' ) );
|
||||
$itemLine = Val::string( $request->get_param( 'item_line' ) );
|
||||
|
||||
$rendered = PaymentEmailController::renderSample( $this->template, $subject, $body, $itemLine );
|
||||
|
||||
return new \WP_REST_Response( $rendered, 200 );
|
||||
}
|
||||
|
||||
public function canManage(): bool {
|
||||
return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_BILLING );
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,15 @@ class PaymentService {
|
||||
return $this->payments->markNoticed( $paymentId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read a payment from the ledger — the caller's way to pick up a status or
|
||||
* credit change {@see applyCredits} wrote straight to the row, since the
|
||||
* Payment object it holds is immutable. Delegates to the ledger.
|
||||
*/
|
||||
public function findPayment( int $paymentId ): ?Payment {
|
||||
return $this->payments->findById( $paymentId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Studio-admin confirmation that a pending payment (e-transfer) was received.
|
||||
* Marks it paid, confirms the registration, and emails the receipt.
|
||||
|
||||
+11
-7
@@ -18,6 +18,7 @@ use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\BillingModeReconciler;
|
||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentEmailPreviewEndpoint;
|
||||
use Unsupervised\Schedular\Payment\PaymentEndpoint;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
@@ -40,15 +41,17 @@ class RestRegistrar {
|
||||
private PolicyEndpoint $policyEndpoint;
|
||||
private EnrollmentEndpoint $enrollmentEndpoint;
|
||||
private PaymentEndpoint $paymentEndpoint;
|
||||
private PaymentEmailPreviewEndpoint $paymentEmailPreviewEndpoint;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians, LessonBooker $booker, BillingModeReconciler $billingModeReconciler ) {
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess, $billingModeReconciler );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
|
||||
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess, $billingModeReconciler );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
|
||||
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
|
||||
$this->paymentEmailPreviewEndpoint = new PaymentEmailPreviewEndpoint();
|
||||
}
|
||||
|
||||
public function register(): void {
|
||||
@@ -63,5 +66,6 @@ class RestRegistrar {
|
||||
$this->policyEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->enrollmentEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->paymentEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->paymentEmailPreviewEndpoint->registerRoutes( self::NAMESPACE );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ if (! defined('ABSPATH')) {
|
||||
* @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.
|
||||
* @var bool $notifyOnBooking Whether the instructor is emailed on new bookings/enrolments.
|
||||
*/
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
@@ -44,6 +45,25 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
|
||||
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Notifications', 'unsupervised-schedular'); ?></h2>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_availability_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="save_notify">
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Booking emails', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" name="notify_on_booking" value="1" <?php checked($notifyOnBooking); ?>>
|
||||
<?php esc_html_e('Email me when someone books a lesson or enrols in one of my group classes', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<p class="description"><?php esc_html_e('Off by default. Covers both your private lessons and your group classes, whether the student booked themselves or the studio booked for them.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php submit_button(esc_html__('Save Notification Preference', 'unsupervised-schedular')); ?>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
<form method="post" id="usc-add-availability">
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var string $subject
|
||||
* @var string $body
|
||||
* @var string $itemLine
|
||||
* @var array<string, string> $tokens
|
||||
* @var array<string, string> $itemTokens
|
||||
* @var string $previewNonce
|
||||
* @var string $previewUrl
|
||||
* @var array{subject: string, body: string} $preview
|
||||
* @var string $notice
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Payment Due Email', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success inline">
|
||||
<p><?php echo esc_html($notice); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e('This is the email a student receives when the daily billing scan finds payments due for them. Edit the subject and body below, then preview the result with sample values. Leave a field blank to use the built-in default.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
|
||||
<form method="post" id="usc-payment-email-form">
|
||||
<?php wp_nonce_field('usc_payment_email_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="save">
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-pe-subject"><?php esc_html_e('Subject', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" name="subject" id="usc-pe-subject" class="large-text" value="<?php echo esc_attr($subject); ?>">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-pe-body"><?php esc_html_e('Body', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<textarea name="body" id="usc-pe-body" class="large-text code" rows="10"><?php echo esc_textarea($body); ?></textarea>
|
||||
<p class="description"><?php esc_html_e('Available tokens:', 'unsupervised-schedular'); ?></p>
|
||||
<ul>
|
||||
<?php foreach ($tokens as $token => $description) : ?>
|
||||
<li><code><?php echo esc_html($token); ?></code> — <?php echo esc_html($description); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-pe-item-line"><?php esc_html_e('Item line', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" name="item_line" id="usc-pe-item-line" class="large-text code" value="<?php echo esc_attr($itemLine); ?>">
|
||||
<p class="description"><?php esc_html_e('The template for each charge in the {items} block. Available tokens:', 'unsupervised-schedular'); ?></p>
|
||||
<ul>
|
||||
<?php foreach ($itemTokens as $token => $description) : ?>
|
||||
<li><code><?php echo esc_html($token); ?></code> — <?php echo esc_html($description); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php submit_button(esc_html__('Save Template', 'unsupervised-schedular')); ?>
|
||||
</form>
|
||||
|
||||
<h2><?php esc_html_e('Reset to default', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description"><?php esc_html_e('Discards your custom subject, body and item line, restoring the built-in default template.', 'unsupervised-schedular'); ?></p>
|
||||
<form method="post" onsubmit="return confirm('<?php echo esc_js(esc_html__('Reset the payment due email to its default template?', 'unsupervised-schedular')); ?>');">
|
||||
<?php wp_nonce_field('usc_payment_email_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="reset">
|
||||
<?php submit_button(esc_html__('Reset to default', 'unsupervised-schedular'), 'delete', 'submit', true); ?>
|
||||
</form>
|
||||
|
||||
<h2><?php esc_html_e('Preview', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description"><?php esc_html_e('Rendered with sample values. Updates as you edit above.', 'unsupervised-schedular'); ?></p>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Subject', 'unsupervised-schedular'); ?></th>
|
||||
<td><strong id="usc-pe-preview-subject"><?php echo esc_html($preview['subject']); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Body', 'unsupervised-schedular'); ?></th>
|
||||
<td><pre id="usc-pe-preview-body" style="white-space:pre-wrap;background:#fff;border:1px solid #ccd0d4;padding:12px;margin:0;max-width:640px;"><?php echo esc_html($preview['body']); ?></pre></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
window.uscPaymentEmailPreview = {
|
||||
url: <?php echo wp_json_encode($previewUrl); ?>,
|
||||
nonce: <?php echo wp_json_encode($previewNonce); ?>
|
||||
};
|
||||
</script>
|
||||
@@ -114,6 +114,39 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
||||
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
/*
|
||||
* Surface the credit balance up top the moment there is one, so the studio
|
||||
* sees at a glance that this student is owed against future billing without
|
||||
* scrolling to the Account credit section. Only shown when there is credit to
|
||||
* report — a zero balance is not news. The full breakdown stays below.
|
||||
*/
|
||||
?>
|
||||
<?php if ($canBilling && $creditBalance > 0) : ?>
|
||||
<div class="notice notice-info inline">
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: total available credit, e.g. "45.00 CAD" */
|
||||
esc_html__('Total account credit: %s', 'unsupervised-schedular'),
|
||||
'<strong>' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '</strong>'
|
||||
);
|
||||
?>
|
||||
<?php if ($payer['id'] !== (int) $student->ID) : ?>
|
||||
<span class="description">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: name of the parent/guardian whose account holds the balance. */
|
||||
esc_html__('Held on %s’s account.', 'unsupervised-schedular'),
|
||||
esc_html($payer['name'])
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
|
||||
|
||||
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationPref;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class InstructorNotificationMailerTest extends TestCase
|
||||
{
|
||||
private InstructorNotificationPref&Mockery\MockInterface $pref;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->pref = Mockery::mock(InstructorNotificationPref::class);
|
||||
}
|
||||
|
||||
private function mailer(): InstructorNotificationMailer
|
||||
{
|
||||
return new InstructorNotificationMailer($this->pref);
|
||||
}
|
||||
|
||||
private function instructor(string $email): \WP_User
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->user_email = $email;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function testLessonNoticeGoesToAnOptedInInstructor(): void
|
||||
{
|
||||
$this->pref->shouldReceive('wants')->with(9)->andReturn(true);
|
||||
Functions\when('get_userdata')->justReturn($this->instructor('[email protected]'));
|
||||
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::on(static fn (string $subject): bool => str_contains($subject, '30 min piano')),
|
||||
Mockery::on(static fn (string $body): bool => str_contains($body, 'Ada') && str_contains($body, 'Jul 1'))
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
self::assertTrue($this->mailer()->notifyLessonBooked(9, 'Ada', '30 min piano', 'Jul 1, 2026 10:00 AM'));
|
||||
}
|
||||
|
||||
public function testWeeklyLessonNoticeCountsTheOccurrences(): void
|
||||
{
|
||||
$this->pref->shouldReceive('wants')->with(9)->andReturn(true);
|
||||
Functions\when('get_userdata')->justReturn($this->instructor('[email protected]'));
|
||||
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::type('string'),
|
||||
Mockery::on(static fn (string $body): bool => str_contains($body, '3 weekly lessons'))
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
self::assertTrue($this->mailer()->notifyLessonBooked(9, 'Ada', '30 min piano', 'Jul 1, 2026 10:00 AM', 3));
|
||||
}
|
||||
|
||||
public function testEnrollmentNoticeGoesToAnOptedInInstructor(): void
|
||||
{
|
||||
$this->pref->shouldReceive('wants')->with(9)->andReturn(true);
|
||||
Functions\when('get_userdata')->justReturn($this->instructor('[email protected]'));
|
||||
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::on(static fn (string $subject): bool => str_contains($subject, 'Choir')),
|
||||
Mockery::on(static fn (string $body): bool => str_contains($body, 'Ada') && str_contains($body, 'Choir'))
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
self::assertTrue($this->mailer()->notifyEnrollment(9, 'Ada', 'Choir'));
|
||||
}
|
||||
|
||||
public function testSendsNothingWhenTheInstructorHasNotOptedIn(): void
|
||||
{
|
||||
$this->pref->shouldReceive('wants')->with(9)->andReturn(false);
|
||||
// Not even a user lookup: the preference is the first gate.
|
||||
Functions\expect('get_userdata')->never();
|
||||
Functions\expect('wp_mail')->never();
|
||||
|
||||
self::assertFalse($this->mailer()->notifyLessonBooked(9, 'Ada', '30 min piano', 'Jul 1, 2026 10:00 AM'));
|
||||
self::assertFalse($this->mailer()->notifyEnrollment(9, 'Ada', 'Choir'));
|
||||
}
|
||||
|
||||
public function testSendsNothingWhenTheInstructorAccountIsGone(): void
|
||||
{
|
||||
$this->pref->shouldReceive('wants')->with(9)->andReturn(true);
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
Functions\expect('wp_mail')->never();
|
||||
|
||||
self::assertFalse($this->mailer()->notifyEnrollment(9, 'Ada', 'Choir'));
|
||||
}
|
||||
|
||||
public function testSendsNothingWhenTheInstructorHasNoEmail(): void
|
||||
{
|
||||
$this->pref->shouldReceive('wants')->with(9)->andReturn(true);
|
||||
Functions\when('get_userdata')->justReturn($this->instructor(''));
|
||||
Functions\expect('wp_mail')->never();
|
||||
|
||||
self::assertFalse($this->mailer()->notifyLessonBooked(9, 'Ada', '30 min piano', 'Jul 1, 2026 10:00 AM'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationPref;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class InstructorNotificationPrefTest extends TestCase
|
||||
{
|
||||
public function testDefaultsOffWhenTheMetaIsUnset(): void
|
||||
{
|
||||
// Absent meta reads as an empty string, which is not the opt-in value.
|
||||
Functions\when('get_user_meta')->justReturn('');
|
||||
|
||||
self::assertFalse((new InstructorNotificationPref())->wants(7));
|
||||
}
|
||||
|
||||
public function testReadsAStoredOptIn(): void
|
||||
{
|
||||
Functions\when('get_user_meta')->alias(
|
||||
static fn (int $id, string $key): string => 7 === $id && InstructorNotificationPref::META_NOTIFY === $key ? '1' : ''
|
||||
);
|
||||
|
||||
self::assertTrue((new InstructorNotificationPref())->wants(7));
|
||||
}
|
||||
|
||||
public function testAStoredNoReadsAsOff(): void
|
||||
{
|
||||
Functions\when('get_user_meta')->justReturn('0');
|
||||
|
||||
self::assertFalse((new InstructorNotificationPref())->wants(7));
|
||||
}
|
||||
|
||||
public function testNobodyWantsNothing(): void
|
||||
{
|
||||
// A zero id is not a user; it must never read as opted-in.
|
||||
Functions\expect('get_user_meta')->never();
|
||||
|
||||
self::assertFalse((new InstructorNotificationPref())->wants(0));
|
||||
}
|
||||
|
||||
public function testSetStoresTheChoiceAsAStringBoolean(): void
|
||||
{
|
||||
Functions\expect('update_user_meta')->once()->with(7, InstructorNotificationPref::META_NOTIFY, '1')->andReturn(true);
|
||||
|
||||
(new InstructorNotificationPref())->set(7, true);
|
||||
}
|
||||
|
||||
public function testSetStoresADeliberateNoRatherThanDeleting(): void
|
||||
{
|
||||
Functions\expect('update_user_meta')->once()->with(7, InstructorNotificationPref::META_NOTIFY, '0')->andReturn(true);
|
||||
|
||||
(new InstructorNotificationPref())->set(7, false);
|
||||
}
|
||||
|
||||
public function testSetIgnoresANonUser(): void
|
||||
{
|
||||
Functions\expect('update_user_meta')->never();
|
||||
|
||||
(new InstructorNotificationPref())->set(0, true);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Availability;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationPref;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityController;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
@@ -16,6 +17,7 @@ class AvailabilityControllerTest extends TestCase
|
||||
{
|
||||
private AvailabilityRepository&Mockery\MockInterface $repository;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private InstructorNotificationPref&Mockery\MockInterface $notifyPref;
|
||||
private AvailabilityController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -24,7 +26,12 @@ class AvailabilityControllerTest extends TestCase
|
||||
|
||||
$this->repository = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->controller = new AvailabilityController($this->repository, $this->offerings, new WindowValidator($this->offerings));
|
||||
// The notification preference is exercised in its own tests; here it simply
|
||||
// reads off and accepts any save, so availability tests stay about slots.
|
||||
$this->notifyPref = Mockery::mock(InstructorNotificationPref::class);
|
||||
$this->notifyPref->shouldReceive('wants')->andReturn(false)->byDefault();
|
||||
$this->notifyPref->shouldReceive('set')->byDefault();
|
||||
$this->controller = new AvailabilityController($this->repository, $this->offerings, new WindowValidator($this->offerings), $this->notifyPref);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
@@ -286,6 +293,29 @@ class AvailabilityControllerTest extends TestCase
|
||||
self::assertStringContainsString('1 slot could not be deleted', $html);
|
||||
}
|
||||
|
||||
public function testTickingTheNotificationBoxSavesAnOptIn(): void
|
||||
{
|
||||
$_POST = ['usc_action' => 'save_notify', 'notify_on_booking' => '1'];
|
||||
|
||||
$this->notifyPref->shouldReceive('set')->once()->with(3, true);
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
self::assertStringContainsString('Notification preference saved.', $html);
|
||||
}
|
||||
|
||||
public function testLeavingTheNotificationBoxUntickedSavesAnOptOut(): void
|
||||
{
|
||||
$_POST = ['usc_action' => 'save_notify'];
|
||||
|
||||
$this->notifyPref->shouldReceive('set')->once()->with(3, false);
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$this->render();
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
@@ -48,15 +49,26 @@ class AdminBookingTest extends TestCase
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
// A charge raised at booking has the payer's credit applied before it
|
||||
// settles. The default holds no balance and re-reads the same payment.
|
||||
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
|
||||
$this->payments->shouldReceive('findPayment')->andReturnUsing(
|
||||
static fn (int $id): ?Payment => null
|
||||
)->byDefault();
|
||||
$this->guardians = Mockery::mock(GuardianService::class);
|
||||
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
|
||||
|
||||
// The real booker over mocked repositories: an admin booking must go
|
||||
// through exactly the machinery a student's own booking does.
|
||||
// The opt-in instructor notice is tested on its own; a mock keeps these
|
||||
// tests about booking, not about who gets emailed.
|
||||
$instructorMailer = Mockery::mock(InstructorNotificationMailer::class);
|
||||
$instructorMailer->shouldReceive('notifyLessonBooked')->andReturn(true)->byDefault();
|
||||
|
||||
$this->admin = new AdminBooking(
|
||||
$this->availability,
|
||||
$this->offerings,
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians)
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians, $instructorMailer)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
@@ -46,6 +47,12 @@ class BookingEndpointTest extends TestCase
|
||||
// Fixed "now" well before the fixture slot start (2026-07-01 10:00), so
|
||||
// the cancellation cutoff never trips unless a test moves it.
|
||||
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
|
||||
// A successful booking resolves the student's name and the lesson date for
|
||||
// the (mocked) instructor notice; neither shapes what these tests assert.
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
Functions\when('mysql2date')->alias(
|
||||
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
|
||||
);
|
||||
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
@@ -57,6 +64,13 @@ class BookingEndpointTest extends TestCase
|
||||
// Crediting a cancelled paid lesson is exercised in dedicated tests; other
|
||||
// cancellation paths simply allow the call.
|
||||
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
|
||||
// A charge raised at booking applies the payer's credit before it settles.
|
||||
// The default holds no balance and re-reads the same payment; the
|
||||
// same-month-rebook test overrides both.
|
||||
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
|
||||
$this->payments->shouldReceive('findPayment')->andReturnUsing(
|
||||
static fn (int $id): ?Payment => null
|
||||
)->byDefault();
|
||||
|
||||
$this->guardians = Mockery::mock(GuardianService::class);
|
||||
// The default account books only for itself: no guardian link anywhere.
|
||||
@@ -71,6 +85,11 @@ class BookingEndpointTest extends TestCase
|
||||
$this->sessions->shouldReceive('upcomingForStudent')->andReturn([])->byDefault();
|
||||
$this->sessions->shouldReceive('upcomingForInstructor')->andReturn([])->byDefault();
|
||||
|
||||
// The opt-in instructor notice is tested on its own; a mock keeps these
|
||||
// tests about booking, not about who gets emailed.
|
||||
$instructorMailer = Mockery::mock(InstructorNotificationMailer::class);
|
||||
$instructorMailer->shouldReceive('notifyLessonBooked')->andReturn(true)->byDefault();
|
||||
|
||||
$this->endpoint = new BookingEndpoint(
|
||||
$this->availability,
|
||||
$this->bookings,
|
||||
@@ -80,7 +99,7 @@ class BookingEndpointTest extends TestCase
|
||||
// The real booker over the same mocked repositories: these tests are
|
||||
// about what a booking does end to end, and the booker is where most
|
||||
// of that now lives.
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians),
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians, $instructorMailer),
|
||||
new CancellationPolicy($this->settings),
|
||||
$this->guardians,
|
||||
$this->sessions,
|
||||
@@ -463,6 +482,42 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertNotNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testMonthlyRebookInBilledMonthAppliesAccountCredit(): void
|
||||
{
|
||||
// Rebooking a cancelled monthly lesson inside an already-billed month is
|
||||
// charged at booking. The payer holds a cancellation credit that must be
|
||||
// applied to that charge — otherwise the family is billed twice for the same
|
||||
// slot. A credit that fully covers it settles the payment and confirms the
|
||||
// lesson, so the front end runs no payment step.
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn(
|
||||
new AvailabilitySlot(instructorId: 3, startDt: '2026-06-20 10:00:00', endDt: '2026-06-20 11:00:00', offeringId: null, id: 10)
|
||||
);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
|
||||
$pending = new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12);
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null, null, null, 5)
|
||||
->andReturn($pending);
|
||||
|
||||
// Credit is applied against the fresh charge for the same payer.
|
||||
$this->payments->shouldReceive('applyCredits')->once()->with(5, [$pending])->andReturn([12 => 45.0]);
|
||||
// applyCredits settled the row; the re-read reflects it as paid.
|
||||
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, creditApplied: 45.0, id: 12);
|
||||
$this->payments->shouldReceive('findPayment')->with(12)->andReturn($paid);
|
||||
|
||||
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
}
|
||||
|
||||
public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
|
||||
{
|
||||
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
@@ -26,6 +27,7 @@ class EnrollmentEndpointTest extends TestCase
|
||||
private RegistrationGate $gate;
|
||||
private PaymentService $payments;
|
||||
private GroupAccessRepository $access;
|
||||
private InstructorNotificationMailer&Mockery\MockInterface $instructorMailer;
|
||||
private EnrollmentEndpoint $endpoint;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -37,6 +39,9 @@ class EnrollmentEndpointTest extends TestCase
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(5);
|
||||
Functions\when('current_time')->justReturn('2026-07-24');
|
||||
// The enrolment notice resolves the student's name before handing off to the
|
||||
// (mocked) mailer; a bare false is enough since the name falls back to the id.
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
|
||||
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
@@ -44,6 +49,11 @@ class EnrollmentEndpointTest extends TestCase
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
$this->access = Mockery::mock(GroupAccessRepository::class);
|
||||
|
||||
// The opt-in instructor notice is exercised on its own; here it is a mock
|
||||
// that ignores whatever it is handed, so enrolment tests stay about enrolment.
|
||||
$this->instructorMailer = Mockery::mock(InstructorNotificationMailer::class);
|
||||
$this->instructorMailer->shouldReceive('notifyEnrollment')->andReturn(true)->byDefault();
|
||||
|
||||
$this->guardians = Mockery::mock(GuardianService::class);
|
||||
$this->guardians->shouldReceive('canActFor')
|
||||
->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault();
|
||||
@@ -57,6 +67,7 @@ class EnrollmentEndpointTest extends TestCase
|
||||
$this->payments,
|
||||
$this->access,
|
||||
$this->guardians,
|
||||
$this->instructorMailer,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +104,19 @@ class EnrollmentEndpointTest extends TestCase
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testASuccessfulEnrolmentNotifiesTheClassInstructor(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0));
|
||||
$this->expectSuccessfulEnrollment();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
|
||||
// The class is taught by instructor 3; the enrolling student falls back to
|
||||
// their id for a name (get_userdata is stubbed false in setUp).
|
||||
$this->instructorMailer->shouldReceive('notifyEnrollment')->once()->with(3, '5', 'Choir')->andReturn(true);
|
||||
|
||||
$this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
}
|
||||
|
||||
public function testEnrollInPricedClassReturnsPaymentSummary(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
@@ -32,6 +33,7 @@ class GroupClassControllerTest extends TestCase
|
||||
private RegistrationMailer&Mockery\MockInterface $mailer;
|
||||
private IntakeAudit&Mockery\MockInterface $audit;
|
||||
private IntakeRecording&Mockery\MockInterface $intake;
|
||||
private InstructorNotificationMailer&Mockery\MockInterface $instructorMailer;
|
||||
private GroupClassController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -47,6 +49,10 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->mailer = Mockery::mock(RegistrationMailer::class);
|
||||
$this->audit = Mockery::mock(IntakeAudit::class);
|
||||
$this->intake = Mockery::mock(IntakeRecording::class);
|
||||
// The opt-in instructor notice is tested on its own; a mock keeps these
|
||||
// tests about enrolment, not about who gets emailed.
|
||||
$this->instructorMailer = Mockery::mock(InstructorNotificationMailer::class);
|
||||
$this->instructorMailer->shouldReceive('notifyEnrollment')->andReturn(true)->byDefault();
|
||||
$this->controller = new GroupClassController(
|
||||
$this->enrollments,
|
||||
$this->offerings,
|
||||
@@ -57,6 +63,7 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->mailer,
|
||||
$this->audit,
|
||||
$this->intake,
|
||||
$this->instructorMailer,
|
||||
);
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueEmailTemplate;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentDueEmailTemplateTest extends TestCase
|
||||
{
|
||||
public function testFallsBackToDefaultsWhenUnset(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static fn (string $name, $default = '') => '');
|
||||
|
||||
$template = new PaymentDueEmailTemplate();
|
||||
|
||||
self::assertSame(PaymentDueEmailTemplate::defaultSubject(), $template->subject());
|
||||
self::assertSame(PaymentDueEmailTemplate::defaultBody(), $template->body());
|
||||
self::assertSame(PaymentDueEmailTemplate::defaultItemLine(), $template->itemLine());
|
||||
}
|
||||
|
||||
public function testReadsStoredValues(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static function (string $name) {
|
||||
return match ($name) {
|
||||
PaymentDueEmailTemplate::OPT_SUBJECT => 'Custom subject',
|
||||
PaymentDueEmailTemplate::OPT_BODY => 'Custom body {items}',
|
||||
PaymentDueEmailTemplate::OPT_ITEM_LINE => '{label}: {amount}',
|
||||
default => '',
|
||||
};
|
||||
});
|
||||
|
||||
$template = new PaymentDueEmailTemplate();
|
||||
|
||||
self::assertSame('Custom subject', $template->subject());
|
||||
self::assertSame('Custom body {items}', $template->body());
|
||||
self::assertSame('{label}: {amount}', $template->itemLine());
|
||||
}
|
||||
|
||||
public function testRenderSubstitutesTokens(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static function (string $name) {
|
||||
return match ($name) {
|
||||
PaymentDueEmailTemplate::OPT_SUBJECT => 'Hi {student_name}',
|
||||
PaymentDueEmailTemplate::OPT_BODY => 'Total: {total_due}',
|
||||
default => '',
|
||||
};
|
||||
});
|
||||
|
||||
$template = new PaymentDueEmailTemplate();
|
||||
|
||||
self::assertSame('Hi Sam', $template->renderSubject(['{student_name}' => 'Sam']));
|
||||
self::assertSame('Total: CAD 10.00', $template->renderBody(['{total_due}' => 'CAD 10.00']));
|
||||
}
|
||||
|
||||
public function testSaveWritesOptions(): void
|
||||
{
|
||||
Functions\expect('update_option')->once()->with(PaymentDueEmailTemplate::OPT_SUBJECT, 'S');
|
||||
Functions\expect('update_option')->once()->with(PaymentDueEmailTemplate::OPT_BODY, 'B');
|
||||
Functions\expect('update_option')->once()->with(PaymentDueEmailTemplate::OPT_ITEM_LINE, 'I');
|
||||
|
||||
$template = new PaymentDueEmailTemplate();
|
||||
$template->saveSubject('S');
|
||||
$template->saveBody('B');
|
||||
$template->saveItemLine('I');
|
||||
}
|
||||
|
||||
public function testDefaultBodyCarriesEveryBlockToken(): void
|
||||
{
|
||||
$body = PaymentDueEmailTemplate::defaultBody();
|
||||
|
||||
foreach (['{items}', '{credit}', '{total_due}', '{etransfer}', '{reference}'] as $token) {
|
||||
self::assertStringContainsString($token, $body);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,27 @@ namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueEmailTemplate;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentDueMailerTest extends TestCase
|
||||
{
|
||||
private function student(string $email): \WP_User
|
||||
protected function setUp(): void
|
||||
{
|
||||
$student = Mockery::mock(\WP_User::class);
|
||||
$student->user_email = $email;
|
||||
parent::setUp();
|
||||
|
||||
// The mailer renders from PaymentDueEmailTemplate, which reads its
|
||||
// subject/body/item-line from options. An empty stored value means the
|
||||
// built-in default template is used — the behaviour these tests assert.
|
||||
Functions\when('get_option')->alias(static fn (string $name, $default = '') => '');
|
||||
}
|
||||
|
||||
private function student(string $email, string $name = 'Alex Student'): \WP_User
|
||||
{
|
||||
$student = Mockery::mock(\WP_User::class);
|
||||
$student->user_email = $email;
|
||||
$student->display_name = $name;
|
||||
|
||||
return $student;
|
||||
}
|
||||
@@ -129,4 +141,39 @@ class PaymentDueMailerTest extends TestCase
|
||||
|
||||
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items));
|
||||
}
|
||||
|
||||
public function testRendersCustomTemplateWithTokens(): void
|
||||
{
|
||||
// A stored template overrides the default; tokens are substituted with
|
||||
// the real values gathered from the items and student.
|
||||
Functions\when('get_option')->alias(static function (string $name) {
|
||||
if ($name === PaymentDueEmailTemplate::OPT_SUBJECT) {
|
||||
return 'Hi {student_name} — {total_due}';
|
||||
}
|
||||
if ($name === PaymentDueEmailTemplate::OPT_BODY) {
|
||||
return "Dear {student_name},\n{items}\nOwing: {total_due}";
|
||||
}
|
||||
if ($name === PaymentDueEmailTemplate::OPT_ITEM_LINE) {
|
||||
return '* {label} = {currency} {amount}';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
'Hi Jordan — CAD 35.00',
|
||||
Mockery::on(static function (string $body): bool {
|
||||
return str_contains($body, 'Dear Jordan,')
|
||||
&& str_contains($body, '* Piano = CAD 35.00')
|
||||
&& str_contains($body, 'Owing: CAD 35.00');
|
||||
})
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
$items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => null ]];
|
||||
|
||||
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]', 'Jordan'), $items));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueEmailTemplate;
|
||||
use Unsupervised\Schedular\Payment\PaymentEmailController;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentEmailControllerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('get_option')->alias(static fn (string $name, $default = '') => '');
|
||||
}
|
||||
|
||||
public function testRenderSampleUsesDefaultTemplateWhenDraftBlank(): void
|
||||
{
|
||||
$stored = new PaymentDueEmailTemplate();
|
||||
|
||||
$rendered = PaymentEmailController::renderSample($stored, '', '', '');
|
||||
|
||||
self::assertSame(PaymentDueEmailTemplate::defaultSubject(), $rendered['subject']);
|
||||
// Default body lists both sample items with a grand total and the sample
|
||||
// reference/credit blocks resolved.
|
||||
self::assertStringContainsString('Piano lesson', $rendered['body']);
|
||||
self::assertStringContainsString('Guitar lesson', $rendered['body']);
|
||||
self::assertStringContainsString('Jul 15, 2026', $rendered['body']);
|
||||
self::assertStringContainsString('Total due: CAD 55.00', $rendered['body']); // 75 - 20 credit
|
||||
self::assertStringContainsString('REF12345', $rendered['body']);
|
||||
self::assertStringContainsString('[email protected]', $rendered['body']);
|
||||
}
|
||||
|
||||
public function testRenderSampleUsesDraftOverStored(): void
|
||||
{
|
||||
$stored = new PaymentDueEmailTemplate();
|
||||
|
||||
$rendered = PaymentEmailController::renderSample(
|
||||
$stored,
|
||||
'Draft: {total_due}',
|
||||
"Hello {student_name}\n{items}",
|
||||
'> {label} {amount}'
|
||||
);
|
||||
|
||||
self::assertSame('Draft: CAD 55.00', $rendered['subject']);
|
||||
self::assertStringContainsString('Hello ' . PaymentEmailController::sampleStudentName(), $rendered['body']);
|
||||
self::assertStringContainsString('> Piano lesson 35.00', $rendered['body']);
|
||||
}
|
||||
|
||||
public function testSampleItemsAreTwoLessons(): void
|
||||
{
|
||||
$items = PaymentEmailController::sampleItems();
|
||||
|
||||
self::assertCount(2, $items);
|
||||
self::assertSame(35.0, $items[0]['amount']);
|
||||
self::assertSame(40.0, $items[1]['amount']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Payment\PaymentEmailPreviewEndpoint;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentEmailPreviewEndpointTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('get_option')->alias(static fn (string $name, $default = '') => '');
|
||||
}
|
||||
|
||||
public function testPreviewRendersDraftAgainstSampleValues(): void
|
||||
{
|
||||
$endpoint = new PaymentEmailPreviewEndpoint();
|
||||
|
||||
$request = new \WP_REST_Request([
|
||||
'subject' => 'Draft {total_due}',
|
||||
'body' => "Hi {student_name}\n{items}",
|
||||
'item_line' => '- {label} {amount}',
|
||||
]);
|
||||
|
||||
$response = $endpoint->preview($request);
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $response);
|
||||
$data = $response->get_data();
|
||||
self::assertSame('Draft CAD 55.00', $data['subject']);
|
||||
self::assertStringContainsString('Piano lesson', $data['body']);
|
||||
self::assertStringContainsString('- Piano lesson 35.00', $data['body']);
|
||||
}
|
||||
|
||||
public function testCanManageRequiresBillingCapability(): void
|
||||
{
|
||||
$endpoint = new PaymentEmailPreviewEndpoint();
|
||||
|
||||
Functions\when('is_user_logged_in')->justReturn(true);
|
||||
Functions\when('current_user_can')->alias(
|
||||
static fn (string $cap): bool => $cap === RoleManager::CAP_MANAGE_BILLING
|
||||
);
|
||||
|
||||
self::assertTrue($endpoint->canManage());
|
||||
}
|
||||
|
||||
public function testCanManageDeniesWithoutCapability(): void
|
||||
{
|
||||
$endpoint = new PaymentEmailPreviewEndpoint();
|
||||
|
||||
Functions\when('is_user_logged_in')->justReturn(true);
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
|
||||
self::assertFalse($endpoint->canManage());
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Unsupervised Scheduler
|
||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||
* Description: Instructor/student lesson scheduling for WordPress.
|
||||
* Version: 1.5.8
|
||||
* Version: 1.6.1
|
||||
* Requires at least: 6.2
|
||||
* Requires PHP: 8.1
|
||||
* Author: Unsupervised
|
||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('USC_VERSION', '1.5.8');
|
||||
define('USC_VERSION', '1.6.1');
|
||||
define('USC_PLUGIN_FILE', __FILE__);
|
||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user