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]>
7.2 KiB
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::existsForLessonblocks a second credit if the same lesson is cancelled again after being reinstated. - A non-anchor lesson in a series (no
payment_idof 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_appliedis 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
pendingat 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 creditconsumed.
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_creditsandus_payments.credit_appliedship as part of the (as-yet-unreleased) 1.2.0 — the same release as scheduled billing — soInstaller/dbDeltacreate them when a pre-1.2.0 site upgrades. If you are on a 1.2.0 dev build that predates this feature, the storedus_schedular_versionalready matchesUSC_VERSION, soPlugin::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 fromBooking\BookingEndpoint::canceland::updateStatus) - Apply at billing:
PaymentService::applyCredits, driven byPayment\ScheduledBillingRunner::sendNotices - Net due:
Payment::netDue(),PaymentRepository::addCreditApplied - Lesson counts:
Booking\BookingRepository::countByPaymentId/countBySeries - Admin view:
Auth\StudentHistory::creditBalance/::credits, rendered intemplates/admin/student-detail.php
Tests
tests/Unit/Payment/CreditRepositoryTest.phptests/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)