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]>
133 lines
7.7 KiB
Markdown
133 lines
7.7 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`)
|
||
|
||
## 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`.
|