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 | | 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 | | 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 | | 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 | | Payments (e-transfer/comp + receipts + HST; 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 | | 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 > 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` > clean seam (a lesson lands `pending`, an enrolment `active`, with `payment_id`
+44 -25
View File
@@ -1,49 +1,68 @@
# Feature: Payment Reporting # Feature: Payment Reporting
## Overview ## 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 ## 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 | | 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 | | Student | `us_payments.student_id` → display name |
| Offering | registration → `us_offerings.title` | | Instructor | `us_payments.instructor_id` → display name |
| Method | `us_payments.method` | | Method | `us_payments.method` |
| Status | `us_payments.status` | | 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 ## Access Rules
- Studio admin (`view_all_payments`): all instructors; may filter by `instructor_id`. - 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. - Export requires `export_payments` and is scoped to the same rows the caller may view.
## Admin Interface ## 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 **Payment Report** in wp-admin (top-level menu, gated on `export_payments` so
| Method | Endpoint | Permission | instructors — who lack `manage_billing` — can reach it):
|--------|------------------------------------------------|----------------------|
| `GET` | `/wp-json/us-scheduler/v1/payments` | `view_own_payments` or `view_all_payments` |
| `GET` | `/wp-json/us-scheduler/v1/payments/export` | `export_payments` |
Query params: `month` (`YYYY-MM`, required), `instructor_id` (optional; ignored for - Month picker (defaults to the current month) and, for studio admin, an
instructors, who are always scoped to themselves). `GET /payments/export` returns instructor filter
`text/csv` with a `Content-Disposition` attachment header. - 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 ## Implementation
- Report controller: `Unsupervised\Schedular\Payment\PaymentReportController`
- CSV exporter: `Unsupervised\Schedular\Payment\PaymentExporter` - Report aggregator (pure totals + CSV): `Unsupervised\Schedular\Payment\PaymentReport`
- Report query: `Unsupervised\Schedular\Payment\PaymentRepository::report()` - 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
- `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_secret_key` | Stripe secret key |
| `us_stripe_mode` | `test` or `live` | | `us_stripe_mode` | `test` or `live` |
| `us_currency` | Default ISO 4217 currency, e.g. `CAD` | | `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 ## Per-Student Billing Method
Each student's billing method is stored in user meta `us_payment_method`, set by the 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` | | `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
| `method` | VARCHAR(20) | `card` / `etransfer` / `comp` | | `method` | VARCHAR(20) | `card` / `etransfer` / `comp` |
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` | | `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 | | `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 | | `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp |
| `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` | | `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\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\PaymentController; use Unsupervised\Schedular\Payment\PaymentController;
use Unsupervised\Schedular\Payment\PaymentReportController;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
@@ -39,22 +40,25 @@ class AdminMenu {
private StudentController $studentController; private StudentController $studentController;
private StudioSettings $settings; private StudioSettings $settings;
private PaymentController $paymentController; 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 ) { 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->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments ); $this->lessonController = new LessonController( $bookings, $payments );
$this->offeringController = new OfferingController( $offerings ); $this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings ); $this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites ); $this->registrationController = new RegistrationController( $invites );
$this->groupClassController = new GroupClassController( $enrollments, $offerings ); $this->groupClassController = new GroupClassController( $enrollments, $offerings );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver ); $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
$this->settings = $settings; $this->settings = $settings;
$this->paymentController = new PaymentController( $payments, $paymentService ); $this->paymentController = new PaymentController( $payments, $paymentService );
$this->paymentReportController = new PaymentReportController( $payments );
} }
public function register(): void { public function register(): void {
add_action( 'admin_menu', [ $this, 'addPages' ] ); add_action( 'admin_menu', [ $this, 'addPages' ] );
add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] );
} }
public function addPages(): void { public function addPages(): void {
@@ -156,6 +160,18 @@ class AdminMenu {
38 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. // Studio admin: Stripe credentials and billing settings.
add_menu_page( add_menu_page(
__( 'Studio Settings', 'unsupervised-schedular' ), __( 'Studio Settings', 'unsupervised-schedular' ),
@@ -164,7 +180,7 @@ class AdminMenu {
'us-settings', 'us-settings',
[ $this->settings, 'renderPage' ], [ $this->settings, 'renderPage' ],
'dashicons-admin-settings', 'dashicons-admin-settings',
39 40
); );
// Instructor: view their upcoming lessons. // 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 * Handle a per-lesson payment override (e-transfer email or HST rate). When
* must belong to the current instructor. * $onlyOwn, the payment must belong to the current instructor.
*/ */
private function handleEtransferUpdate( bool $onlyOwn ): void { private function handleEtransferUpdate( bool $onlyOwn ): void {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) { 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. // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'set_etransfer' !== sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) { $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
return;
}
$paymentId = absint( $_POST['payment_id'] ?? 0 ); $paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ); $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 // phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId <= 0 ) { if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) {
return; return;
} }
$payment = $this->payments->findById( $paymentId ); $payment = $this->payments->findById( $paymentId );
if ( null !== $payment && ( ! $onlyOwn || get_current_user_id() === $payment->instructorId ) ) { if ( null === $payment || ( $onlyOwn && get_current_user_id() !== $payment->instructorId ) ) {
$this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null ); 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> * @return array<string, mixed>
*/ */
@@ -83,8 +89,14 @@ class LessonController {
'status' => $lesson->status, 'status' => $lesson->status,
'notes' => $lesson->notes ?? '', 'notes' => $lesson->notes ?? '',
'payment_id' => $payment ? (int) $payment->id : 0, '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_email' => $payment ? (string) $payment->etransferEmail : '',
'etransfer_editable' => null !== $payment && Payment::METHOD_ETRANSFER === $payment->method && ! $payment->isPaid(), '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 $currency = 'CAD',
public readonly string $method = self::METHOD_ETRANSFER, public readonly string $method = self::METHOD_ETRANSFER,
public readonly string $status = self::STATUS_PENDING, 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 $etransferEmail = null,
public readonly ?string $stripePaymentIntentId = null, public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null, public readonly ?string $receiptNumber = null,
@@ -58,6 +60,8 @@ class Payment {
currency: $row->currency, currency: $row->currency,
method: $row->method, method: $row->method,
status: $row->status, status: $row->status,
taxRate: (float) $row->tax_rate,
taxAmount: (float) $row->tax_amount,
etransferEmail: $row->etransfer_email, etransferEmail: $row->etransfer_email,
stripePaymentIntentId: $row->stripe_payment_intent_id, stripePaymentIntentId: $row->stripe_payment_intent_id,
receiptNumber: $row->receipt_number, receiptNumber: $row->receipt_number,
@@ -71,6 +75,13 @@ class Payment {
return self::STATUS_PAID === $this->status; 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. * Returns a plain array representation of the payment.
* *
@@ -85,6 +96,9 @@ class Payment {
'etransfer_email' => $this->etransferEmail, 'etransfer_email' => $this->etransferEmail,
'registration_id' => $this->registrationId, 'registration_id' => $this->registrationId,
'amount' => $this->amount, 'amount' => $this->amount,
'tax_rate' => $this->taxRate,
'tax_amount' => $this->taxAmount,
'total' => $this->total(),
'currency' => $this->currency, 'currency' => $this->currency,
'method' => $this->method, 'method' => $this->method,
'status' => $this->status, '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, 'currency' => $payment->currency,
'method' => $payment->method, 'method' => $payment->method,
'status' => $payment->status, 'status' => $payment->status,
'tax_rate' => $payment->taxRate,
'tax_amount' => $payment->taxAmount,
'etransfer_email' => $payment->etransferEmail, 'etransfer_email' => $payment->etransferEmail,
'stripe_payment_intent_id' => $payment->stripePaymentIntentId, 'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
'receipt_number' => $payment->receiptNumber, 'receipt_number' => $payment->receiptNumber,
@@ -30,7 +32,7 @@ class PaymentRepository {
'paid_at' => $payment->paidAt, 'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ), '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; 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 { public function findById( int $id ): ?Payment {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
+6
View File
@@ -41,6 +41,10 @@ class PaymentService {
? $offeringEtransferEmail ? $offeringEtransferEmail
: ( '' !== $this->settings->etransferEmail() ? $this->settings->etransferEmail() : null ); : ( '' !== $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( $id = $this->payments->insert(
new Payment( new Payment(
studentId: $studentId, studentId: $studentId,
@@ -51,6 +55,8 @@ class PaymentService {
currency: $currency, currency: $currency,
method: $method, method: $method,
status: $status, status: $status,
taxRate: $taxRate,
taxAmount: $taxAmount,
etransferEmail: $etransferEmail, etransferEmail: $etransferEmail,
) )
); );
+20 -7
View File
@@ -20,13 +20,26 @@ class ReceiptMailer {
(string) $payment->receiptNumber (string) $payment->receiptNumber
); );
$body = sprintf( if ( $payment->taxAmount > 0 ) {
/* translators: 1: amount, 2: currency, 3: receipt number */ $body = sprintf(
__( "Thank you. We have recorded your payment of %1\$s %2\$s.\n\nReceipt: %3\$s", 'unsupervised-schedular' ), /* translators: 1: currency, 2: subtotal, 3: HST rate, 4: HST amount, 5: total, 6: receipt number */
number_format( $payment->amount, 2 ), __( "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, $payment->currency,
(string) $payment->receiptNumber 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 ); 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_MODE = 'us_stripe_mode';
public const OPT_CURRENCY = 'us_currency'; public const OPT_CURRENCY = 'us_currency';
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email'; public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate';
public function publishableKey(): string { public function publishableKey(): string {
return (string) get_option( self::OPT_PUBLISHABLE, '' ); return (string) get_option( self::OPT_PUBLISHABLE, '' );
@@ -39,6 +40,13 @@ class StudioSettings {
return (string) get_option( self::OPT_ETRANSFER_EMAIL, '' ); 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 * Whether Stripe is configured. When false the platform falls back to
* e-transfer billing and card processing is unavailable. * e-transfer billing and card processing is unavailable.
@@ -61,6 +69,7 @@ class StudioSettings {
$mode = $this->mode(); $mode = $this->mode();
$currency = $this->currency(); $currency = $this->currency();
$etransferEmail = $this->etransferEmail(); $etransferEmail = $this->etransferEmail();
$hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured(); $stripeConfigured = $this->isStripeConfigured();
include USC_PLUGIN_DIR . 'templates/admin/settings.php'; 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_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ); 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'] ?? '' ) ) ); 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 // phpcs:enable WordPress.Security.NonceVerification.Missing
} }
} }
+2
View File
@@ -151,6 +151,8 @@ class Schema {
currency VARCHAR(3) NOT NULL DEFAULT 'CAD', currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
method VARCHAR(20) NOT NULL DEFAULT 'etransfer', method VARCHAR(20) NOT NULL DEFAULT 'etransfer',
status VARCHAR(20) NOT NULL DEFAULT 'pending', 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, etransfer_email VARCHAR(191) DEFAULT NULL,
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
receipt_number VARCHAR(50) DEFAULT NULL, receipt_number VARCHAR(50) DEFAULT NULL,
+18 -1
View File
@@ -5,7 +5,7 @@ if (! defined('ABSPATH')) {
exit; 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"> <div class="wrap">
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1> <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('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Slot ID', '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('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('E-transfer email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
</tr> </tr>
@@ -31,6 +33,21 @@ if (! defined('ABSPATH')) {
<td><?php echo esc_html($row['instructor']); ?></td> <td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo esc_html((string) $row['slot_id']); ?></td> <td><?php echo esc_html((string) $row['slot_id']); ?></td>
<td><?php echo esc_html($row['status']); ?></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> <td>
<?php if ($row['etransfer_editable']) : ?> <?php if ($row['etransfer_editable']) : ?>
<form method="post" style="display:flex; gap:4px;"> <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 $mode
* @var string $currency * @var string $currency
* @var string $etransferEmail * @var string $etransferEmail
* @var float $hstRate
* @var bool $stripeConfigured * @var bool $stripeConfigured
*/ */
?> ?>
@@ -53,6 +54,13 @@ if (! defined('ABSPATH')) {
<th><label for="currency"><?php esc_html_e('Default currency', 'unsupervised-schedular'); ?></label></th> <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> <td><input type="text" name="currency" id="currency" class="small-text" maxlength="3" value="<?php echo esc_attr($currency); ?>"></td>
</tr> </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> </table>
<h2><?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?></h2> <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')); 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 public function testFindByRegistrationReturnsPayment(): void
{ {
$this->db->shouldReceive('prepare') $this->db->shouldReceive('prepare')
@@ -98,6 +134,8 @@ class PaymentRepositoryTest extends TestCase
'currency' => 'CAD', 'currency' => 'CAD',
'method' => Payment::METHOD_ETRANSFER, 'method' => Payment::METHOD_ETRANSFER,
'status' => Payment::STATUS_PENDING, 'status' => Payment::STATUS_PENDING,
'tax_rate' => '0.00',
'tax_amount' => '0.00',
'etransfer_email' => null, 'etransfer_email' => null,
'stripe_payment_intent_id' => null, 'stripe_payment_intent_id' => null,
'receipt_number' => null, 'receipt_number' => null,
+32
View File
@@ -37,6 +37,7 @@ class PaymentServiceTest extends TestCase
$this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->settings = Mockery::mock(StudioSettings::class); $this->settings = Mockery::mock(StudioSettings::class);
$this->settings->shouldReceive('etransferEmail')->andReturn(''); $this->settings->shouldReceive('etransferEmail')->andReturn('');
$this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault();
$this->service = new PaymentService( $this->service = new PaymentService(
$this->payments, $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 public function testCompIsPaidAndConfirmsImmediately(): void
{ {
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP); $this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP);
+19 -1
View File
@@ -39,6 +39,8 @@ class PaymentTest extends TestCase
'currency' => 'CAD', 'currency' => 'CAD',
'method' => Payment::METHOD_COMP, 'method' => Payment::METHOD_COMP,
'status' => Payment::STATUS_PAID, 'status' => Payment::STATUS_PAID,
'tax_rate' => '13.00',
'tax_amount' => '15.60',
'etransfer_email' => null, 'etransfer_email' => null,
'stripe_payment_intent_id' => null, 'stripe_payment_intent_id' => null,
'receipt_number' => 'USC-7', 'receipt_number' => 'USC-7',
@@ -48,16 +50,32 @@ class PaymentTest extends TestCase
self::assertSame(7, $payment->id); self::assertSame(7, $payment->id);
self::assertSame(120.00, $payment->amount); 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::assertSame(Payment::METHOD_COMP, $payment->method);
self::assertTrue($payment->isPaid()); self::assertTrue($payment->isPaid());
self::assertSame('USC-7', $payment->receiptNumber); 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 public function testToArrayContainsExpectedKeys(): void
{ {
$arr = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toArray(); $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); self::assertArrayHasKey($key, $arr);
} }
} }
+19
View File
@@ -32,4 +32,23 @@ class ReceiptMailerTest extends TestCase
self::assertTrue((new ReceiptMailer())->send($payment, $student)); 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));
}
} }