Apply account credit on same-month rebook, surface credit on student page
CI / Coding Standards (pull_request) Successful in 27s
CI / Tests (PHP 8.2) (pull_request) Successful in 28s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.5) (pull_request) Successful in 35s
CI / Tests (PHP 8.1) (pull_request) Successful in 37s
CI / Tests (PHP 8.3) (pull_request) Successful in 43s
CI / Static Analysis (pull_request) Successful in 51s
CI / Build Plugin Zip (pull_request) Skipped

A monthly/weekly lesson booked into a month whose billing date has
already passed is charged at booking time by LessonBooker::settle,
bypassing the daily scan where account credit is otherwise applied. So
rebooking a cancelled paid lesson within the same month charged the
family in full while their cancellation credit sat unused — billed twice
for the same slot.

Apply the payer's credit to that charge-at-booking payment for scheduled
offerings, mirroring the daily scan: a payment fully covered by credit
settles and confirms its lesson. Add PaymentService::findPayment so
settle can re-read the row after applyCredits writes to it.

Also surface the student's total account credit at the top of their
detail page when they hold a balance, so the studio sees it at a glance.

Co-authored-by: anthropic/claude-opus-4-8
This commit is contained in:
2026-09-17 15:39:53 -03:00
co-authored by anthropic/claude-opus-4-8
parent d8d842b1ef
commit e7de627752
4 changed files with 101 additions and 1 deletions
+16 -1
View File
@@ -184,6 +184,7 @@ class LessonBooker {
? $offering->price
: $offering->price * count( $ids );
$payerId = $this->guardians->payerFor( $studentId );
$payment = $this->payments->createForRegistration(
Payment::REG_LESSON,
$anchorId,
@@ -192,9 +193,23 @@ class LessonBooker {
$amount,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
payerId: $payerId
);
// A scheduled lesson charged at booking — the extra lesson added to (or a
// cancelled one rebooked within) an already-billed month — skips the daily
// scan, which is the only place credit is otherwise applied. Apply the
// payer's account credit here too, so a cancellation credit settles the
// rebooking instead of the family being charged twice for the same slot. A
// pending payment fully covered by credit is settled and its lesson
// confirmed; applyCredits is a no-op when the payer holds no balance. The
// one-time pay-now flow is left alone: its payment step already fronts the
// student, and credit there is a manual studio decision.
if ( $offering->isScheduledBilling() && null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
$this->payments->applyCredits( $payerId, [ $payment ] );
$payment = $this->payments->findPayment( (int) $payment->id ) ?? $payment;
}
return [
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
'payment' => $payment,
+9
View File
@@ -116,6 +116,15 @@ class PaymentService {
return $this->payments->markNoticed( $paymentId );
}
/**
* Re-read a payment from the ledger — the caller's way to pick up a status or
* credit change {@see applyCredits} wrote straight to the row, since the
* Payment object it holds is immutable. Delegates to the ledger.
*/
public function findPayment( int $paymentId ): ?Payment {
return $this->payments->findById( $paymentId );
}
/**
* Studio-admin confirmation that a pending payment (e-transfer) was received.
* Marks it paid, confirms the registration, and emails the receipt.