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
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:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,22 @@ page (`manage_billing`, studio admin only):
|
||||
| `us_stripe_secret_key` | Stripe secret key |
|
||||
| `us_stripe_mode` | `test` or `live` |
|
||||
| `us_currency` | Default ISO 4217 currency, e.g. `CAD` |
|
||||
| `us_etransfer_email` | Studio-default e-transfer destination |
|
||||
| `us_hst_rate` | Default HST/tax percentage, e.g. `13` |
|
||||
|
||||
## HST / Tax
|
||||
|
||||
A studio-default **HST rate** (percentage) is configured on **Studio Settings**
|
||||
(`us_hst_rate`, `manage_billing`). At booking the rate is **frozen onto the
|
||||
payment** (`us_payments.tax_rate`) and the tax is computed against the pre-tax
|
||||
subtotal (`tax_amount = round(amount × rate / 100, 2)`). The billed total is
|
||||
`amount + tax_amount` (`Payment::total()`). Comped registrations are never taxed
|
||||
(rate and amount are 0).
|
||||
|
||||
The rate is overridable **per booking** on **My Lessons** (instructor) while the
|
||||
payment is still unpaid; saving recomputes `tax_amount` from the current
|
||||
subtotal (`PaymentRepository::updateTax`). Receipts break out subtotal, HST, and
|
||||
total when tax applies.
|
||||
|
||||
## Per-Student Billing Method
|
||||
Each student's billing method is stored in user meta `us_payment_method`, set by the
|
||||
@@ -66,6 +82,8 @@ After booking, the destination on a payment can be corrected per booking:
|
||||
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
|
||||
| `method` | VARCHAR(20) | `card` / `etransfer` / `comp` |
|
||||
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` |
|
||||
| `tax_rate` | DECIMAL(5,2) | HST rate % frozen at booking; editable until paid |
|
||||
| `tax_amount` | DECIMAL(10,2) | Computed tax in dollars (`amount × tax_rate / 100`) |
|
||||
| `etransfer_email` | VARCHAR(191) | Frozen e-transfer destination; editable until confirmed |
|
||||
| `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp |
|
||||
| `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` |
|
||||
|
||||
+27
-11
@@ -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.
|
||||
|
||||
@@ -39,8 +39,8 @@ class LessonController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a per-lesson e-transfer email override. When $onlyOwn, the payment
|
||||
* must belong to the current instructor.
|
||||
* Handle a per-lesson payment override (e-transfer email or HST rate). When
|
||||
* $onlyOwn, the payment must belong to the current instructor.
|
||||
*/
|
||||
private function handleEtransferUpdate( bool $onlyOwn ): void {
|
||||
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
|
||||
@@ -48,26 +48,32 @@ class LessonController {
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
|
||||
if ( 'set_etransfer' !== sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
|
||||
$paymentId = absint( $_POST['payment_id'] ?? 0 );
|
||||
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
|
||||
$taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, (float) $_POST['tax_rate'] ) : 0.0;
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
if ( $paymentId <= 0 ) {
|
||||
if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payment = $this->payments->findById( $paymentId );
|
||||
if ( null !== $payment && ( ! $onlyOwn || get_current_user_id() === $payment->instructorId ) ) {
|
||||
$this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null );
|
||||
if ( null === $payment || ( $onlyOwn && get_current_user_id() !== $payment->instructorId ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'set_tax' === $action ) {
|
||||
$this->payments->updateTax( $paymentId, $taxRate );
|
||||
return;
|
||||
}
|
||||
|
||||
$this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a display row for a lesson, including its e-transfer payment override.
|
||||
* Build a display row for a lesson, including its e-transfer and HST payment
|
||||
* overrides.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -83,8 +89,14 @@ class LessonController {
|
||||
'status' => $lesson->status,
|
||||
'notes' => $lesson->notes ?? '',
|
||||
'payment_id' => $payment ? (int) $payment->id : 0,
|
||||
'currency' => $payment ? (string) $payment->currency : '',
|
||||
'amount' => $payment ? (float) $payment->amount : 0.0,
|
||||
'tax_rate' => $payment ? (float) $payment->taxRate : 0.0,
|
||||
'tax_amount' => $payment ? (float) $payment->taxAmount : 0.0,
|
||||
'total' => $payment ? $payment->total() : 0.0,
|
||||
'etransfer_email' => $payment ? (string) $payment->etransferEmail : '',
|
||||
'etransfer_editable' => null !== $payment && Payment::METHOD_ETRANSFER === $payment->method && ! $payment->isPaid(),
|
||||
'tax_editable' => null !== $payment && ! $payment->isPaid(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class PaymentRepository {
|
||||
'currency' => $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'status' => $payment->status,
|
||||
'tax_rate' => $payment->taxRate,
|
||||
'tax_amount' => $payment->taxAmount,
|
||||
'etransfer_email' => $payment->etransferEmail,
|
||||
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
|
||||
'receipt_number' => $payment->receiptNumber,
|
||||
@@ -30,7 +32,7 @@ class PaymentRepository {
|
||||
'paid_at' => $payment->paidAt,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -46,6 +48,42 @@ class PaymentRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a payment's tax rate and recompute the tax amount from its subtotal.
|
||||
*/
|
||||
public function updateTax( int $id, float $rate ): bool {
|
||||
return false !== $this->db->query(
|
||||
$this->db->prepare(
|
||||
"UPDATE {$this->table} SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d",
|
||||
$rate,
|
||||
$rate,
|
||||
$id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paid payments in a month (`Y-m` bounds), optionally for one instructor —
|
||||
* the reporting source.
|
||||
*
|
||||
* @return list<Payment>
|
||||
*/
|
||||
public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array {
|
||||
$sql = "SELECT * FROM {$this->table} WHERE status = %s AND paid_at >= %s AND paid_at < %s";
|
||||
$params = [ Payment::STATUS_PAID, $from, $to ];
|
||||
|
||||
if ( $instructorId > 0 ) {
|
||||
$sql .= ' AND instructor_id = %d';
|
||||
$params[] = $instructorId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY paid_at ASC';
|
||||
|
||||
$rows = $this->db->get_results( $this->db->prepare( $sql, $params ) );
|
||||
|
||||
return array_map( Payment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
public function findById( int $id ): ?Payment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
|
||||
|
||||
@@ -41,6 +41,10 @@ class PaymentService {
|
||||
? $offeringEtransferEmail
|
||||
: ( '' !== $this->settings->etransferEmail() ? $this->settings->etransferEmail() : null );
|
||||
|
||||
// HST is frozen from the studio default at booking; comped students are not taxed.
|
||||
$taxRate = Payment::METHOD_COMP === $method ? 0.0 : $this->settings->hstRate();
|
||||
$taxAmount = round( $amount * $taxRate / 100, 2 );
|
||||
|
||||
$id = $this->payments->insert(
|
||||
new Payment(
|
||||
studentId: $studentId,
|
||||
@@ -51,6 +55,8 @@ class PaymentService {
|
||||
currency: $currency,
|
||||
method: $method,
|
||||
status: $status,
|
||||
taxRate: $taxRate,
|
||||
taxAmount: $taxAmount,
|
||||
etransferEmail: $etransferEmail,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -20,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 );
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/** @var list<array{student: string, instructor: string, slot_id: int, status: string, notes: string, payment_id: int, etransfer_email: string, etransfer_editable: bool}> $rows */
|
||||
/** @var list<array{student: string, instructor: string, slot_id: int, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows */
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
|
||||
@@ -20,6 +20,8 @@ if (! defined('ABSPATH')) {
|
||||
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Slot ID', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
@@ -31,6 +33,21 @@ if (! defined('ABSPATH')) {
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
<td><?php echo esc_html((string) $row['slot_id']); ?></td>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
<td>
|
||||
<?php if ($row['tax_editable']) : ?>
|
||||
<form method="post" style="display:flex; gap:4px; align-items:center;">
|
||||
<?php wp_nonce_field('usc_lesson_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="set_tax">
|
||||
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['payment_id']); ?>">
|
||||
<input type="number" name="tax_rate" min="0" max="100" step="0.01" style="width:5em;" value="<?php echo esc_attr(number_format($row['tax_rate'], 2)); ?>">
|
||||
<span>%</span>
|
||||
<button type="submit" class="button button-small"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
|
||||
</form>
|
||||
<?php else : ?>
|
||||
<?php echo esc_html(number_format($row['tax_rate'], 2) . '% (' . $row['currency'] . ' ' . number_format($row['tax_amount'], 2) . ')'); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo $row['payment_id'] > 0 ? esc_html($row['currency'] . ' ' . number_format($row['total'], 2)) : esc_html('—'); ?></td>
|
||||
<td>
|
||||
<?php if ($row['etransfer_editable']) : ?>
|
||||
<form method="post" style="display:flex; gap:4px;">
|
||||
|
||||
@@ -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)); ?>
|
||||
|
|
||||
<strong><?php esc_html_e('Subtotal:', 'unsupervised-schedular'); ?></strong>
|
||||
<?php echo esc_html(number_format($report->totalAmount(), 2)); ?>
|
||||
|
|
||||
<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>
|
||||
@@ -11,6 +11,7 @@ if (! defined('ABSPATH')) {
|
||||
* @var string $mode
|
||||
* @var string $currency
|
||||
* @var string $etransferEmail
|
||||
* @var float $hstRate
|
||||
* @var bool $stripeConfigured
|
||||
*/
|
||||
?>
|
||||
@@ -53,6 +54,13 @@ if (! defined('ABSPATH')) {
|
||||
<th><label for="currency"><?php esc_html_e('Default currency', 'unsupervised-schedular'); ?></label></th>
|
||||
<td><input type="text" name="currency" id="currency" class="small-text" maxlength="3" value="<?php echo esc_attr($currency); ?>"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="hst_rate"><?php esc_html_e('Default HST rate (%)', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="number" name="hst_rate" id="hst_rate" class="small-text" min="0" max="100" step="0.01" value="<?php echo esc_attr(number_format($hstRate, 2)); ?>">
|
||||
<p class="description"><?php esc_html_e('Added to the subtotal when billing. 0 means no tax. Overridable per booking on My Lessons.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?></h2>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Payment\PaymentReport;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentReportTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return list<array{date: string, student: string, instructor: string, method: string, status: string, amount: float, tax_rate: float, tax_amount: float, total: float}>
|
||||
*/
|
||||
private function rows(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'date' => '2026-06-02',
|
||||
'student' => 'Ada',
|
||||
'instructor' => 'Pat',
|
||||
'method' => 'etransfer',
|
||||
'status' => 'paid',
|
||||
'amount' => 100.00,
|
||||
'tax_rate' => 13.0,
|
||||
'tax_amount' => 13.00,
|
||||
'total' => 113.00,
|
||||
],
|
||||
[
|
||||
'date' => '2026-06-09',
|
||||
'student' => 'Grace',
|
||||
'instructor' => 'Pat',
|
||||
'method' => 'card',
|
||||
'status' => 'paid',
|
||||
'amount' => 50.00,
|
||||
'tax_rate' => 13.0,
|
||||
'tax_amount' => 6.50,
|
||||
'total' => 56.50,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testTotalsAggregateAmountsAndTax(): void
|
||||
{
|
||||
$report = new PaymentReport($this->rows());
|
||||
|
||||
self::assertSame(2, $report->count());
|
||||
self::assertSame(150.00, $report->totalAmount());
|
||||
self::assertSame(19.50, $report->totalTax());
|
||||
self::assertSame(169.50, $report->grandTotal());
|
||||
}
|
||||
|
||||
public function testEmptyReportHasZeroTotals(): void
|
||||
{
|
||||
$report = new PaymentReport([]);
|
||||
|
||||
self::assertSame(0, $report->count());
|
||||
self::assertSame(0.0, $report->totalTax());
|
||||
self::assertSame(0.0, $report->grandTotal());
|
||||
}
|
||||
|
||||
public function testCsvIncludesHeaderRowsAndTotals(): void
|
||||
{
|
||||
$csv = (new PaymentReport($this->rows()))->toCsv();
|
||||
$lines = explode("\n", trim($csv));
|
||||
|
||||
// header + 2 data rows + totals row.
|
||||
self::assertCount(4, $lines);
|
||||
self::assertStringContainsString('"Date","Student","Instructor"', $lines[0]);
|
||||
self::assertStringContainsString('"Ada"', $lines[1]);
|
||||
self::assertStringContainsString('"Totals"', $lines[3]);
|
||||
self::assertStringContainsString('"19.50"', $lines[3]);
|
||||
self::assertStringContainsString('"169.50"', $lines[3]);
|
||||
}
|
||||
|
||||
public function testCsvEscapesEmbeddedQuotes(): void
|
||||
{
|
||||
$rows = $this->rows();
|
||||
$rows[0]['student'] = 'Ada "The Great"';
|
||||
$csv = (new PaymentReport($rows))->toCsv();
|
||||
|
||||
self::assertStringContainsString('"Ada ""The Great"""', $csv);
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,42 @@ class PaymentRepositoryTest extends TestCase
|
||||
self::assertTrue($this->repo->markPaid(50, 'USC-50'));
|
||||
}
|
||||
|
||||
public function testUpdateTaxRecomputesAmountFromRate(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::pattern('/tax_amount = ROUND\( amount \* %f \/ 100, 2 \)/'), 13.0, 13.0, 50)
|
||||
->andReturn('UPDATE ...');
|
||||
|
||||
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
|
||||
|
||||
self::assertTrue($this->repo->updateTax(50, 13.0));
|
||||
}
|
||||
|
||||
public function testFindPaidBetweenFiltersByInstructor(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::pattern('/status = %s AND paid_at >= %s AND paid_at < %s AND instructor_id = %d/'), ['paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00', 3])
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
|
||||
|
||||
self::assertCount(1, $this->repo->findPaidBetween('2026-06-01 00:00:00', '2026-07-01 00:00:00', 3));
|
||||
}
|
||||
|
||||
public function testFindPaidBetweenWithoutInstructorOmitsFilter(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::on(static fn (string $sql): bool => ! str_contains($sql, 'instructor_id')), ['paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00'])
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$this->db->shouldReceive('get_results')->andReturn([]);
|
||||
|
||||
self::assertCount(0, $this->repo->findPaidBetween('2026-06-01 00:00:00', '2026-07-01 00:00:00'));
|
||||
}
|
||||
|
||||
public function testFindByRegistrationReturnsPayment(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
@@ -98,6 +134,8 @@ class PaymentRepositoryTest extends TestCase
|
||||
'currency' => 'CAD',
|
||||
'method' => Payment::METHOD_ETRANSFER,
|
||||
'status' => Payment::STATUS_PENDING,
|
||||
'tax_rate' => '0.00',
|
||||
'tax_amount' => '0.00',
|
||||
'etransfer_email' => null,
|
||||
'stripe_payment_intent_id' => null,
|
||||
'receipt_number' => null,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user