Add HST/tax support and payment reporting with HST aggregation
CI / Tests (PHP 8.1) (pull_request) Successful in 51s
CI / Coding Standards (pull_request) Successful in 1m1s
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / No Debug Code (pull_request) Successful in 4s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 45s
CI / Build Plugin Zip (pull_request) Has been skipped

Studio Settings gains a default HST rate; the rate is frozen onto each
payment at booking and computed against the pre-tax subtotal, with the
total billed as subtotal + tax. The rate is overridable per booking on
My Lessons while unpaid (recomputing the tax amount), comped
registrations are never taxed, and receipts break out subtotal/HST/total.

Builds the payments report (roadmap #8) from us_payments: a monthly
per-instructor view with subtotal, HST collected, and grand-total
aggregation, plus a nonce-protected CSV export via admin-post. Studio
admins see all instructors and can filter; instructors are scoped to
their own rows. The Payment Report menu is gated on export_payments so
instructors (who lack manage_billing) can reach it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:29:48 -03:00
parent b73d81421f
commit 553cfafa49
21 changed files with 756 additions and 58 deletions
+2 -2
View File
@@ -35,8 +35,8 @@ model, REST API, classes, and tests. For contributor/architecture guidance see
| Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented |
| Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented |
| Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | ✅ Implemented |
| Payments (e-transfer/comp + receipts; Stripe card charge pending) | [payments.md](docs/features/payments.md) | 🟡 Partial |
| Payment reporting (monthly per-instructor + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | 🟡 Planned |
| Payments (e-transfer/comp + receipts + HST; Stripe card charge pending) | [payments.md](docs/features/payments.md) | 🟡 Partial |
| Payment reporting (monthly per-instructor + HST + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | ✅ Implemented |
> Payments are deliberately deferred to the end: booking and enrolment ship with a
> clean seam (a lesson lands `pending`, an enrolment `active`, with `payment_id`
+44 -25
View File
@@ -1,49 +1,68 @@
# Feature: Payment Reporting
## Overview
A monthly view of payments per instructor, with a downloadable spreadsheet (CSV) export. The studio admin sees every instructor and can filter; each instructor sees only their own payments. The report is built entirely from `us_payments` — no additional table.
A monthly view of paid payments with **HST aggregation** and a downloadable
spreadsheet (CSV) export. The studio admin sees every instructor and can filter;
each instructor sees only their own payments. The report is built entirely from
`us_payments` — no additional table.
## Data Source
Reads `{prefix}us_payments` (see `payments.md`), grouped by calendar month and
instructor. Each report row joins through to the student and offering for display:
Reads `{prefix}us_payments` (see `payments.md`), filtered to `paid` rows whose
`paid_at` falls within the selected calendar month, optionally narrowed to one
instructor. Each report row resolves the student and instructor for display:
| Report Column | Source |
|---------------|---------------------------------------------------|
| Date | `us_payments.paid_at` (falls back to `created_at`) |
| Date | `us_payments.paid_at` (date portion) |
| Student | `us_payments.student_id` → display name |
| Offering | registration → `us_offerings.title` |
| Instructor | `us_payments.instructor_id` → display name |
| Method | `us_payments.method` |
| Status | `us_payments.status` |
| Amount | `us_payments.amount_cents` / `currency` |
| Subtotal | `us_payments.amount` / `currency` |
| HST | `us_payments.tax_amount` (with `tax_rate` %) |
| Total | `amount + tax_amount` (`Payment::total()`) |
Totals are summed over `paid` rows for the selected month.
Three totals are summed over the selected rows: **subtotal**, **HST collected**
(the figure the studio remits), and **grand total**.
## Access Rules
- Studio admin (`view_all_payments`): all instructors; may filter by `instructor_id`.
- Instructor (`view_own_payments`): rows where `us_payments.instructor_id` is their own user ID only.
- Instructor (`view_own_payments`): always scoped to their own user ID; the
instructor filter is hidden and any requested `instructor_id` is overridden.
- Export requires `export_payments` and is scoped to the same rows the caller may view.
## Admin Interface
**Payments** in wp-admin:
- Month picker (defaults to the current month) and, for studio admin, an instructor filter
- A table of payments with a monthly total
- An **Export** button that downloads the filtered rows as CSV
## REST API
| Method | Endpoint | Permission |
|--------|------------------------------------------------|----------------------|
| `GET` | `/wp-json/us-scheduler/v1/payments` | `view_own_payments` or `view_all_payments` |
| `GET` | `/wp-json/us-scheduler/v1/payments/export` | `export_payments` |
**Payment Report** in wp-admin (top-level menu, gated on `export_payments` so
instructors — who lack `manage_billing` — can reach it):
Query params: `month` (`YYYY-MM`, required), `instructor_id` (optional; ignored for
instructors, who are always scoped to themselves). `GET /payments/export` returns
`text/csv` with a `Content-Disposition` attachment header.
- Month picker (defaults to the current month) and, for studio admin, an
instructor filter
- A summary line of HST collected / subtotal / total collected
- A table of paid payments with a totals footer
- An **Export CSV** button that downloads the filtered rows (plus a totals row)
## Export
CSV export is served by an `admin-post.php` handler
(`admin_post_usc_export_payments`) rather than a REST route, so the browser
downloads it directly with a nonce-protected link. It honours the same `month`
and `instructor_id` query params as the page and returns `text/csv` with a
`Content-Disposition: attachment` header. Instructor requests are scoped to
their own rows regardless of `instructor_id`.
## Implementation
- Report controller: `Unsupervised\Schedular\Payment\PaymentReportController`
- CSV exporter: `Unsupervised\Schedular\Payment\PaymentExporter`
- Report query: `Unsupervised\Schedular\Payment\PaymentRepository::report()`
- Report aggregator (pure totals + CSV): `Unsupervised\Schedular\Payment\PaymentReport`
- Report controller (page + export): `Unsupervised\Schedular\Payment\PaymentReportController`
- Report query: `Unsupervised\Schedular\Payment\PaymentRepository::findPaidBetween()`
- Template: `templates/admin/payment-report.php`
- Menu + `admin_post` wiring: `Unsupervised\Schedular\AdminMenu`
## Tests
- `tests/Unit/Payment/PaymentReportControllerTest.php`
- `tests/Unit/Payment/PaymentExporterTest.php`
- `tests/Unit/Payment/PaymentReportTest.php` — totals and CSV rendering
- `tests/Unit/Payment/PaymentRepositoryTest.php``findPaidBetween` query
+18
View File
@@ -28,6 +28,22 @@ page (`manage_billing`, studio admin only):
| `us_stripe_secret_key` | Stripe secret key |
| `us_stripe_mode` | `test` or `live` |
| `us_currency` | Default ISO 4217 currency, e.g. `CAD` |
| `us_etransfer_email` | Studio-default e-transfer destination |
| `us_hst_rate` | Default HST/tax percentage, e.g. `13` |
## HST / Tax
A studio-default **HST rate** (percentage) is configured on **Studio Settings**
(`us_hst_rate`, `manage_billing`). At booking the rate is **frozen onto the
payment** (`us_payments.tax_rate`) and the tax is computed against the pre-tax
subtotal (`tax_amount = round(amount × rate / 100, 2)`). The billed total is
`amount + tax_amount` (`Payment::total()`). Comped registrations are never taxed
(rate and amount are 0).
The rate is overridable **per booking** on **My Lessons** (instructor) while the
payment is still unpaid; saving recomputes `tax_amount` from the current
subtotal (`PaymentRepository::updateTax`). Receipts break out subtotal, HST, and
total when tax applies.
## Per-Student Billing Method
Each student's billing method is stored in user meta `us_payment_method`, set by the
@@ -66,6 +82,8 @@ After booking, the destination on a payment can be corrected per booking:
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
| `method` | VARCHAR(20) | `card` / `etransfer` / `comp` |
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` |
| `tax_rate` | DECIMAL(5,2) | HST rate % frozen at booking; editable until paid |
| `tax_amount` | DECIMAL(10,2) | Computed tax in dollars (`amount × tax_rate / 100`) |
| `etransfer_email` | VARCHAR(191) | Frozen e-transfer destination; editable until confirmed |
| `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp |
| `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` |
+27 -11
View File
@@ -17,6 +17,7 @@ use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\PaymentController;
use Unsupervised\Schedular\Payment\PaymentReportController;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
@@ -39,22 +40,25 @@ class AdminMenu {
private StudentController $studentController;
private StudioSettings $settings;
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments );
$this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
$this->settings = $settings;
$this->paymentController = new PaymentController( $payments, $paymentService );
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments );
$this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
$this->settings = $settings;
$this->paymentController = new PaymentController( $payments, $paymentService );
$this->paymentReportController = new PaymentReportController( $payments );
}
public function register(): void {
add_action( 'admin_menu', [ $this, 'addPages' ] );
add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] );
}
public function addPages(): void {
@@ -156,6 +160,18 @@ class AdminMenu {
38
);
// Studio admin (all) / instructor (own): monthly payments report with HST.
// Gated on export so instructors — who lack manage_billing — can still see it.
add_menu_page(
__( 'Payment Report', 'unsupervised-schedular' ),
__( 'Payment Report', 'unsupervised-schedular' ),
RoleManager::CAP_EXPORT_PAYMENTS,
'us-reports',
[ $this->paymentReportController, 'renderPage' ],
'dashicons-chart-bar',
39
);
// Studio admin: Stripe credentials and billing settings.
add_menu_page(
__( 'Studio Settings', 'unsupervised-schedular' ),
@@ -164,7 +180,7 @@ class AdminMenu {
'us-settings',
[ $this->settings, 'renderPage' ],
'dashicons-admin-settings',
39
40
);
// Instructor: view their upcoming lessons.
+22 -10
View File
@@ -39,8 +39,8 @@ class LessonController {
}
/**
* Handle a per-lesson e-transfer email override. When $onlyOwn, the payment
* must belong to the current instructor.
* Handle a per-lesson payment override (e-transfer email or HST rate). When
* $onlyOwn, the payment must belong to the current instructor.
*/
private function handleEtransferUpdate( bool $onlyOwn ): void {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
@@ -48,26 +48,32 @@ class LessonController {
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'set_etransfer' !== sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) {
return;
}
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
$taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, (float) $_POST['tax_rate'] ) : 0.0;
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId <= 0 ) {
if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) {
return;
}
$payment = $this->payments->findById( $paymentId );
if ( null !== $payment && ( ! $onlyOwn || get_current_user_id() === $payment->instructorId ) ) {
$this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null );
if ( null === $payment || ( $onlyOwn && get_current_user_id() !== $payment->instructorId ) ) {
return;
}
if ( 'set_tax' === $action ) {
$this->payments->updateTax( $paymentId, $taxRate );
return;
}
$this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null );
}
/**
* Build a display row for a lesson, including its e-transfer payment override.
* Build a display row for a lesson, including its e-transfer and HST payment
* overrides.
*
* @return array<string, mixed>
*/
@@ -83,8 +89,14 @@ class LessonController {
'status' => $lesson->status,
'notes' => $lesson->notes ?? '',
'payment_id' => $payment ? (int) $payment->id : 0,
'currency' => $payment ? (string) $payment->currency : '',
'amount' => $payment ? (float) $payment->amount : 0.0,
'tax_rate' => $payment ? (float) $payment->taxRate : 0.0,
'tax_amount' => $payment ? (float) $payment->taxAmount : 0.0,
'total' => $payment ? $payment->total() : 0.0,
'etransfer_email' => $payment ? (string) $payment->etransferEmail : '',
'etransfer_editable' => null !== $payment && Payment::METHOD_ETRANSFER === $payment->method && ! $payment->isPaid(),
'tax_editable' => null !== $payment && ! $payment->isPaid(),
];
}
}
+14
View File
@@ -40,6 +40,8 @@ class Payment {
public readonly string $currency = 'CAD',
public readonly string $method = self::METHOD_ETRANSFER,
public readonly string $status = self::STATUS_PENDING,
public readonly float $taxRate = 0.0,
public readonly float $taxAmount = 0.0,
public readonly ?string $etransferEmail = null,
public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null,
@@ -58,6 +60,8 @@ class Payment {
currency: $row->currency,
method: $row->method,
status: $row->status,
taxRate: (float) $row->tax_rate,
taxAmount: (float) $row->tax_amount,
etransferEmail: $row->etransfer_email,
stripePaymentIntentId: $row->stripe_payment_intent_id,
receiptNumber: $row->receipt_number,
@@ -71,6 +75,13 @@ class Payment {
return self::STATUS_PAID === $this->status;
}
/**
* Amount billed including tax.
*/
public function total(): float {
return round( $this->amount + $this->taxAmount, 2 );
}
/**
* Returns a plain array representation of the payment.
*
@@ -85,6 +96,9 @@ class Payment {
'etransfer_email' => $this->etransferEmail,
'registration_id' => $this->registrationId,
'amount' => $this->amount,
'tax_rate' => $this->taxRate,
'tax_amount' => $this->taxAmount,
'total' => $this->total(),
'currency' => $this->currency,
'method' => $this->method,
'status' => $this->status,
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
/**
* Pure aggregator over a set of paid-payment display rows: produces the column
* totals (subtotal, HST collected, grand total) and a CSV rendering. Building
* the rows (name lookups, filtering) is the controller's job — this class does
* no I/O so it stays trivially testable.
*/
class PaymentReport {
/**
* Build a report over already-resolved display rows.
*
* @param list<array{date: string, student: string, instructor: string, method: string, status: string, amount: float, tax_rate: float, tax_amount: float, total: float}> $rows
*/
public function __construct( private array $rows ) {}
/**
* The report's display rows.
*
* @return list<array{date: string, student: string, instructor: string, method: string, status: string, amount: float, tax_rate: float, tax_amount: float, total: float}>
*/
public function rows(): array {
return $this->rows;
}
public function count(): int {
return count( $this->rows );
}
public function totalAmount(): float {
return round( array_sum( array_column( $this->rows, 'amount' ) ), 2 );
}
/**
* Total HST collected across all rows — the figure the studio remits.
*/
public function totalTax(): float {
return round( array_sum( array_column( $this->rows, 'tax_amount' ) ), 2 );
}
public function grandTotal(): float {
return round( array_sum( array_column( $this->rows, 'total' ) ), 2 );
}
/**
* Render the report as CSV, including a trailing totals row.
*/
public function toCsv(): string {
$lines = [];
$lines[] = $this->csvLine(
[ 'Date', 'Student', 'Instructor', 'Method', 'Status', 'Subtotal', 'HST Rate', 'HST', 'Total' ]
);
foreach ( $this->rows as $row ) {
$lines[] = $this->csvLine(
[
$row['date'],
$row['student'],
$row['instructor'],
$row['method'],
$row['status'],
number_format( $row['amount'], 2, '.', '' ),
number_format( $row['tax_rate'], 2, '.', '' ),
number_format( $row['tax_amount'], 2, '.', '' ),
number_format( $row['total'], 2, '.', '' ),
]
);
}
$lines[] = $this->csvLine(
[
'Totals',
'',
'',
'',
'',
number_format( $this->totalAmount(), 2, '.', '' ),
'',
number_format( $this->totalTax(), 2, '.', '' ),
number_format( $this->grandTotal(), 2, '.', '' ),
]
);
return implode( "\n", $lines ) . "\n";
}
/**
* Format one CSV record, quoting fields and escaping embedded quotes.
*
* @param list<string> $fields
*/
private function csvLine( array $fields ): string {
$escaped = array_map(
static fn( string $field ): string => '"' . str_replace( '"', '""', $field ) . '"',
$fields
);
return implode( ',', $escaped );
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
class PaymentReportController {
public const EXPORT_ACTION = 'usc_export_payments';
public function __construct( private PaymentRepository $payments ) {}
/**
* Render the monthly payments report with HST aggregation. Studio admins see
* all instructors; instructors are scoped to their own payments.
*/
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_PAYMENTS ) && ! current_user_can( RoleManager::CAP_VIEW_OWN_PAYMENTS ) ) {
wp_die( esc_html__( 'You do not have permission to view payment reports.', 'unsupervised-schedular' ) );
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only report filters, no state change.
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$instructorId = $this->scopeInstructor( $instructorId );
$report = $this->buildReport( $month, $instructorId );
$canExport = current_user_can( RoleManager::CAP_EXPORT_PAYMENTS );
$canFilter = current_user_can( RoleManager::CAP_VIEW_ALL_PAYMENTS );
$exportUrl = wp_nonce_url(
admin_url( 'admin-post.php?action=' . self::EXPORT_ACTION . '&month=' . rawurlencode( $month ) . '&instructor_id=' . $instructorId ),
self::EXPORT_ACTION
);
$instructors = $canFilter
? get_users(
[
'role' => RoleManager::INSTRUCTOR,
'fields' => [ 'ID', 'display_name' ],
]
)
: [];
include USC_PLUGIN_DIR . 'templates/admin/payment-report.php';
}
/**
* Stream the report as a CSV download (admin_post handler).
*/
public function export(): void {
if ( ! current_user_can( RoleManager::CAP_EXPORT_PAYMENTS ) ) {
wp_die( esc_html__( 'You do not have permission to export payments.', 'unsupervised-schedular' ) );
}
check_admin_referer( self::EXPORT_ACTION );
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above.
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$instructorId = $this->scopeInstructor( $instructorId );
$report = $this->buildReport( $month, $instructorId );
$filename = 'payments-' . $month . '.csv';
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
echo $report->toCsv(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV body, not HTML.
exit;
}
/**
* Restrict the requested instructor to the current user when they may only
* see their own payments.
*/
private function scopeInstructor( int $instructorId ): int {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_PAYMENTS ) ) {
return get_current_user_id();
}
return $instructorId;
}
/**
* Build a report of paid payments for the given `Y-m` month, optionally for
* one instructor.
*/
private function buildReport( string $month, int $instructorId ): PaymentReport {
$start = $month . '-01 00:00:00';
$end = gmdate( 'Y-m-d H:i:s', strtotime( $month . '-01 00:00:00 +1 month' ) );
$rows = array_map(
static function ( Payment $payment ): array {
$student = get_userdata( $payment->studentId );
$instructor = get_userdata( $payment->instructorId );
return [
'date' => substr( (string) $payment->paidAt, 0, 10 ),
'student' => $student ? $student->display_name : (string) $payment->studentId,
'instructor' => $instructor ? $instructor->display_name : (string) $payment->instructorId,
'method' => $payment->method,
'status' => $payment->status,
'amount' => (float) $payment->amount,
'tax_rate' => (float) $payment->taxRate,
'tax_amount' => (float) $payment->taxAmount,
'total' => $payment->total(),
];
},
$this->payments->findPaidBetween( $start, $end, $instructorId )
);
return new PaymentReport( $rows );
}
/**
* Validate a `Y-m` month string, defaulting to the current month.
*/
private function sanitizeMonth( string $month ): string {
if ( 1 === preg_match( '/^\d{4}-\d{2}$/', $month ) ) {
return $month;
}
return gmdate( 'Y-m' );
}
}
+39 -1
View File
@@ -23,6 +23,8 @@ class PaymentRepository {
'currency' => $payment->currency,
'method' => $payment->method,
'status' => $payment->status,
'tax_rate' => $payment->taxRate,
'tax_amount' => $payment->taxAmount,
'etransfer_email' => $payment->etransferEmail,
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
'receipt_number' => $payment->receiptNumber,
@@ -30,7 +32,7 @@ class PaymentRepository {
'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
@@ -46,6 +48,42 @@ class PaymentRepository {
);
}
/**
* Set a payment's tax rate and recompute the tax amount from its subtotal.
*/
public function updateTax( int $id, float $rate ): bool {
return false !== $this->db->query(
$this->db->prepare(
"UPDATE {$this->table} SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d",
$rate,
$rate,
$id
)
);
}
/**
* Paid payments in a month (`Y-m` bounds), optionally for one instructor —
* the reporting source.
*
* @return list<Payment>
*/
public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array {
$sql = "SELECT * FROM {$this->table} WHERE status = %s AND paid_at >= %s AND paid_at < %s";
$params = [ Payment::STATUS_PAID, $from, $to ];
if ( $instructorId > 0 ) {
$sql .= ' AND instructor_id = %d';
$params[] = $instructorId;
}
$sql .= ' ORDER BY paid_at ASC';
$rows = $this->db->get_results( $this->db->prepare( $sql, $params ) );
return array_map( Payment::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
+6
View File
@@ -41,6 +41,10 @@ class PaymentService {
? $offeringEtransferEmail
: ( '' !== $this->settings->etransferEmail() ? $this->settings->etransferEmail() : null );
// HST is frozen from the studio default at booking; comped students are not taxed.
$taxRate = Payment::METHOD_COMP === $method ? 0.0 : $this->settings->hstRate();
$taxAmount = round( $amount * $taxRate / 100, 2 );
$id = $this->payments->insert(
new Payment(
studentId: $studentId,
@@ -51,6 +55,8 @@ class PaymentService {
currency: $currency,
method: $method,
status: $status,
taxRate: $taxRate,
taxAmount: $taxAmount,
etransferEmail: $etransferEmail,
)
);
+20 -7
View File
@@ -20,13 +20,26 @@ class ReceiptMailer {
(string) $payment->receiptNumber
);
$body = sprintf(
/* translators: 1: amount, 2: currency, 3: receipt number */
__( "Thank you. We have recorded your payment of %1\$s %2\$s.\n\nReceipt: %3\$s", 'unsupervised-schedular' ),
number_format( $payment->amount, 2 ),
$payment->currency,
(string) $payment->receiptNumber
);
if ( $payment->taxAmount > 0 ) {
$body = sprintf(
/* translators: 1: currency, 2: subtotal, 3: HST rate, 4: HST amount, 5: total, 6: receipt number */
__( "Thank you. We have recorded your payment.\n\nSubtotal: %1\$s %2\$s\nHST (%3\$s%%): %1\$s %4\$s\nTotal: %1\$s %5\$s\n\nReceipt: %6\$s", 'unsupervised-schedular' ),
$payment->currency,
number_format( $payment->amount, 2 ),
number_format( $payment->taxRate, 2 ),
number_format( $payment->taxAmount, 2 ),
number_format( $payment->total(), 2 ),
(string) $payment->receiptNumber
);
} else {
$body = sprintf(
/* translators: 1: amount, 2: currency, 3: receipt number */
__( "Thank you. We have recorded your payment of %1\$s %2\$s.\n\nReceipt: %3\$s", 'unsupervised-schedular' ),
number_format( $payment->total(), 2 ),
$payment->currency,
(string) $payment->receiptNumber
);
}
return (bool) wp_mail( $student->user_email, $subject, $body );
}
+11
View File
@@ -12,6 +12,7 @@ class StudioSettings {
public const OPT_MODE = 'us_stripe_mode';
public const OPT_CURRENCY = 'us_currency';
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate';
public function publishableKey(): string {
return (string) get_option( self::OPT_PUBLISHABLE, '' );
@@ -39,6 +40,13 @@ class StudioSettings {
return (string) get_option( self::OPT_ETRANSFER_EMAIL, '' );
}
/**
* Default HST/tax rate as a percentage (e.g. 13.0). 0 means no tax.
*/
public function hstRate(): float {
return max( 0.0, (float) get_option( self::OPT_HST_RATE, 0 ) );
}
/**
* Whether Stripe is configured. When false the platform falls back to
* e-transfer billing and card processing is unavailable.
@@ -61,6 +69,7 @@ class StudioSettings {
$mode = $this->mode();
$currency = $this->currency();
$etransferEmail = $this->etransferEmail();
$hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured();
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
@@ -75,6 +84,8 @@ class StudioSettings {
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
$hstRate = isset( $_POST['hst_rate'] ) ? (float) $_POST['hst_rate'] : 0.0;
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
}
+2
View File
@@ -151,6 +151,8 @@ class Schema {
currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
method VARCHAR(20) NOT NULL DEFAULT 'etransfer',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
etransfer_email VARCHAR(191) DEFAULT NULL,
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
receipt_number VARCHAR(50) DEFAULT NULL,
+18 -1
View File
@@ -5,7 +5,7 @@ if (! defined('ABSPATH')) {
exit;
}
/** @var list<array{student: string, instructor: string, slot_id: int, status: string, notes: string, payment_id: int, etransfer_email: string, etransfer_editable: bool}> $rows */
/** @var list<array{student: string, instructor: string, slot_id: int, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows */
?>
<div class="wrap">
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
@@ -20,6 +20,8 @@ if (! defined('ABSPATH')) {
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Slot ID', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
</tr>
@@ -31,6 +33,21 @@ if (! defined('ABSPATH')) {
<td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo esc_html((string) $row['slot_id']); ?></td>
<td><?php echo esc_html($row['status']); ?></td>
<td>
<?php if ($row['tax_editable']) : ?>
<form method="post" style="display:flex; gap:4px; align-items:center;">
<?php wp_nonce_field('usc_lesson_action'); ?>
<input type="hidden" name="usc_action" value="set_tax">
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['payment_id']); ?>">
<input type="number" name="tax_rate" min="0" max="100" step="0.01" style="width:5em;" value="<?php echo esc_attr(number_format($row['tax_rate'], 2)); ?>">
<span>%</span>
<button type="submit" class="button button-small"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
</form>
<?php else : ?>
<?php echo esc_html(number_format($row['tax_rate'], 2) . '% (' . $row['currency'] . ' ' . number_format($row['tax_amount'], 2) . ')'); ?>
<?php endif; ?>
</td>
<td><?php echo $row['payment_id'] > 0 ? esc_html($row['currency'] . ' ' . number_format($row['total'], 2)) : esc_html('—'); ?></td>
<td>
<?php if ($row['etransfer_editable']) : ?>
<form method="post" style="display:flex; gap:4px;">
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
use Unsupervised\Schedular\Payment\PaymentReport;
/**
* @var PaymentReport $report
* @var string $month
* @var int $instructorId
* @var bool $canExport
* @var bool $canFilter
* @var string $exportUrl
* @var list<\WP_User|\stdClass> $instructors
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Payment Report', 'unsupervised-schedular'); ?></h1>
<form method="get" style="margin: 1em 0; display:flex; gap:8px; align-items:flex-end; flex-wrap:wrap;">
<input type="hidden" name="page" value="us-reports">
<label>
<?php esc_html_e('Month', 'unsupervised-schedular'); ?><br>
<input type="month" name="month" value="<?php echo esc_attr($month); ?>">
</label>
<?php if ($canFilter) : ?>
<label>
<?php esc_html_e('Instructor', 'unsupervised-schedular'); ?><br>
<select name="instructor_id">
<option value="0"><?php esc_html_e('All instructors', 'unsupervised-schedular'); ?></option>
<?php foreach ($instructors as $instructor) : ?>
<option value="<?php echo esc_attr((string) $instructor->ID); ?>" <?php selected($instructorId, (int) $instructor->ID); ?>>
<?php echo esc_html($instructor->display_name); ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<button type="submit" class="button"><?php esc_html_e('Filter', 'unsupervised-schedular'); ?></button>
<?php if ($canExport) : ?>
<a class="button button-secondary" href="<?php echo esc_url($exportUrl); ?>"><?php esc_html_e('Export CSV', 'unsupervised-schedular'); ?></a>
<?php endif; ?>
</form>
<p>
<strong><?php esc_html_e('HST collected:', 'unsupervised-schedular'); ?></strong>
<?php echo esc_html(number_format($report->totalTax(), 2)); ?>
&nbsp;|&nbsp;
<strong><?php esc_html_e('Subtotal:', 'unsupervised-schedular'); ?></strong>
<?php echo esc_html(number_format($report->totalAmount(), 2)); ?>
&nbsp;|&nbsp;
<strong><?php esc_html_e('Total collected:', 'unsupervised-schedular'); ?></strong>
<?php echo esc_html(number_format($report->grandTotal(), 2)); ?>
</p>
<?php if (0 === $report->count()) : ?>
<p><?php esc_html_e('No paid payments in this period.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Date', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Method', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Subtotal', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($report->rows() as $row) : ?>
<tr>
<td><?php echo esc_html($row['date']); ?></td>
<td><?php echo esc_html($row['student']); ?></td>
<td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo esc_html($row['method']); ?></td>
<td><?php echo esc_html($row['status']); ?></td>
<td><?php echo esc_html(number_format($row['amount'], 2)); ?></td>
<td><?php echo esc_html(number_format($row['tax_amount'], 2) . ' (' . number_format($row['tax_rate'], 2) . '%)'); ?></td>
<td><?php echo esc_html(number_format($row['total'], 2)); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<th colspan="5"><?php esc_html_e('Totals', 'unsupervised-schedular'); ?></th>
<th><?php echo esc_html(number_format($report->totalAmount(), 2)); ?></th>
<th><?php echo esc_html(number_format($report->totalTax(), 2)); ?></th>
<th><?php echo esc_html(number_format($report->grandTotal(), 2)); ?></th>
</tr>
</tfoot>
</table>
<?php endif; ?>
</div>
+8
View File
@@ -11,6 +11,7 @@ if (! defined('ABSPATH')) {
* @var string $mode
* @var string $currency
* @var string $etransferEmail
* @var float $hstRate
* @var bool $stripeConfigured
*/
?>
@@ -53,6 +54,13 @@ if (! defined('ABSPATH')) {
<th><label for="currency"><?php esc_html_e('Default currency', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="currency" id="currency" class="small-text" maxlength="3" value="<?php echo esc_attr($currency); ?>"></td>
</tr>
<tr>
<th><label for="hst_rate"><?php esc_html_e('Default HST rate (%)', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="number" name="hst_rate" id="hst_rate" class="small-text" min="0" max="100" step="0.01" value="<?php echo esc_attr(number_format($hstRate, 2)); ?>">
<p class="description"><?php esc_html_e('Added to the subtotal when billing. 0 means no tax. Overridable per booking on My Lessons.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
</table>
<h2><?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?></h2>
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Unsupervised\Schedular\Payment\PaymentReport;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class PaymentReportTest extends TestCase
{
/**
* @return list<array{date: string, student: string, instructor: string, method: string, status: string, amount: float, tax_rate: float, tax_amount: float, total: float}>
*/
private function rows(): array
{
return [
[
'date' => '2026-06-02',
'student' => 'Ada',
'instructor' => 'Pat',
'method' => 'etransfer',
'status' => 'paid',
'amount' => 100.00,
'tax_rate' => 13.0,
'tax_amount' => 13.00,
'total' => 113.00,
],
[
'date' => '2026-06-09',
'student' => 'Grace',
'instructor' => 'Pat',
'method' => 'card',
'status' => 'paid',
'amount' => 50.00,
'tax_rate' => 13.0,
'tax_amount' => 6.50,
'total' => 56.50,
],
];
}
public function testTotalsAggregateAmountsAndTax(): void
{
$report = new PaymentReport($this->rows());
self::assertSame(2, $report->count());
self::assertSame(150.00, $report->totalAmount());
self::assertSame(19.50, $report->totalTax());
self::assertSame(169.50, $report->grandTotal());
}
public function testEmptyReportHasZeroTotals(): void
{
$report = new PaymentReport([]);
self::assertSame(0, $report->count());
self::assertSame(0.0, $report->totalTax());
self::assertSame(0.0, $report->grandTotal());
}
public function testCsvIncludesHeaderRowsAndTotals(): void
{
$csv = (new PaymentReport($this->rows()))->toCsv();
$lines = explode("\n", trim($csv));
// header + 2 data rows + totals row.
self::assertCount(4, $lines);
self::assertStringContainsString('"Date","Student","Instructor"', $lines[0]);
self::assertStringContainsString('"Ada"', $lines[1]);
self::assertStringContainsString('"Totals"', $lines[3]);
self::assertStringContainsString('"19.50"', $lines[3]);
self::assertStringContainsString('"169.50"', $lines[3]);
}
public function testCsvEscapesEmbeddedQuotes(): void
{
$rows = $this->rows();
$rows[0]['student'] = 'Ada "The Great"';
$csv = (new PaymentReport($rows))->toCsv();
self::assertStringContainsString('"Ada ""The Great"""', $csv);
}
}
@@ -62,6 +62,42 @@ class PaymentRepositoryTest extends TestCase
self::assertTrue($this->repo->markPaid(50, 'USC-50'));
}
public function testUpdateTaxRecomputesAmountFromRate(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/tax_amount = ROUND\( amount \* %f \/ 100, 2 \)/'), 13.0, 13.0, 50)
->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
self::assertTrue($this->repo->updateTax(50, 13.0));
}
public function testFindPaidBetweenFiltersByInstructor(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/status = %s AND paid_at >= %s AND paid_at < %s AND instructor_id = %d/'), ['paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00', 3])
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
self::assertCount(1, $this->repo->findPaidBetween('2026-06-01 00:00:00', '2026-07-01 00:00:00', 3));
}
public function testFindPaidBetweenWithoutInstructorOmitsFilter(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::on(static fn (string $sql): bool => ! str_contains($sql, 'instructor_id')), ['paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00'])
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([]);
self::assertCount(0, $this->repo->findPaidBetween('2026-06-01 00:00:00', '2026-07-01 00:00:00'));
}
public function testFindByRegistrationReturnsPayment(): void
{
$this->db->shouldReceive('prepare')
@@ -98,6 +134,8 @@ class PaymentRepositoryTest extends TestCase
'currency' => 'CAD',
'method' => Payment::METHOD_ETRANSFER,
'status' => Payment::STATUS_PENDING,
'tax_rate' => '0.00',
'tax_amount' => '0.00',
'etransfer_email' => null,
'stripe_payment_intent_id' => null,
'receipt_number' => null,
+32
View File
@@ -37,6 +37,7 @@ class PaymentServiceTest extends TestCase
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->settings = Mockery::mock(StudioSettings::class);
$this->settings->shouldReceive('etransferEmail')->andReturn('');
$this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault();
$this->service = new PaymentService(
$this->payments,
@@ -91,6 +92,37 @@ class PaymentServiceTest extends TestCase
);
}
public function testHstIsComputedAndFrozenOntoPayment(): void
{
$this->settings->shouldReceive('hstRate')->andReturn(13.0);
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER);
$this->payments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->taxRate === 13.0 && $p->taxAmount === 13.00 && $p->total() === 113.00))
->andReturn(50);
$this->bookings->shouldReceive('setPaymentId')->once()->with(12, 50);
$this->payments->shouldReceive('findById')->with(50)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 50));
self::assertNotNull($this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 100.00, 'CAD'));
}
public function testCompIsNotTaxed(): void
{
$this->settings->shouldReceive('hstRate')->andReturn(13.0);
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP);
$this->payments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->taxRate === 0.0 && $p->taxAmount === 0.0))
->andReturn(61);
$this->bookings->shouldReceive('setPaymentId')->once()->with(12, 61);
$this->payments->shouldReceive('markPaid')->once()->with(61, 'USC-61')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->payments->shouldReceive('findById')->with(61)->andReturn($this->payment(Payment::METHOD_COMP, Payment::STATUS_PAID, 61));
$this->mailer->shouldReceive('send')->andReturn(false);
self::assertNotNull($this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 100.00, 'CAD'));
}
public function testCompIsPaidAndConfirmsImmediately(): void
{
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP);
+19 -1
View File
@@ -39,6 +39,8 @@ class PaymentTest extends TestCase
'currency' => 'CAD',
'method' => Payment::METHOD_COMP,
'status' => Payment::STATUS_PAID,
'tax_rate' => '13.00',
'tax_amount' => '15.60',
'etransfer_email' => null,
'stripe_payment_intent_id' => null,
'receipt_number' => 'USC-7',
@@ -48,16 +50,32 @@ class PaymentTest extends TestCase
self::assertSame(7, $payment->id);
self::assertSame(120.00, $payment->amount);
self::assertSame(13.00, $payment->taxRate);
self::assertSame(15.60, $payment->taxAmount);
self::assertSame(Payment::METHOD_COMP, $payment->method);
self::assertTrue($payment->isPaid());
self::assertSame('USC-7', $payment->receiptNumber);
}
public function testTotalAddsTaxToAmount(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 100.00, taxRate: 13.0, taxAmount: 13.00);
self::assertSame(113.00, $payment->total());
}
public function testTotalEqualsAmountWhenUntaxed(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 100.00);
self::assertSame(100.00, $payment->total());
}
public function testToArrayContainsExpectedKeys(): void
{
$arr = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toArray();
foreach (['id', 'student_id', 'amount', 'currency', 'method', 'status', 'receipt_number'] as $key) {
foreach (['id', 'student_id', 'amount', 'tax_rate', 'tax_amount', 'total', 'currency', 'method', 'status', 'receipt_number'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
+19
View File
@@ -32,4 +32,23 @@ class ReceiptMailerTest extends TestCase
self::assertTrue((new ReceiptMailer())->send($payment, $student));
}
public function testReceiptBreaksOutHstWhenTaxed(): void
{
Functions\expect('wp_mail')
->once()
->with(
'a@b.test',
Mockery::type('string'),
Mockery::on(static fn (string $body): bool => str_contains($body, 'HST') && str_contains($body, '113.00'))
)
->andReturn(true);
$student = Mockery::mock(\WP_User::class);
$student->user_email = 'a@b.test';
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 100.00, taxRate: 13.0, taxAmount: 13.00, receiptNumber: 'USC-1');
self::assertTrue((new ReceiptMailer())->send($payment, $student));
}
}