From e7de62775281e551705fa4ab2a1f4f16a3ae4542 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Thu, 17 Sep 2026 15:39:53 -0300 Subject: [PATCH 1/6] Apply account credit on same-month rebook, surface credit on student page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Booking/LessonBooker.php | 17 ++++++++- src/Payment/PaymentService.php | 9 +++++ templates/admin/student-detail.php | 33 +++++++++++++++++ tests/Unit/Booking/BookingEndpointTest.php | 43 ++++++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/Booking/LessonBooker.php b/src/Booking/LessonBooker.php index 515ab93..95e1148 100644 --- a/src/Booking/LessonBooker.php +++ b/src/Booking/LessonBooker.php @@ -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, diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index 3121c09..adde3dd 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -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. diff --git a/templates/admin/student-detail.php b/templates/admin/student-detail.php index cfac62b..d7c316b 100644 --- a/templates/admin/student-detail.php +++ b/templates/admin/student-detail.php @@ -114,6 +114,39 @@ $renderLessons = static function (array $rows, bool $withActions = false): void

+ + 0) : ?> +
+

+ ' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '' + ); + ?> + ID) : ?> + + + + +

+
+ + add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>

diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index b11f6f2..1aeedb9 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -57,6 +57,13 @@ class BookingEndpointTest extends TestCase // Crediting a cancelled paid lesson is exercised in dedicated tests; other // cancellation paths simply allow the call. $this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault(); + // A charge-at-booking scheduled lesson (an add-on to an already-billed month) + // 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); // 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']); } + 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 { // "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st, From 67017409d7e10432170515d56cf9f62885742948 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Thu, 17 Sep 2026 15:42:38 -0300 Subject: [PATCH 2/6] Drop stale comment about not applying credit at booking This change applies credit at booking for scheduled rebooks, so the note saying the pay-now flow deliberately does not was contradictory. Co-authored-by: anthropic/claude-opus-4-8 --- src/Booking/LessonBooker.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Booking/LessonBooker.php b/src/Booking/LessonBooker.php index 95e1148..8a416fe 100644 --- a/src/Booking/LessonBooker.php +++ b/src/Booking/LessonBooker.php @@ -198,13 +198,11 @@ class LessonBooker { // 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. + // scan, so apply the payer's account credit here too. A cancellation credit + // then 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. 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; From 1758c253a1092d3614d77bc4640e9b0e9707e926 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Thu, 17 Sep 2026 15:43:57 -0300 Subject: [PATCH 3/6] Trim credit-at-booking comment to one line Co-authored-by: anthropic/claude-opus-4-8 --- src/Booking/LessonBooker.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Booking/LessonBooker.php b/src/Booking/LessonBooker.php index 8a416fe..f5e9590 100644 --- a/src/Booking/LessonBooker.php +++ b/src/Booking/LessonBooker.php @@ -196,13 +196,7 @@ class LessonBooker { 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, so apply the payer's account credit here too. A cancellation credit - // then 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. + // Apply any available credit while booking. 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; From e3ab7973d6dad36b703a9d00b8f54696e0356dc9 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Thu, 17 Sep 2026 15:44:41 -0300 Subject: [PATCH 4/6] Clarify credit-at-booking comment scopes to scheduled charges Co-authored-by: anthropic/claude-opus-4-8 --- src/Booking/LessonBooker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Booking/LessonBooker.php b/src/Booking/LessonBooker.php index f5e9590..585e6b3 100644 --- a/src/Booking/LessonBooker.php +++ b/src/Booking/LessonBooker.php @@ -196,7 +196,7 @@ class LessonBooker { payerId: $payerId ); - // Apply any available credit while booking. + // Apply any available credit to a scheduled charge raised at booking. 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; From 362980d0082b62afdc2a4f67e152a1e1d0d932f7 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Thu, 17 Sep 2026 15:46:11 -0300 Subject: [PATCH 5/6] Apply available credit to any at-booking charge, not just scheduled A payer's account credit should offset any charge raised at booking, so drop the scheduled-only guard. Covers the one-time pay-now flow too. Co-authored-by: anthropic/claude-opus-4-8 --- src/Booking/LessonBooker.php | 4 ++-- tests/Unit/Booking/AdminBookingTest.php | 6 ++++++ tests/Unit/Booking/BookingEndpointTest.php | 6 +++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Booking/LessonBooker.php b/src/Booking/LessonBooker.php index 585e6b3..649119e 100644 --- a/src/Booking/LessonBooker.php +++ b/src/Booking/LessonBooker.php @@ -196,8 +196,8 @@ class LessonBooker { payerId: $payerId ); - // Apply any available credit to a scheduled charge raised at booking. - if ( $offering->isScheduledBilling() && null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) { + // 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; } diff --git a/tests/Unit/Booking/AdminBookingTest.php b/tests/Unit/Booking/AdminBookingTest.php index 0a4a201..2bfb307 100644 --- a/tests/Unit/Booking/AdminBookingTest.php +++ b/tests/Unit/Booking/AdminBookingTest.php @@ -48,6 +48,12 @@ class AdminBookingTest extends TestCase $this->bookings = Mockery::mock(BookingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::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->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault(); diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index 1aeedb9..9001257 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -57,9 +57,9 @@ class BookingEndpointTest extends TestCase // Crediting a cancelled paid lesson is exercised in dedicated tests; other // cancellation paths simply allow the call. $this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault(); - // A charge-at-booking scheduled lesson (an add-on to an already-billed month) - // 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. + // 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 From 1a166eb4e3ff4209aaadced5177559320209dc71 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Thu, 17 Sep 2026 15:49:16 -0300 Subject: [PATCH 6/6] Add changelog entries for the credit fix and student-page display Recorded under the current 1.5.8 section; no version bump. Co-authored-by: anthropic/claude-opus-4-8 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9822653..6ebce3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,11 @@ each change under the current top section as you work. ## [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 +- **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. - **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.)