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]>
124 lines
7.2 KiB
Markdown
124 lines
7.2 KiB
Markdown
# 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`)
|