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
+25 -10
View File
@@ -17,9 +17,10 @@ class PaymentDueMailer {
* on a lump-sum e-transfer so the studio can reconcile it to these payments.
*
* @param list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}> $items
* @param float $creditApplied Account credit deducted from the total this notice covers.
* @return bool False when there is no recipient or nothing to bill.
*/
public function send( \WP_User $student, array $items, string $reference = '' ): bool {
public function send( \WP_User $student, array $items, string $reference = '', float $creditApplied = 0.0 ): bool {
if ( '' === (string) $student->user_email || [] === $items ) {
return false;
}
@@ -48,16 +49,30 @@ class PaymentDueMailer {
}
}
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
. implode( "\n", $lines ) . "\n\n"
. sprintf(
/* translators: 1: currency, 2: total amount */
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $total, 2 )
);
// Account credit (from an earlier cancelled paid lesson) offsets the total.
$creditApplied = round( min( $creditApplied, $total ), 2 );
$dueTotal = round( $total - $creditApplied, 2 );
if ( [] !== $emails ) {
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
. implode( "\n", $lines );
if ( $creditApplied > 0.0 ) {
$body .= "\n\n" . sprintf(
/* translators: 1: currency, 2: credit amount */
__( 'Account credit applied: -%1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $creditApplied, 2 )
);
}
$body .= "\n\n" . sprintf(
/* translators: 1: currency, 2: total amount */
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $dueTotal, 2 )
);
if ( $dueTotal > 0.0 && [] !== $emails ) {
$body .= "\n\n" . sprintf(
/* translators: %s: e-transfer destination email address(es) */
__( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ),