Credit students for cancelled paid lessons
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

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]>
This commit is contained in:
2026-07-24 15:32:20 -03:00
co-authored by Claude Opus 4.8
parent 3f9aef7746
commit e8e66eef3c
30 changed files with 1210 additions and 80 deletions
+70 -53
View File
@@ -44,16 +44,16 @@ class ScheduledBillingRunner {
// One notice bucket per student, filled as pending payments are created and
// flushed to a single email at the end, so a student billed for several
// lessons on one day is emailed once — never once per lesson. $batchIds
// tracks the payment ids behind each student's bucket so they can be tagged
// with a shared reference for lump-sum e-transfer reconciliation.
$buckets = [];
$batchIds = [];
// lessons on one day is emailed once — never once per lesson. Each entry keeps
// the created payment and its label; credits are applied across the whole
// bucket before the notice is built, so a student's account credit offsets the
// run's charges oldest-first.
$buckets = [];
$this->billPrivateLessons( $now, $buckets, $batchIds );
$this->billGroupEnrollments( $now, $buckets, $batchIds );
$this->billPrivateLessons( $now, $buckets );
$this->billGroupEnrollments( $now, $buckets );
$this->sendNotices( $buckets, $batchIds );
$this->sendNotices( $buckets );
}
/**
@@ -61,10 +61,9 @@ class ScheduledBillingRunner {
* are within 24 hours; monthly lessons are grouped per calendar month and billed
* one payment for the month once its 1st has arrived.
*
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void {
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets ): void {
$today = $now->format( 'Y-m-d' );
$monthly = [];
@@ -109,7 +108,6 @@ class ScheduledBillingRunner {
$this->bill(
$buckets,
$batchIds,
Payment::REG_LESSON,
$lessonId,
$studentId,
@@ -123,7 +121,7 @@ class ScheduledBillingRunner {
);
}
$this->billMonthlyLessonGroups( $today, $monthly, $buckets, $batchIds );
$this->billMonthlyLessonGroups( $today, $monthly, $buckets );
}
/**
@@ -132,10 +130,9 @@ class ScheduledBillingRunner {
* lesson in the group; the rest are pointed at it so they are not re-billed.
*
* @param array<string, list<array{lesson_id: int, student_id: int, instructor_id: int, currency: string, etransfer: ?string, title: string, price: float, start: \DateTimeImmutable}>> $monthly
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets, array &$batchIds ): void {
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets ): void {
foreach ( $monthly as $group ) {
$first = $group[0]['start'];
$monthStart = $first->format( 'Y-m-01' );
@@ -151,7 +148,6 @@ class ScheduledBillingRunner {
$payment = $this->bill(
$buckets,
$batchIds,
Payment::REG_LESSON,
$anchorId,
$group[0]['student_id'],
@@ -188,10 +184,9 @@ class ScheduledBillingRunner {
* per month (on the 1st) for that month's sessions. Dedup is by `period_key`
* since a single enrolment maps to many periodic charges.
*
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void {
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets ): void {
$today = $now->format( 'Y-m-d' );
$offerings = [];
@@ -211,9 +206,9 @@ class ScheduledBillingRunner {
}
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
$this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets, $batchIds );
$this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets );
} else {
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets, $batchIds );
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets );
}
}
}
@@ -222,10 +217,9 @@ class ScheduledBillingRunner {
* Bill one payment per group-class session that is now within 24 hours.
*
* @param list<array{start: string, end: string}> $windows
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void {
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void {
foreach ( $windows as $window ) {
$start = new \DateTimeImmutable( $window['start'] );
$due = $start->modify( '-1 day' );
@@ -240,7 +234,6 @@ class ScheduledBillingRunner {
$this->bill(
$buckets,
$batchIds,
Payment::REG_ENROLLMENT,
(int) $enrollment->id,
$enrollment->studentId,
@@ -259,10 +252,9 @@ class ScheduledBillingRunner {
* Bill one payment per calendar month of a group class, once its 1st arrives.
*
* @param list<array{start: string, end: string}> $windows
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
// Count this enrolment's sessions per calendar month.
$months = [];
foreach ( $windows as $window ) {
@@ -282,7 +274,6 @@ class ScheduledBillingRunner {
$this->bill(
$buckets,
$batchIds,
Payment::REG_ENROLLMENT,
(int) $enrollment->id,
$enrollment->studentId,
@@ -305,46 +296,72 @@ class ScheduledBillingRunner {
/**
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
* add an itemised line to the student's notice bucket and record its payment id
* for the shared notice batch. Returns the created payment, or null when there
* was nothing to charge.
* add it to the student's notice bucket with the label to show on the notice.
* Credits are applied later, once the whole bucket is known. Returns the created
* payment, or null when there was nothing to charge.
*
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function bill( array &$buckets, array &$batchIds, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
private function bill( array &$buckets, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey );
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
$buckets[ $studentId ][] = [
'label' => $label,
'amount' => $payment->total(),
'currency' => $payment->currency,
'due_date' => $payment->dueDate,
'etransfer_email' => $payment->etransferEmail,
$buckets[ $studentId ][] = [
'payment' => $payment,
'label' => $label,
];
$batchIds[ $studentId ][] = $payment->id;
}
return $payment;
}
/**
* Tag each student's payments with a shared batch reference and email them one
* itemised notice quoting it, so a lump-sum e-transfer can be reconciled to the
* exact pending payments it covers.
* For each student, apply any account credit they hold against the run's charges,
* tag the payments they still owe with a shared batch reference, and email them
* one itemised notice. The notice lists each charge at its full amount, then the
* credit applied and the reduced total due; a charge fully covered by credit is
* already settled and carries no reference. A lump-sum e-transfer for the balance
* reconciles to the reference.
*
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function sendNotices( array $buckets, array $batchIds ): void {
foreach ( $buckets as $studentId => $items ) {
$reference = $this->reference();
$this->payments->assignNoticeBatch( $batchIds[ $studentId ] ?? [], $reference );
private function sendNotices( array $buckets ): void {
foreach ( $buckets as $studentId => $entries ) {
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
$applied = $this->payments->applyCredits( $studentId, $payments );
$items = [];
$batchIds = [];
$creditTotal = 0.0;
foreach ( $entries as $entry ) {
$payment = $entry['payment'];
$id = (int) $payment->id;
$credited = $applied[ $id ] ?? 0.0;
$creditTotal += $credited;
$items[] = [
'label' => $entry['label'],
'amount' => $payment->total(),
'currency' => $payment->currency,
'due_date' => $payment->dueDate,
'etransfer_email' => $payment->etransferEmail,
];
// A charge still carrying a balance is what a lump-sum e-transfer covers;
// one fully settled by credit needs no reconciliation reference.
if ( round( $payment->total() - $credited, 2 ) > 0.0 ) {
$batchIds[] = $id;
}
}
$reference = [] !== $batchIds ? $this->reference() : '';
$this->payments->assignNoticeBatch( $batchIds, $reference );
$user = get_userdata( $studentId );
if ( $user instanceof \WP_User ) {
$this->mailer->send( $user, $items, $reference );
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
}
}
}