# 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`) ## Family Balances A credit records the student it was earned for (`student_id`) and the account that **holds** it (`payer_id`). Balance lookups — `availableBalance()`, `findAvailableByPayer()`, `consume()` — key on the payer, so a family shares one balance and a credit from one child's cancelled lesson can settle a sibling's next charge. A child's admin screen still lists the credits their own cancellations produced, labelled with whose account holds the balance. See `parent-guardian-accounts.md`.