From e7de62775281e551705fa4ab2a1f4f16a3ae4542 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Thu, 17 Sep 2026 15:39:53 -0300 Subject: [PATCH] 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,