Add editable, previewable payment-due email template #203

Merged
thatguygriff merged 1 commits from feature/editable-payment-due-email into main 2026-09-17 20:40:37 +00:00
14 changed files with 841 additions and 41 deletions
+5
View File
@@ -11,6 +11,11 @@ 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.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
+60
View File
@@ -0,0 +1,60 @@
/**
* Payment due email editor: live preview.
*
* Posts the subject/body/item-line the admin is editing to the read-only preview
* REST endpoint and swaps the rendered result into the preview panel, debounced
* as they type. The server always renders from the same sample values, so this
* mirrors exactly what a real billing scan would send. Purely a convenience the
* page already shows a server-rendered preview of the saved template without it.
*/
(function () {
'use strict';
const config = window.uscPaymentEmailPreview;
if (!config || !config.url) return;
const subjectEl = document.getElementById('usc-pe-subject');
const bodyEl = document.getElementById('usc-pe-body');
const itemLineEl = document.getElementById('usc-pe-item-line');
const outSubject = document.getElementById('usc-pe-preview-subject');
const outBody = document.getElementById('usc-pe-preview-body');
if (!subjectEl || !bodyEl || !itemLineEl || !outSubject || !outBody) return;
let timer = null;
function refresh() {
fetch(config.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': config.nonce
},
body: JSON.stringify({
subject: subjectEl.value,
body: bodyEl.value,
item_line: itemLineEl.value
})
})
.then(function (response) {
if (!response.ok) throw new Error('preview failed');
return response.json();
})
.then(function (data) {
outSubject.textContent = data.subject || '';
outBody.textContent = data.body || '';
})
.catch(function () {
// Leave the last good preview in place on error.
});
}
function schedule() {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(refresh, 300);
}
[subjectEl, bodyEl, itemLineEl].forEach(function (el) {
el.addEventListener('input', schedule);
});
})();
+36 -8
View File
@@ -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.
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
/**
* The editable payment-due email: its subject and body are stored as WordPress
* options (falling back to built-in defaults) and rendered by substituting a
* small set of `{token}` placeholders with the values the daily billing scan
* gathered for a student.
*
* The body carries an {items} block one line per charge and optional
* {credit}, {etransfer} and {reference} blocks that the renderer collapses to
* nothing when they do not apply, so a studio admin never has to hand-edit
* conditional prose.
*/
class PaymentDueEmailTemplate {
public const OPT_SUBJECT = 'us_payment_due_email_subject';
public const OPT_BODY = 'us_payment_due_email_body';
public const OPT_ITEM_LINE = 'us_payment_due_email_item_line';
/**
* Tokens the admin may drop into the subject/body, mapped to a short
* translated description shown beside the editor. `{items}` expands to the
* itemised list rendered from the item-line template; the credit/etransfer/
* reference tokens are whole lines that vanish when not applicable.
*
* @return array<string, string>
*/
public static function tokens(): array {
return [
'{student_name}' => __( "The student's display name.", 'unsupervised-schedular' ),
'{items}' => __( 'The itemised list of charges (one line each).', 'unsupervised-schedular' ),
'{total_due}' => __( 'The grand total due, e.g. CAD 75.00.', 'unsupervised-schedular' ),
'{credit}' => __( 'Account-credit line; empty when no credit applies.', 'unsupervised-schedular' ),
'{etransfer}' => __( 'E-transfer destination line; empty when nothing is owed.', 'unsupervised-schedular' ),
'{reference}' => __( 'Payment reference line; empty when no reference is set.', 'unsupervised-schedular' ),
];
}
/**
* Tokens the item-line template understands, one charge at a time.
*
* @return array<string, string>
*/
public static function itemTokens(): array {
return [
'{label}' => __( 'The charge description, e.g. Piano.', 'unsupervised-schedular' ),
'{due_date}' => __( 'The due date, e.g. Jul 15, 2026.', 'unsupervised-schedular' ),
'{currency}' => __( 'The currency code, e.g. CAD.', 'unsupervised-schedular' ),
'{amount}' => __( 'The charge amount, e.g. 35.00.', 'unsupervised-schedular' ),
];
}
public static function defaultSubject(): string {
return __( 'Payment due', 'unsupervised-schedular' );
}
public static function defaultBody(): string {
return __(
"You have upcoming payments due:\n\n{items}{credit}\n\nTotal due: {total_due}{etransfer}{reference}",
'unsupervised-schedular'
);
}
public static function defaultItemLine(): string {
/* translators: this is a template with tokens; keep the {tokens} intact. */
return __( '- {label} (due {due_date}): {currency} {amount}', 'unsupervised-schedular' );
}
public function subject(): string {
$stored = Val::string( get_option( self::OPT_SUBJECT, '' ) );
return '' !== $stored ? $stored : self::defaultSubject();
}
public function body(): string {
$stored = Val::string( get_option( self::OPT_BODY, '' ) );
return '' !== $stored ? $stored : self::defaultBody();
}
public function itemLine(): string {
$stored = Val::string( get_option( self::OPT_ITEM_LINE, '' ) );
return '' !== $stored ? $stored : self::defaultItemLine();
}
public function saveSubject( string $subject ): void {
update_option( self::OPT_SUBJECT, $subject );
}
public function saveBody( string $body ): void {
update_option( self::OPT_BODY, $body );
}
public function saveItemLine( string $itemLine ): void {
update_option( self::OPT_ITEM_LINE, $itemLine );
}
/**
* Render the stored subject template with the given token values.
*
* @param array<string, string> $tokens Token => replacement (keys include the braces).
*/
public function renderSubject( array $tokens ): string {
return strtr( $this->subject(), $tokens );
}
/**
* Render the stored body template with the given token values.
*
* @param array<string, string> $tokens Token => replacement (keys include the braces).
*/
public function renderBody( array $tokens ): string {
return strtr( $this->body(), $tokens );
}
/**
* Render one itemised charge line from the stored item-line template.
*
* @param array<string, string> $tokens Item token => replacement (keys include the braces).
*/
public function renderItemLine( array $tokens ): string {
return strtr( $this->itemLine(), $tokens );
}
}
+51 -21
View File
@@ -8,9 +8,16 @@ namespace Unsupervised\Schedular\Payment;
* scan generated for them in one run, so a student billed for several lessons on
* 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,
];
}
/**
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
/**
* Admin screen for viewing and editing the payment-due email template, with a
* live preview rendered from sample values. Save is a plain POST (nonce +
* capability checked); the preview updates client-side against a REST endpoint
* so an admin sees the effect of an edit before saving it.
*/
class PaymentEmailController {
public const NONCE_ACTION = 'usc_payment_email_action';
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) );
}
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( self::NONCE_ACTION ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified immediately above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
if ( 'reset' === $action ) {
$this->reset();
$notice = __( 'Template reset to the built-in default.', 'unsupervised-schedular' );
} else {
$this->save();
$notice = __( 'Payment due email template saved.', 'unsupervised-schedular' );
}
}
$subject = $this->template->subject();
$body = $this->template->body();
$itemLine = $this->template->itemLine();
$tokens = PaymentDueEmailTemplate::tokens();
$itemTokens = PaymentDueEmailTemplate::itemTokens();
$previewNonce = wp_create_nonce( 'wp_rest' );
$previewUrl = rest_url( 'us-scheduler/v1/payment-email/preview' );
// Server-render the initial preview from sample values so the panel is
// populated before any JavaScript runs (and if it never does).
$preview = self::renderSample( $this->template, $subject, $body, $itemLine );
include USC_PLUGIN_DIR . 'templates/admin/payment-email.php';
}
/**
* Render the given template text against a fixed set of sample values, using
* the real mailer so the preview matches a genuine scan exactly. The passed
* subject/body/item-line override the stored ones so an unsaved edit can be
* previewed.
*
* @return array{subject: string, body: string}
*/
public static function renderSample( PaymentDueEmailTemplate $stored, string $subject, string $body, string $itemLine ): array {
// A throwaway template returning the supplied (possibly unsaved) text.
$draft = new class( $subject, $body, $itemLine ) extends PaymentDueEmailTemplate {
public function __construct(
private string $draftSubject,
private string $draftBody,
private string $draftItemLine,
) {}
public function subject(): string {
return '' !== $this->draftSubject ? $this->draftSubject : self::defaultSubject();
}
public function body(): string {
return '' !== $this->draftBody ? $this->draftBody : self::defaultBody();
}
public function itemLine(): string {
return '' !== $this->draftItemLine ? $this->draftItemLine : self::defaultItemLine();
}
};
$mailer = new PaymentDueMailer( $draft );
$tokens = $mailer->buildTokens( self::sampleStudentName(), self::sampleItems(), self::sampleReference(), self::sampleCredit() );
return [
'subject' => $draft->renderSubject( $tokens ),
'body' => $draft->renderBody( $tokens ),
];
}
public static function sampleStudentName(): string {
return __( 'Alex Student', 'unsupervised-schedular' );
}
public static function sampleReference(): string {
return 'REF12345';
}
public static function sampleCredit(): float {
return 20.0;
}
/**
* The sample charges the preview is rendered against two lessons on
* different dates so the {items} block and grand total are both exercised.
*
* @return list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>
*/
public static function sampleItems(): array {
return [
[
'label' => __( 'Piano lesson', 'unsupervised-schedular' ),
'amount' => 35.0,
'currency' => 'CAD',
'due_date' => '2026-07-15',
'etransfer_email' => '[email protected]',
],
[
'label' => __( 'Guitar lesson', 'unsupervised-schedular' ),
'amount' => 40.0,
'currency' => 'CAD',
'due_date' => '2026-07-22',
'etransfer_email' => '[email protected]',
],
];
}
private function save(): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$this->template->saveSubject( sanitize_text_field( Val::string( wp_unslash( $_POST['subject'] ?? '' ) ) ) );
$this->template->saveBody( sanitize_textarea_field( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) ) );
$this->template->saveItemLine( sanitize_text_field( Val::string( wp_unslash( $_POST['item_line'] ?? '' ) ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
private function reset(): void {
$this->template->saveSubject( '' );
$this->template->saveBody( '' );
$this->template->saveItemLine( '' );
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
/**
* Renders a live preview of the payment-due email from template text the admin
* is editing (not yet saved) against fixed sample values, so the settings screen
* can show the resulting email as the admin types. Read-only: it never writes
* the template.
*/
class PaymentEmailPreviewEndpoint {
public function __construct( private PaymentDueEmailTemplate $template = new PaymentDueEmailTemplate() ) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
'/payment-email/preview',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'preview' ],
'permission_callback' => [ $this, 'canManage' ],
'args' => [
'subject' => [ 'type' => 'string' ],
'body' => [ 'type' => 'string' ],
'item_line' => [ 'type' => 'string' ],
],
],
]
);
}
/**
* Render the submitted (draft) template text against the sample values.
*/
public function preview( \WP_REST_Request $request ): \WP_REST_Response {
$subject = Val::string( $request->get_param( 'subject' ) );
$body = Val::string( $request->get_param( 'body' ) );
$itemLine = Val::string( $request->get_param( 'item_line' ) );
$rendered = PaymentEmailController::renderSample( $this->template, $subject, $body, $itemLine );
return new \WP_REST_Response( $rendered, 200 );
}
public function canManage(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_BILLING );
}
}
+11 -7
View File
@@ -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 );
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var string $subject
* @var string $body
* @var string $itemLine
* @var array<string, string> $tokens
* @var array<string, string> $itemTokens
* @var string $previewNonce
* @var string $previewUrl
* @var array{subject: string, body: string} $preview
* @var string $notice
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Payment Due Email', 'unsupervised-schedular'); ?></h1>
<?php if ('' !== $notice) : ?>
<div class="notice notice-success inline">
<p><?php echo esc_html($notice); ?></p>
</div>
<?php endif; ?>
<p class="description">
<?php esc_html_e('This is the email a student receives when the daily billing scan finds payments due for them. Edit the subject and body below, then preview the result with sample values. Leave a field blank to use the built-in default.', 'unsupervised-schedular'); ?>
</p>
<form method="post" id="usc-payment-email-form">
<?php wp_nonce_field('usc_payment_email_action'); ?>
<input type="hidden" name="usc_action" value="save">
<table class="form-table">
<tr>
<th scope="row"><label for="usc-pe-subject"><?php esc_html_e('Subject', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="text" name="subject" id="usc-pe-subject" class="large-text" value="<?php echo esc_attr($subject); ?>">
</td>
</tr>
<tr>
<th scope="row"><label for="usc-pe-body"><?php esc_html_e('Body', 'unsupervised-schedular'); ?></label></th>
<td>
<textarea name="body" id="usc-pe-body" class="large-text code" rows="10"><?php echo esc_textarea($body); ?></textarea>
<p class="description"><?php esc_html_e('Available tokens:', 'unsupervised-schedular'); ?></p>
<ul>
<?php foreach ($tokens as $token => $description) : ?>
<li><code><?php echo esc_html($token); ?></code> — <?php echo esc_html($description); ?></li>
<?php endforeach; ?>
</ul>
</td>
</tr>
<tr>
<th scope="row"><label for="usc-pe-item-line"><?php esc_html_e('Item line', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="text" name="item_line" id="usc-pe-item-line" class="large-text code" value="<?php echo esc_attr($itemLine); ?>">
<p class="description"><?php esc_html_e('The template for each charge in the {items} block. Available tokens:', 'unsupervised-schedular'); ?></p>
<ul>
<?php foreach ($itemTokens as $token => $description) : ?>
<li><code><?php echo esc_html($token); ?></code> — <?php echo esc_html($description); ?></li>
<?php endforeach; ?>
</ul>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Save Template', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Reset to default', 'unsupervised-schedular'); ?></h2>
<p class="description"><?php esc_html_e('Discards your custom subject, body and item line, restoring the built-in default template.', 'unsupervised-schedular'); ?></p>
<form method="post" onsubmit="return confirm('<?php echo esc_js(esc_html__('Reset the payment due email to its default template?', 'unsupervised-schedular')); ?>');">
<?php wp_nonce_field('usc_payment_email_action'); ?>
<input type="hidden" name="usc_action" value="reset">
<?php submit_button(esc_html__('Reset to default', 'unsupervised-schedular'), 'delete', 'submit', true); ?>
</form>
<h2><?php esc_html_e('Preview', 'unsupervised-schedular'); ?></h2>
<p class="description"><?php esc_html_e('Rendered with sample values. Updates as you edit above.', 'unsupervised-schedular'); ?></p>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Subject', 'unsupervised-schedular'); ?></th>
<td><strong id="usc-pe-preview-subject"><?php echo esc_html($preview['subject']); ?></strong></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Body', 'unsupervised-schedular'); ?></th>
<td><pre id="usc-pe-preview-body" style="white-space:pre-wrap;background:#fff;border:1px solid #ccd0d4;padding:12px;margin:0;max-width:640px;"><?php echo esc_html($preview['body']); ?></pre></td>
</tr>
</table>
</div>
<script>
window.uscPaymentEmailPreview = {
url: <?php echo wp_json_encode($previewUrl); ?>,
nonce: <?php echo wp_json_encode($previewNonce); ?>
};
</script>
@@ -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);
}
}
}
+50 -3
View File
@@ -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());
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.5.8
* Version: 1.6.0
* 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.0');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));