A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
7.7 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)
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.