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);