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
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:
@@ -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`)
|
||||
@@ -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 |
|
||||
|---------|---------------------------------------------|-----------------------------|
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user