From 553cfafa493c3385edc7545d236055048eee33ba Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 8 Jun 2026 11:29:48 -0300 Subject: [PATCH] Add HST/tax support and payment reporting with HST aggregation 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 --- README.md | 4 +- docs/features/payment-reporting.md | 69 ++++++---- docs/features/payments.md | 18 +++ src/AdminMenu.php | 38 ++++-- src/Booking/LessonController.php | 32 +++-- src/Payment/Payment.php | 14 ++ src/Payment/PaymentReport.php | 105 +++++++++++++++ src/Payment/PaymentReportController.php | 130 +++++++++++++++++++ src/Payment/PaymentRepository.php | 40 +++++- src/Payment/PaymentService.php | 6 + src/Payment/ReceiptMailer.php | 27 +++- src/Payment/StudioSettings.php | 11 ++ src/Schema.php | 2 + templates/admin/lessons.php | 19 ++- templates/admin/payment-report.php | 99 ++++++++++++++ templates/admin/settings.php | 8 ++ tests/Unit/Payment/PaymentReportTest.php | 83 ++++++++++++ tests/Unit/Payment/PaymentRepositoryTest.php | 38 ++++++ tests/Unit/Payment/PaymentServiceTest.php | 32 +++++ tests/Unit/Payment/PaymentTest.php | 20 ++- tests/Unit/Payment/ReceiptMailerTest.php | 19 +++ 21 files changed, 756 insertions(+), 58 deletions(-) create mode 100644 src/Payment/PaymentReport.php create mode 100644 src/Payment/PaymentReportController.php create mode 100644 templates/admin/payment-report.php create mode 100644 tests/Unit/Payment/PaymentReportTest.php diff --git a/README.md b/README.md index ad4df21..8a5bc3a 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docs/features/payment-reporting.md b/docs/features/payment-reporting.md index 5582d08..4be963d 100644 --- a/docs/features/payment-reporting.md +++ b/docs/features/payment-reporting.md @@ -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 diff --git a/docs/features/payments.md b/docs/features/payments.md index 6300c29..68a79bb 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -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` | diff --git a/src/AdminMenu.php b/src/AdminMenu.php index ac757b3..d630f27 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -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. diff --git a/src/Booking/LessonController.php b/src/Booking/LessonController.php index e54400a..3a276d0 100644 --- a/src/Booking/LessonController.php +++ b/src/Booking/LessonController.php @@ -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 */ @@ -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(), ]; } } diff --git a/src/Payment/Payment.php b/src/Payment/Payment.php index 40c3aaf..a7ce3a3 100644 --- a/src/Payment/Payment.php +++ b/src/Payment/Payment.php @@ -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, diff --git a/src/Payment/PaymentReport.php b/src/Payment/PaymentReport.php new file mode 100644 index 0000000..1739b84 --- /dev/null +++ b/src/Payment/PaymentReport.php @@ -0,0 +1,105 @@ + $rows + */ + public function __construct( private array $rows ) {} + + /** + * The report's display rows. + * + * @return list + */ + 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 $fields + */ + private function csvLine( array $fields ): string { + $escaped = array_map( + static fn( string $field ): string => '"' . str_replace( '"', '""', $field ) . '"', + $fields + ); + + return implode( ',', $escaped ); + } +} diff --git a/src/Payment/PaymentReportController.php b/src/Payment/PaymentReportController.php new file mode 100644 index 0000000..8a9017a --- /dev/null +++ b/src/Payment/PaymentReportController.php @@ -0,0 +1,130 @@ +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' ); + } +} diff --git a/src/Payment/PaymentRepository.php b/src/Payment/PaymentRepository.php index 66a7d1e..6642636 100644 --- a/src/Payment/PaymentRepository.php +++ b/src/Payment/PaymentRepository.php @@ -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 + */ + 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 ) diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index 238509c..f1afdf8 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -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, ) ); diff --git a/src/Payment/ReceiptMailer.php b/src/Payment/ReceiptMailer.php index b34d257..9eedfa8 100644 --- a/src/Payment/ReceiptMailer.php +++ b/src/Payment/ReceiptMailer.php @@ -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 ); } diff --git a/src/Payment/StudioSettings.php b/src/Payment/StudioSettings.php index 089c003..4dabbc4 100644 --- a/src/Payment/StudioSettings.php +++ b/src/Payment/StudioSettings.php @@ -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 } } diff --git a/src/Schema.php b/src/Schema.php index 9800296..af2deb6 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -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, diff --git a/templates/admin/lessons.php b/templates/admin/lessons.php index c664f30..691d84f 100644 --- a/templates/admin/lessons.php +++ b/templates/admin/lessons.php @@ -5,7 +5,7 @@ if (! defined('ABSPATH')) { exit; } -/** @var list $rows */ +/** @var list $rows */ ?>

@@ -20,6 +20,8 @@ if (! defined('ABSPATH')) { + + @@ -31,6 +33,21 @@ if (! defined('ABSPATH')) { + + +
+ + + + + % + +
+ + + + + 0 ? esc_html($row['currency'] . ' ' . number_format($row['total'], 2)) : esc_html('—'); ?>
diff --git a/templates/admin/payment-report.php b/templates/admin/payment-report.php new file mode 100644 index 0000000..cd43642 --- /dev/null +++ b/templates/admin/payment-report.php @@ -0,0 +1,99 @@ + $instructors + */ +?> +
+

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

+ + totalTax(), 2)); ?> +  |  + + totalAmount(), 2)); ?> +  |  + + grandTotal(), 2)); ?> +

+ + count()) : ?> +

+ + + + + + + + + + + + + + + + rows() as $row) : ?> + + + + + + + + + + + + + + + + + + + + +
totalAmount(), 2)); ?>totalTax(), 2)); ?>grandTotal(), 2)); ?>
+ +
diff --git a/templates/admin/settings.php b/templates/admin/settings.php index adcee45..b46ba30 100644 --- a/templates/admin/settings.php +++ b/templates/admin/settings.php @@ -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')) { + + + + +

+ +

diff --git a/tests/Unit/Payment/PaymentReportTest.php b/tests/Unit/Payment/PaymentReportTest.php new file mode 100644 index 0000000..671fe6b --- /dev/null +++ b/tests/Unit/Payment/PaymentReportTest.php @@ -0,0 +1,83 @@ + + */ + 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); + } +} diff --git a/tests/Unit/Payment/PaymentRepositoryTest.php b/tests/Unit/Payment/PaymentRepositoryTest.php index dd94a2a..3a7e169 100644 --- a/tests/Unit/Payment/PaymentRepositoryTest.php +++ b/tests/Unit/Payment/PaymentRepositoryTest.php @@ -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, diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php index dca51f1..779f892 100644 --- a/tests/Unit/Payment/PaymentServiceTest.php +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -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); diff --git a/tests/Unit/Payment/PaymentTest.php b/tests/Unit/Payment/PaymentTest.php index 74b9bd4..cec1221 100644 --- a/tests/Unit/Payment/PaymentTest.php +++ b/tests/Unit/Payment/PaymentTest.php @@ -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); } } diff --git a/tests/Unit/Payment/ReceiptMailerTest.php b/tests/Unit/Payment/ReceiptMailerTest.php index 9d58d09..87e3ce5 100644 --- a/tests/Unit/Payment/ReceiptMailerTest.php +++ b/tests/Unit/Payment/ReceiptMailerTest.php @@ -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)); + } }