Credit students for cancelled paid lessons
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m52s

Cancelling a lesson that was already paid for now credits the student
that money instead of leaving it as a manual refund, and the daily
scheduled-billing scan applies any available credit against their due
charges before emailing the notice.

- New us_credits ledger + us_payments.credit_applied column (Payment::netDue).
- PaymentService::creditForCancelledLesson issues a per-lesson share of the
  covering payment's total; wired into all three cancel paths (student
  self-cancel, instructor status update, admin student-detail cancel).
- PaymentService::applyCredits draws credit down FIFO across a run's charges,
  marking a fully-covered charge paid-by-credit; the notice shows the credit
  applied and reduced total, and the admin queue shows net due.
- Student detail page shows a student's credit balance and history.

Ships as part of the unreleased 1.2.0 (same release as scheduled billing).

Tests: composer test (585), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-24 15:32:20 -03:00
co-authored by Claude Opus 4.8
parent 3f9aef7746
commit e8e66eef3c
30 changed files with 1210 additions and 80 deletions
+123
View File
@@ -0,0 +1,123 @@
# Feature: Student Credits (cancelled paid lessons)
## Overview
When a lesson that has **already been paid for** is cancelled, the student is
credited the amount they paid for *that lesson*. The credit sits on their account
and is automatically applied against their future scheduled-billing charges
(weekly / monthly) before they are asked to pay — so a cancelled-and-paid lesson
becomes money toward the next one rather than a manual refund.
This complements — it does not replace — the existing cancellation behaviour: a
still-**pending** payment is voided (`PaymentService::voidPending`), and only a
**paid** payment produces a credit.
## Credit amount — one lesson's share
The credit is one lesson's share of the covering payment's **total (including
tax)**:
| Covering payment | Lessons it covers | Credit on cancelling one |
|------------------|-------------------|--------------------------|
| Single booking (one-time / full-term single) | 1 | the whole total |
| Weekly **scheduled** lesson | 1 (one payment per lesson) | the whole total |
| Monthly **scheduled** charge | N lessons that month | `total ÷ N` |
| Weekly reservation **series** paid upfront (full-term) | the whole series | `total ÷ series size` |
The divisor is resolved in `PaymentService::coveredLessonCount`: a weekly series
paid upfront (an *unscheduled* payment on a lesson that has a `series_id`) divides
by the series size (`BookingRepository::countBySeries`); every other case divides
by how many lessons point at the payment (`BookingRepository::countByPaymentId`),
which is 1 for a single or weekly-scheduled lesson and N for a monthly charge.
The original payment is **left untouched** — the studio keeps the money it
collected; the credit is a forward-looking liability offset against future
billing, never a refund of past revenue.
### Guards
- Only a **paid** payment credits; an unpaid/pending one is voided instead.
- A lesson is credited **once**`CreditRepository::existsForLesson` blocks a
second credit if the same lesson is cancelled again after being reinstated.
- A non-anchor lesson in a series (no `payment_id` of its own) is credited through
the series anchor's payment.
## Applying credit at billing time
The daily scan (`Payment\ScheduledBillingRunner`) generates each student's due
payments, then — before sending the notice — applies their available credit
across those charges oldest-first (`PaymentService::applyCredits`):
- Each payment's `us_payments.credit_applied` is raised by the amount covered,
reducing what the student owes (`Payment::netDue()`).
- A payment **fully** covered by credit is marked **paid-by-credit** (status
`paid`, registration confirmed) so it drops out of the admin confirmation queue.
- A payment **partially** covered stays `pending` at its reduced net due, shown in
the admin Payments queue and on the notice.
- The credit ledger is drawn down by the total applied
(`CreditRepository::consume`, FIFO), marking each spent credit `consumed`.
The consolidated notice email (`Payment\PaymentDueMailer`) lists each charge at
its full amount, then an **"Account credit applied: -X"** line and the reduced
**Total due**. When the balance is zero the notice still goes out (so the student
knows their credit covered it) but carries no e-transfer destination or reference.
## Admin visibility
The studio admin sees a student's credit on their **student detail** page (gated by
`manage_billing`, like the payment history). An **Account credit** section shows the
available balance and a table of every credit — date, reason, original amount,
remaining, and status (`available` / `consumed`). Built by
`Auth\StudentHistory::creditBalance` / `::credits`.
## Data model — `{prefix}us_credits`
| Column | Type | Notes |
|---------------------|-----------------|---------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `student_id` | BIGINT UNSIGNED | WordPress user ID |
| `amount` | DECIMAL(10,2) | Original credit amount |
| `remaining` | DECIMAL(10,2) | Unused balance |
| `currency` | VARCHAR(3) | ISO 4217 |
| `source_payment_id` | BIGINT UNSIGNED | Payment that paid for the cancelled lesson |
| `source_lesson_id` | BIGINT UNSIGNED | The cancelled lesson (dedup key) |
| `reason` | VARCHAR(191) | Human-readable note |
| `status` | VARCHAR(20) | `available` / `consumed` |
| `created_at` | DATETIME | Insertion time |
| `updated_at` | DATETIME | Last draw-down; NULL until first consumed |
A new column on `{prefix}us_payments`:
| Column | Type | Notes |
|------------------|---------------|-----------------------------------------------------------|
| `credit_applied` | DECIMAL(10,2) | Account credit applied to this payment; `netDue = total credit_applied` |
> **Schema change:** `us_credits` and `us_payments.credit_applied` ship as part of
> the (as-yet-unreleased) **1.2.0** — the same release as scheduled billing — so
> `Installer`/`dbDelta` create them when a pre-1.2.0 site upgrades. If you are on a
> 1.2.0 *dev* build that predates this feature, the stored `us_schedular_version`
> already matches `USC_VERSION`, so `Plugin::boot()` will not re-run the installer;
> reactivate the plugin (or bump the version) to pick the new table/column up.
## Reporting caveat
Credits never touch past revenue and a credit-covered future charge is still
marked `paid`, so `PaymentReport` (which sums `status = paid`) counts the original
paid lesson and the later credit-covered lesson as gross revenue. This mirrors the
design choice to leave the original payment intact rather than represent a partial
refund of a shared payment.
## Implementation
- Model: `Unsupervised\Schedular\Payment\Credit`
- Repository: `Unsupervised\Schedular\Payment\CreditRepository`
- Issue on cancel: `PaymentService::creditForCancelledLesson`
(called from `Booking\BookingEndpoint::cancel` and `::updateStatus`)
- Apply at billing: `PaymentService::applyCredits`, driven by
`Payment\ScheduledBillingRunner::sendNotices`
- Net due: `Payment::netDue()`, `PaymentRepository::addCreditApplied`
- Lesson counts: `Booking\BookingRepository::countByPaymentId` / `countBySeries`
- Admin view: `Auth\StudentHistory::creditBalance` / `::credits`, rendered in
`templates/admin/student-detail.php`
## Tests
- `tests/Unit/Payment/CreditRepositoryTest.php`
- `tests/Unit/Payment/PaymentServiceTest.php` (`creditForCancelledLesson`, `applyCredits`)
- `tests/Unit/Payment/ScheduledBillingRunnerTest.php` (credit applied to a run)
- `tests/Unit/Payment/PaymentDueMailerTest.php` (credit line + reduced total)
- `tests/Unit/Payment/PaymentTest.php` (`netDue`)
- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel)
- `tests/Unit/Auth/StudentHistoryTest.php` (`creditBalance`, `credits`)
+5
View File
@@ -118,6 +118,11 @@ notice per scan (`Payment\PaymentDueMailer`). Because these payments are schedul
monthly charge (and every other lesson it covers) untouched, and never rebills. Full
model, dedup, and the four generation cases are documented in `scheduled-billing.md`.
Cancelling a lesson that was **already paid** issues the student an account credit for
that lesson's share of what they paid; the next daily scan applies any available credit
against their due charges (reducing `us_payments.credit_applied``Payment::netDue()`)
before emailing the notice. See `credits.md`.
## REST API
| Method | Endpoint | Permission |
|---------|---------------------------------------------|-----------------------------|
+6 -2
View File
@@ -69,8 +69,12 @@ at-registration payments have no batch and appear on their own.
## Cancellation
Scheduled payments are never auto-voided. `PaymentService::voidPending` acts only on
legacy at-registration payments (`! Payment::isScheduled()`), so cancelling one lesson
never voids a shared monthly charge, never refunds, and never rebills. Refunds/credits
are a manual, admin-side decision.
never voids a shared monthly charge, never refunds, and never rebills.
Cancelling a lesson that was **already paid** credits the student one lesson's share
of what they paid (`PaymentService::creditForCancelledLesson`), and the next scan
applies that credit against their due charges before emailing the notice
(`PaymentService::applyCredits`). See `credits.md` for the full model.
## Implementation
- Runner: `Unsupervised\Schedular\Payment\ScheduledBillingRunner`
+6 -1
View File
@@ -33,6 +33,10 @@ No new tables. The views are composed from existing data:
and when it was accepted.
- **Intake answers** — every registration-question answer, newest first:
question label, answer, and the registration it was given for.
- **Account credit** (`manage_billing` only) — the student's available credit
balance plus every credit (date, reason, amount, remaining, status). Credit
comes from cancelled paid lessons and is applied automatically to upcoming
scheduled billing. See `credits.md`.
- **Payment history** (`manage_billing` only) — every payment, newest first:
date, context, method, status, subtotal, HST, total, and receipt number.
@@ -44,7 +48,8 @@ All actions are nonce-protected POSTs handled on the detail page:
- **Cancel lesson** — on any non-cancelled upcoming lesson. Uses the same path
as student-initiated cancellation: the lesson is marked `cancelled`, the
availability slot is freed for rebooking, and a still-pending payment is
voided. Paid lessons keep their payment — refunds stay a manual decision (#72).
voided. A paid lesson is credited back to the student's account (see
`credits.md`) rather than refunded.
- **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing
its capacity seat), with the same pending-payment voiding.