From ff059909a51cef592538d9294031b820e96f5488 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 22 Jul 2026 10:19:53 -0300 Subject: [PATCH] Charge weekly reservations for every claimed occurrence and confirm the whole series A weekly booking on a per-lesson (one_time) priced offering was creating its single upfront payment for one week's price while reserving up to 12 weeks, and settling that payment confirmed only the anchor lesson, leaving the rest of the series pending forever. - BookingEndpoint now charges price x claimed occurrences for one_time billing; a full_term price is still charged once since it covers the term. - PaymentService::confirmRegistration resolves the anchor lesson's series and confirms every non-cancelled row via the new BookingRepository::updateStatusForSeries(). Closes #79 Co-Authored-By: Claude Fable 5 --- docs/features/lesson-booking.md | 9 ++- docs/features/payments.md | 4 +- src/Booking/BookingEndpoint.php | 9 ++- src/Booking/BookingRepository.php | 20 ++++++ src/Payment/PaymentService.php | 16 ++++- tests/Unit/Booking/BookingEndpointTest.php | 70 ++++++++++++++++++++ tests/Unit/Booking/BookingRepositoryTest.php | 23 +++++++ tests/Unit/Payment/PaymentServiceTest.php | 20 ++++++ 8 files changed, 164 insertions(+), 7 deletions(-) diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index fd15845..80922de 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -44,8 +44,13 @@ slot_taken` if the freed time was booked by someone else in the meantime. ## Weekly Reservations A weekly reservation creates one `series_id` shared across N lesson rows (one per week in the term) and reserves the matching availability windows. It is billed -**full-term upfront** as a single payment (`billing_mode = full_term` on the -offering). +**upfront as a single payment** linked to the series' first (anchor) lesson: +- `billing_mode = full_term` — the offering's price already covers the term and is charged once. +- `billing_mode = one_time` — the per-lesson price is charged **once per occurrence actually claimed** (price × N). + +Settling that payment (Stripe webhook, e-transfer confirmation, comp) confirms +**every non-cancelled lesson in the series** +(`BookingRepository::updateStatusForSeries()`), not just the anchor row. ## REST API | Method | Endpoint | Permission | diff --git a/docs/features/payments.md b/docs/features/payments.md index 90aca91..480cb22 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -6,7 +6,9 @@ falls back to **e-transfer** — a pending payment a studio admin marks received so everything works without any credentials. When Stripe **is** configured the default rail becomes the **credit card**. The studio admin can override any student's method (card / e-transfer / comp). Single bookings are charged once; -weekly reservations and group classes are charged the full term upfront. A +weekly reservations and group classes are charged the full term upfront (a +`full_term` price once, or a per-lesson `one_time` price × the occurrences +reserved — see `lesson-booking.md`). A numbered receipt is emailed automatically when a payment is marked paid. > **Implemented:** the payment ledger, studio settings, method resolution diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 92670ea..9518040 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -249,7 +249,14 @@ class BookingEndpoint { $status = Lesson::STATUS_PENDING; if ( $offering->price > 0.0 ) { - $payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); + // A full-term price already covers the whole reservation; a per-lesson + // (one_time) price is owed once per occurrence actually claimed, so a + // weekly reservation cannot hold a term while paying for one week. + $amount = Offering::BILLING_FULL_TERM === $offering->billingMode + ? $offering->price + : $offering->price * count( $ids ); + + $payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $amount, $offering->currency, $offering->etransferEmail ); if ( null !== $payment && $payment->isPaid() ) { $status = Lesson::STATUS_CONFIRMED; diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index d5b9ea7..dd5df72 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -214,6 +214,26 @@ class BookingRepository { ); } + /** + * Update every non-cancelled lesson in a weekly series at once — e.g. + * confirming the whole reservation when its single upfront payment settles. + */ + public function updateStatusForSeries( int $seriesId, string $status ): bool { + if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) { + return false; + } + + $sql = $this->db->prepare( + 'UPDATE %i SET status = %s WHERE series_id = %d AND status != %s', + $this->table, + $status, + $seriesId, + Lesson::STATUS_CANCELLED + ); + + return null !== $sql && false !== $this->db->query( $sql ); + } + public function updateStatus( int $id, string $status ): bool { if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) { return false; diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index d0ac40a..d7beb18 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -195,10 +195,20 @@ class PaymentService { } private function confirmRegistration( string $type, int $registrationId ): void { - if ( Payment::REG_LESSON === $type ) { - $this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED ); + if ( Payment::REG_LESSON !== $type ) { + // Group enrolments are already `active`; no status change on payment. + return; } - // Group enrolments are already `active`; no status change on payment. + + // A weekly reservation's payment is linked to its anchor lesson but pays + // for the whole series, so settling it confirms every lesson in the series. + $lesson = $this->bookings->findById( $registrationId ); + if ( null !== $lesson && null !== $lesson->seriesId ) { + $this->bookings->updateStatusForSeries( $lesson->seriesId, Lesson::STATUS_CONFIRMED ); + return; + } + + $this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED ); } private function linkPayment( string $type, int $registrationId, int $paymentId ): void { diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index c9ac497..c87f25c 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -304,6 +304,76 @@ class BookingEndpointTest extends TestCase self::assertSame(Payment::METHOD_COMP, $result->get_data()['payment']['method']); } + public function testWeeklyBookingChargesPerLessonPriceTimesClaimedOccurrences(): void + { + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null, false, 7)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, allowWeekly: true, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + + $this->availability->shouldReceive('findUnbookedInGroup')->with(7)->andReturn([ + $this->slot(10, 3, null, false, 7), + $this->slot(11, 3, null, false, 7), + $this->slot(12, 3, null, false, 7), + ]); + $this->availability->shouldReceive('claim')->times(3)->andReturn(true); + $this->bookings->shouldReceive('insertSeries')->once()->andReturn([77, 78, 79]); + $this->gate->shouldReceive('record')->once(); + + // Three claimed occurrences at a per-lesson (one_time) price of 50 → 150. + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null) + ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 150.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12)); + $this->bookings->shouldNotReceive('updateStatus'); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame([77, 78, 79], $result->get_data()['ids']); + self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']); + } + + public function testWeeklyBookingChargesFullTermPriceOnce(): void + { + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null, false, 7)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering( + instructorId: 3, + kind: Offering::KIND_PRIVATE_LESSON, + title: 'Term', + price: 400.0, + billingMode: Offering::BILLING_FULL_TERM, + allowWeekly: true, + id: 8 + ) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + + $this->availability->shouldReceive('findUnbookedInGroup')->with(7)->andReturn([ + $this->slot(10, 3, null, false, 7), + $this->slot(11, 3, null, false, 7), + ]); + $this->availability->shouldReceive('claim')->times(2)->andReturn(true); + $this->bookings->shouldReceive('insertSeries')->once()->andReturn([77, 78]); + $this->gate->shouldReceive('record')->once(); + + // A full_term price already covers the whole reservation. + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null) + ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 400.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12)); + $this->bookings->shouldNotReceive('updateStatus'); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']); + } + public function testCancelByOwnerCancelsReleasesSlotAndVoidsPayment(): void { $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77); diff --git a/tests/Unit/Booking/BookingRepositoryTest.php b/tests/Unit/Booking/BookingRepositoryTest.php index 98242cf..3282953 100644 --- a/tests/Unit/Booking/BookingRepositoryTest.php +++ b/tests/Unit/Booking/BookingRepositoryTest.php @@ -132,6 +132,29 @@ class BookingRepositoryTest extends TestCase self::assertFalse($this->repo->updateStatus(1, Lesson::STATUS_CONFIRMED)); } + public function testUpdateStatusForSeriesReturnsFalseForInvalidStatus(): void + { + self::assertFalse($this->repo->updateStatusForSeries(12, 'invalid')); + } + + public function testUpdateStatusForSeriesUpdatesNonCancelledRows(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::on(static fn (string $sql): bool => + str_contains($sql, 'series_id = %d') && str_contains($sql, 'status != %s')), + 'wp_us_lessons', + Lesson::STATUS_CONFIRMED, + 12, + Lesson::STATUS_CANCELLED + ) + ->andReturn('UPDATE ...'); + $this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(3); + + self::assertTrue($this->repo->updateStatusForSeries(12, Lesson::STATUS_CONFIRMED)); + } + public function testFindUpcomingForStudentJoinsSlotAndExcludesCancelled(): void { Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php index 2f619a1..7bc865a 100644 --- a/tests/Unit/Payment/PaymentServiceTest.php +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -41,6 +41,9 @@ class PaymentServiceTest extends TestCase $this->stripe = Mockery::mock(StripeGateway::class); $this->settings->shouldReceive('etransferEmail')->andReturn(''); $this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault(); + // Confirming a lesson looks it up to detect a weekly series; single + // lessons (or a lookup miss) fall back to the per-lesson update. + $this->bookings->shouldReceive('findById')->andReturn(null)->byDefault(); $this->service = new PaymentService( $this->payments, @@ -180,6 +183,23 @@ class PaymentServiceTest extends TestCase self::assertTrue($this->service->markPaid(70)); } + public function testMarkPaidConfirmsEveryLessonInAWeeklySeries(): void + { + $this->payments->shouldReceive('findById')->with(70)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 70)); + $this->payments->shouldReceive('markPaid')->once()->with(70, 'USC-70')->andReturn(true); + + // The anchor lesson (registration_id 12) belongs to series 12: the whole + // series is confirmed, not just the anchor row. + $this->bookings->shouldReceive('findById')->with(12)->andReturn( + new Lesson(slotId: 10, studentId: 5, instructorId: 3, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: 12, id: 12) + ); + $this->bookings->shouldReceive('updateStatusForSeries')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true); + $this->bookings->shouldNotReceive('updateStatus'); + $this->mailer->shouldReceive('send')->andReturn(false); + + self::assertTrue($this->service->markPaid(70)); + } + public function testMarkPaidReturnsFalseWhenMissing(): void { $this->payments->shouldReceive('findById')->with(99)->andReturn(null); -- 2.54.0