Charge weekly reservations for every claimed occurrence and confirm the whole series
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
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 <[email protected]>
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user