Add editable, previewable payment-due email template
CI / Coding Standards (pull_request) Successful in 29s
CI / Tests (PHP 8.2) (pull_request) Successful in 31s
CI / Tests (PHP 8.3) (pull_request) Successful in 31s
CI / Tests (PHP 8.5) (pull_request) Successful in 31s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Static Analysis (pull_request) Successful in 51s
CI / Build Plugin Zip (pull_request) Skipped
CI / Coding Standards (pull_request) Successful in 29s
CI / Tests (PHP 8.2) (pull_request) Successful in 31s
CI / Tests (PHP 8.3) (pull_request) Successful in 31s
CI / Tests (PHP 8.5) (pull_request) Successful in 31s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Static Analysis (pull_request) Successful in 51s
CI / Build Plugin Zip (pull_request) Skipped
The "Payment due" notice the daily billing scan sends was fixed wording
baked into PaymentDueMailer. Studio admins can now view, edit and preview
its subject and body under Studio Settings -> Payment Due Email.
- PaymentDueEmailTemplate stores subject/body/item-line as us_payment_due_email_*
options, each falling back to the built-in default when blank, and renders
via {token} substitution.
- PaymentDueMailer now builds a token map from the same consolidated
items/credit/e-transfer/reference data and renders the stored template; the
default template reproduces the previous wording exactly.
- PaymentEmailController serves the view/edit/save/reset admin page
(manage_billing, nonce-checked) with a server-rendered sample preview.
- PaymentEmailPreviewEndpoint (POST us-scheduler/v1/payment-email/preview,
manage_billing) + payment-email-admin.js drive a live preview of unsaved edits.
- Submenu wired under Studio Settings; endpoint wired in RestRegistrar.
- Bump to 1.6.0 and add CHANGELOG entry. New options default gracefully, so
existing sites need no migration and send the identical notice until edited.
Co-authored-by: anthropic/claude-opus-4-8
This commit is contained in:
co-authored by
anthropic/claude-opus-4-8
parent
ad4e4ae357
commit
a2c609803e
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user