Files
thatguygriffandClaude Opus 5 b772e1811e Let parents register once and book for their children
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]>
2026-07-29 16:07:52 -03:00

7.7 KiB
Raw Permalink Blame History

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 onceCreditRepository::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.