diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ebce3c..98557a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/js/payment-email-admin.js b/assets/js/payment-email-admin.js new file mode 100644 index 0000000..de66f54 --- /dev/null +++ b/assets/js/payment-email-admin.js @@ -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); + }); +})(); diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 0302d11..c8c7f43 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -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. diff --git a/src/Payment/PaymentDueEmailTemplate.php b/src/Payment/PaymentDueEmailTemplate.php new file mode 100644 index 0000000..9594bee --- /dev/null +++ b/src/Payment/PaymentDueEmailTemplate.php @@ -0,0 +1,130 @@ + + */ + 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 + */ + 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 $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 $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 $tokens Item token => replacement (keys include the braces). + */ + public function renderItemLine( array $tokens ): string { + return strtr( $this->itemLine(), $tokens ); + } +} diff --git a/src/Payment/PaymentDueMailer.php b/src/Payment/PaymentDueMailer.php index c5d34cb..6d83fc0 100644 --- a/src/Payment/PaymentDueMailer.php +++ b/src/Payment/PaymentDueMailer.php @@ -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 $items + * @return array + */ + 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, + ]; } /** diff --git a/src/Payment/PaymentEmailController.php b/src/Payment/PaymentEmailController.php new file mode 100644 index 0000000..802dea4 --- /dev/null +++ b/src/Payment/PaymentEmailController.php @@ -0,0 +1,144 @@ +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 + */ + public static function sampleItems(): array { + return [ + [ + 'label' => __( 'Piano lesson', 'unsupervised-schedular' ), + 'amount' => 35.0, + 'currency' => 'CAD', + 'due_date' => '2026-07-15', + 'etransfer_email' => 'pay@studio.test', + ], + [ + 'label' => __( 'Guitar lesson', 'unsupervised-schedular' ), + 'amount' => 40.0, + 'currency' => 'CAD', + 'due_date' => '2026-07-22', + 'etransfer_email' => 'pay@studio.test', + ], + ]; + } + + 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( '' ); + } +} diff --git a/src/Payment/PaymentEmailPreviewEndpoint.php b/src/Payment/PaymentEmailPreviewEndpoint.php new file mode 100644 index 0000000..f57c0ea --- /dev/null +++ b/src/Payment/PaymentEmailPreviewEndpoint.php @@ -0,0 +1,59 @@ + \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 ); + } +} diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 47644c4..adb5f74 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -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 ); } } diff --git a/templates/admin/payment-email.php b/templates/admin/payment-email.php new file mode 100644 index 0000000..8721cb4 --- /dev/null +++ b/templates/admin/payment-email.php @@ -0,0 +1,97 @@ + $tokens + * @var array $itemTokens + * @var string $previewNonce + * @var string $previewUrl + * @var array{subject: string, body: string} $preview + * @var string $notice + */ +?> +
+

+ + +
+

+
+ + +

+ +

+ +
+ + + + + + + + + + + + + + + +
+ +
+ +

+
    + $description) : ?> +
  • + +
+
+ +

+
    + $description) : ?> +
  • + +
+
+ +
+ +

+

+
+ + + +
+ +

+

+ + + + + + + + + +
+
+ diff --git a/tests/Unit/Payment/PaymentDueEmailTemplateTest.php b/tests/Unit/Payment/PaymentDueEmailTemplateTest.php new file mode 100644 index 0000000..6569f35 --- /dev/null +++ b/tests/Unit/Payment/PaymentDueEmailTemplateTest.php @@ -0,0 +1,77 @@ +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); + } + } +} diff --git a/tests/Unit/Payment/PaymentDueMailerTest.php b/tests/Unit/Payment/PaymentDueMailerTest.php index adbe979..4ac992e 100644 --- a/tests/Unit/Payment/PaymentDueMailerTest.php +++ b/tests/Unit/Payment/PaymentDueMailerTest.php @@ -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('a@b.test'), $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( + 'a@b.test', + '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('a@b.test', 'Jordan'), $items)); + } } diff --git a/tests/Unit/Payment/PaymentEmailControllerTest.php b/tests/Unit/Payment/PaymentEmailControllerTest.php new file mode 100644 index 0000000..97ec873 --- /dev/null +++ b/tests/Unit/Payment/PaymentEmailControllerTest.php @@ -0,0 +1,60 @@ +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('pay@studio.test', $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']); + } +} diff --git a/tests/Unit/Payment/PaymentEmailPreviewEndpointTest.php b/tests/Unit/Payment/PaymentEmailPreviewEndpointTest.php new file mode 100644 index 0000000..0ec4fba --- /dev/null +++ b/tests/Unit/Payment/PaymentEmailPreviewEndpointTest.php @@ -0,0 +1,59 @@ +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()); + } +} diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php index 830cbc9..ce99768 100644 --- a/unsupervised-schedular.php +++ b/unsupervised-schedular.php @@ -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__));