Charge weekly reservations for every claimed occurrence and confirm the whole series #81

Merged
thatguygriff merged 1 commits from fix/recurring-payment-amount into main 2026-07-22 13:26:16 +00:00
8 changed files with 164 additions and 7 deletions
+7 -2
View File
@@ -44,8 +44,13 @@ slot_taken` if the freed time was booked by someone else in the meantime.
## Weekly Reservations ## Weekly Reservations
A weekly reservation creates one `series_id` shared across N lesson rows (one per 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 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 **upfront as a single payment** linked to the series' first (anchor) lesson:
offering). - `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 ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
+3 -1
View File
@@ -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 so everything works without any credentials. When Stripe **is** configured the
default rail becomes the **credit card**. The studio admin can override any default rail becomes the **credit card**. The studio admin can override any
student's method (card / e-transfer / comp). Single bookings are charged once; 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. numbered receipt is emailed automatically when a payment is marked paid.
> **Implemented:** the payment ledger, studio settings, method resolution > **Implemented:** the payment ledger, studio settings, method resolution
+8 -1
View File
@@ -249,7 +249,14 @@ class BookingEndpoint {
$status = Lesson::STATUS_PENDING; $status = Lesson::STATUS_PENDING;
if ( $offering->price > 0.0 ) { 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() ) { if ( null !== $payment && $payment->isPaid() ) {
$status = Lesson::STATUS_CONFIRMED; $status = Lesson::STATUS_CONFIRMED;
+20
View File
@@ -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 { public function updateStatus( int $id, string $status ): bool {
if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) { if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) {
return false; return false;
+13 -3
View File
@@ -195,10 +195,20 @@ class PaymentService {
} }
private function confirmRegistration( string $type, int $registrationId ): void { private function confirmRegistration( string $type, int $registrationId ): void {
if ( Payment::REG_LESSON === $type ) { if ( Payment::REG_LESSON !== $type ) {
$this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED );
}
// Group enrolments are already `active`; no status change on payment. // 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 { 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']); 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 public function testCancelByOwnerCancelsReleasesSlotAndVoidsPayment(): void
{ {
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77); $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)); 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 public function testFindUpcomingForStudentJoinsSlotAndExcludesCancelled(): void
{ {
Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
+20
View File
@@ -41,6 +41,9 @@ class PaymentServiceTest extends TestCase
$this->stripe = Mockery::mock(StripeGateway::class); $this->stripe = Mockery::mock(StripeGateway::class);
$this->settings->shouldReceive('etransferEmail')->andReturn(''); $this->settings->shouldReceive('etransferEmail')->andReturn('');
$this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault(); $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->service = new PaymentService(
$this->payments, $this->payments,
@@ -180,6 +183,23 @@ class PaymentServiceTest extends TestCase
self::assertTrue($this->service->markPaid(70)); 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 public function testMarkPaidReturnsFalseWhenMissing(): void
{ {
$this->payments->shouldReceive('findById')->with(99)->andReturn(null); $this->payments->shouldReceive('findById')->with(99)->andReturn(null);