Charge weekly reservations for every claimed occurrence and confirm the whole series #81
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user