Compare commits
2
Commits
ad4e4ae357
...
694366a6c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
694366a6c8
|
||
|
|
a2c609803e
|
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
+32
-4
@@ -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,10 +114,7 @@ class AdminMenu {
|
||||
* @param string $hookSuffix Screen the enqueue is running for.
|
||||
*/
|
||||
public function enqueueAssets( string $hookSuffix ): void {
|
||||
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( '' !== $this->availabilityHook && $hookSuffix === $this->availabilityHook ) {
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-availability-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
|
||||
@@ -116,6 +122,18 @@ class AdminMenu {
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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,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 );
|
||||
}
|
||||
}
|
||||
@@ -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,6 +41,7 @@ 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 ) );
|
||||
@@ -49,6 +51,7 @@ class RestRegistrar {
|
||||
$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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
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.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__));
|
||||
|
||||
Reference in New Issue
Block a user