Merge pull request 'Apply account credit on same-month rebook; show total credit on student page' (#202) from feature/student-credit-display-and-rebook-fix into main
CI / Coding Standards (push) Successful in 28s
CI / Tests (PHP 8.2) (push) Successful in 31s
CI / Tests (PHP 8.5) (push) Successful in 32s
CI / No Debug Code (push) Successful in 9s
CI / Static Analysis (push) Successful in 37s
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.3) (push) Successful in 44s
CI / Build Plugin Zip (push) Successful in 13s

Reviewed-on: #202
Reviewed-by: James Griffin-Allwood <[email protected]>
This commit was merged in pull request #202.
This commit is contained in:
2026-09-17 18:50:43 +00:00
6 changed files with 103 additions and 1 deletions
+4
View File
@@ -13,7 +13,11 @@ each change under the current top section as you work.
## [1.5.8] ## [1.5.8]
### Added
- **A student's account credit balance now shows at the top of their detail page.** Credit from a cancelled paid lesson was already recorded and listed further down the page, but you had to scroll to the Account credit section to find out a student was owed anything. When there is a balance to report it now appears up top the moment you open the page, so you can see at a glance that this student's future billing will be offset — and, for a child, that the balance sits on their guardian's account. The full breakdown of where the credit came from stays where it was.
### Fixed ### Fixed
- **Rebooking a cancelled paid lesson in the same month no longer charges the family twice.** Cancelling a paid lesson credits the account for it, and that credit is meant to cover the next lesson booked in its place. But a lesson booked back into a month already billed is charged there and then, and that charge skipped the step where credit is applied — so the family was billed in full for the replacement while the credit for the cancelled lesson sat unused, in effect paying twice for the one slot. Account credit is now applied to a charge raised at booking, so the credit settles the rebooking the same way it settles a scheduled charge; a lesson fully covered by credit is confirmed with nothing left to pay.
- **A student is no longer emailed the same "Payment due" notice twice.** The daily billing scan runs whenever the site gets traffic, and on a busy day two copies of it could end up running at the same time. Neither knew about the other, so each would send its own notice for the same charge — one payment on the books, but the family saw two identical requests to pay and reasonably read it as being billed twice. Each payment is now stamped the moment its notice goes out, and a second run that reaches the same payment sees the stamp and stays quiet, so exactly one notice is sent no matter how the scan is triggered. Payments already noticed before this update are marked as such on upgrade, so nobody gets a fresh round of reminders for charges they were already told about. - **A student is no longer emailed the same "Payment due" notice twice.** The daily billing scan runs whenever the site gets traffic, and on a busy day two copies of it could end up running at the same time. Neither knew about the other, so each would send its own notice for the same charge — one payment on the books, but the family saw two identical requests to pay and reasonably read it as being billed twice. Each payment is now stamped the moment its notice goes out, and a second run that reaches the same payment sees the stamp and stays quiet, so exactly one notice is sent no matter how the scan is triggered. Payments already noticed before this update are marked as such on upgrade, so nobody gets a fresh round of reminders for charges they were already told about.
- **Switching a group class to monthly billing no longer charges students who already paid up front a second time.** When a class was set up to be paid once at sign-up and later changed to bill monthly, the daily scan did not recognise the payment already taken at enrolment — it carried no billing month — and raised a fresh charge for the current month on top of it. Families who had already paid were billed again, sometimes for a month they had covered. Changing a class to monthly now marks each enrolled student's up-front payment as covering the current month, so the scan bills them from the following month on and never doubles up on the month already paid. (Enrolments made after the switch, and classes that were always monthly, were never affected.) - **Switching a group class to monthly billing no longer charges students who already paid up front a second time.** When a class was set up to be paid once at sign-up and later changed to bill monthly, the daily scan did not recognise the payment already taken at enrolment — it carried no billing month — and raised a fresh charge for the current month on top of it. Families who had already paid were billed again, sometimes for a month they had covered. Changing a class to monthly now marks each enrolled student's up-front payment as covering the current month, so the scan bills them from the following month on and never doubles up on the month already paid. (Enrolments made after the switch, and classes that were always monthly, were never affected.)
+8 -1
View File
@@ -184,6 +184,7 @@ class LessonBooker {
? $offering->price ? $offering->price
: $offering->price * count( $ids ); : $offering->price * count( $ids );
$payerId = $this->guardians->payerFor( $studentId );
$payment = $this->payments->createForRegistration( $payment = $this->payments->createForRegistration(
Payment::REG_LESSON, Payment::REG_LESSON,
$anchorId, $anchorId,
@@ -192,9 +193,15 @@ class LessonBooker {
$amount, $amount,
$offering->currency, $offering->currency,
$offering->etransferEmail, $offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId ) payerId: $payerId
); );
// Apply any available credit to a charge raised at booking.
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
$this->payments->applyCredits( $payerId, [ $payment ] );
$payment = $this->payments->findPayment( (int) $payment->id ) ?? $payment;
}
return [ return [
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING, 'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
'payment' => $payment, 'payment' => $payment,
+9
View File
@@ -116,6 +116,15 @@ class PaymentService {
return $this->payments->markNoticed( $paymentId ); 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. * Studio-admin confirmation that a pending payment (e-transfer) was received.
* Marks it paid, confirms the registration, and emails the receipt. * Marks it paid, confirms the registration, and emails the receipt.
+33
View File
@@ -114,6 +114,39 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div> <div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
<?php endif; ?> <?php endif; ?>
<?php
/*
* Surface the credit balance up top the moment there is one, so the studio
* sees at a glance that this student is owed against future billing without
* scrolling to the Account credit section. Only shown when there is credit to
* report a zero balance is not news. The full breakdown stays below.
*/
?>
<?php if ($canBilling && $creditBalance > 0) : ?>
<div class="notice notice-info inline">
<p>
<?php
printf(
/* translators: %s: total available credit, e.g. "45.00 CAD" */
esc_html__('Total account credit: %s', 'unsupervised-schedular'),
'<strong>' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '</strong>'
);
?>
<?php if ($payer['id'] !== (int) $student->ID) : ?>
<span class="description">
<?php
printf(
/* translators: %s: name of the parent/guardian whose account holds the balance. */
esc_html__('Held on %ss account.', 'unsupervised-schedular'),
esc_html($payer['name'])
);
?>
</span>
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?> <?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
+6
View File
@@ -48,6 +48,12 @@ class AdminBookingTest extends TestCase
$this->bookings = Mockery::mock(BookingRepository::class); $this->bookings = Mockery::mock(BookingRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class);
$this->payments = Mockery::mock(PaymentService::class); $this->payments = Mockery::mock(PaymentService::class);
// A charge raised at booking has the payer's credit applied before it
// settles. The default holds no balance and re-reads the same payment.
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
$this->payments->shouldReceive('findPayment')->andReturnUsing(
static fn (int $id): ?Payment => null
)->byDefault();
$this->guardians = Mockery::mock(GuardianService::class); $this->guardians = Mockery::mock(GuardianService::class);
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault(); $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
@@ -57,6 +57,13 @@ class BookingEndpointTest extends TestCase
// Crediting a cancelled paid lesson is exercised in dedicated tests; other // Crediting a cancelled paid lesson is exercised in dedicated tests; other
// cancellation paths simply allow the call. // cancellation paths simply allow the call.
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault(); $this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
// A charge raised at booking applies the payer's credit before it settles.
// The default holds no balance and re-reads the same payment; the
// same-month-rebook test overrides both.
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
$this->payments->shouldReceive('findPayment')->andReturnUsing(
static fn (int $id): ?Payment => null
)->byDefault();
$this->guardians = Mockery::mock(GuardianService::class); $this->guardians = Mockery::mock(GuardianService::class);
// The default account books only for itself: no guardian link anywhere. // The default account books only for itself: no guardian link anywhere.
@@ -463,6 +470,42 @@ class BookingEndpointTest extends TestCase
self::assertNotNull($result->get_data()['payment']); self::assertNotNull($result->get_data()['payment']);
} }
public function testMonthlyRebookInBilledMonthAppliesAccountCredit(): void
{
// Rebooking a cancelled monthly lesson inside an already-billed month is
// charged at booking. The payer holds a cancellation credit that must be
// applied to that charge — otherwise the family is billed twice for the same
// slot. A credit that fully covers it settles the payment and confirms the
// lesson, so the front end runs no payment step.
$this->availability->shouldReceive('findById')->with(10)->andReturn(
new AvailabilitySlot(instructorId: 3, startDt: '2026-06-20 10:00:00', endDt: '2026-06-20 11:00:00', offeringId: null, id: 10)
);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
);
$this->gate->shouldReceive('validate')->andReturn(null);
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
$this->gate->shouldReceive('record')->once();
$pending = new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12);
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null, null, null, 5)
->andReturn($pending);
// Credit is applied against the fresh charge for the same payer.
$this->payments->shouldReceive('applyCredits')->once()->with(5, [$pending])->andReturn([12 => 45.0]);
// applyCredits settled the row; the re-read reflects it as paid.
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, creditApplied: 45.0, id: 12);
$this->payments->shouldReceive('findPayment')->with(12)->andReturn($paid);
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
}
public function testMonthlyLessonBeforeBillingDateDefersPayment(): void public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
{ {
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st, // "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,