Files
unsupervised-scheduler/docs/features/credits.md
T
thatguygriffandClaude Opus 4.8 e8e66eef3c
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m52s
Credit students for cancelled paid lessons
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]>
2026-07-24 15:32:20 -03:00

7.2 KiB
Raw 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)