Skip payment step for unpriced bookings, confirm them immediately, show students their lessons #54
@@ -34,6 +34,38 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.us-my-lessons {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.us-my-lesson {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.us-lesson-status {
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.us-lesson-status-confirmed {
|
||||
background: #e2f5e5;
|
||||
color: #1a7d2e;
|
||||
}
|
||||
|
||||
.us-lesson-status-pending {
|
||||
background: #fdf3d7;
|
||||
color: #8a6d1a;
|
||||
}
|
||||
|
||||
.us-view-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
+44
-2
@@ -6,6 +6,7 @@
|
||||
if (!app) return;
|
||||
|
||||
const slotList = document.getElementById('us-slot-list');
|
||||
const myLessons = document.getElementById('us-my-lessons');
|
||||
const confirm = document.getElementById('us-booking-confirmation');
|
||||
const errorBox = document.getElementById('us-booking-error');
|
||||
const { restUrl, nonce } = usScheduler;
|
||||
@@ -274,11 +275,51 @@
|
||||
accepted_policy_version_ids: accepted,
|
||||
}),
|
||||
})
|
||||
.then((res) => window.usPayment.collect('lesson', (res.ids || [])[0], slotList))
|
||||
.then((result) => showConfirmation(window.usPayment.message(result)))
|
||||
// A booking with nothing owed has no payment, so there is no payment
|
||||
// step to run — the booking is already confirmed server-side.
|
||||
.then((res) => (res.payment
|
||||
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
|
||||
: null))
|
||||
.then((result) => {
|
||||
loadMyLessons();
|
||||
showConfirmation(window.usPayment.message(result));
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function lessonStatusLabel(status) {
|
||||
if (status === 'pending') return 'Pending payment';
|
||||
if (status === 'confirmed') return 'Confirmed';
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
function renderMyLessons(lessons) {
|
||||
const upcoming = lessons.filter((l) => l.start_dt);
|
||||
if (!upcoming.length) {
|
||||
myLessons.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
myLessons.innerHTML = `
|
||||
<div class="us-my-lessons">
|
||||
<h3>Your upcoming lessons</h3>
|
||||
${upcoming.map((l) => `
|
||||
<div class="us-my-lesson">
|
||||
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function loadMyLessons() {
|
||||
if (!myLessons) return;
|
||||
// The lesson list is a bonus panel: never let it break slot browsing.
|
||||
apiFetch('bookings')
|
||||
.then(renderMyLessons)
|
||||
.catch(() => { myLessons.innerHTML = ''; });
|
||||
}
|
||||
|
||||
function showConfirmation(message) {
|
||||
confirm.textContent = message;
|
||||
slotList.style.display = 'none';
|
||||
@@ -289,6 +330,7 @@
|
||||
clearError();
|
||||
slotList.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
loadMyLessons();
|
||||
apiFetch('availability')
|
||||
.then((slots) => {
|
||||
allSlots = slots;
|
||||
|
||||
@@ -142,7 +142,11 @@
|
||||
accepted_policy_version_ids: accepted,
|
||||
}),
|
||||
})
|
||||
.then((res) => window.usPayment.collect('enrollment', res.id, list))
|
||||
// An enrolment with nothing owed has no payment, so there is no
|
||||
// payment step to run.
|
||||
.then((res) => (res.payment
|
||||
? window.usPayment.collect('enrollment', res.id, list)
|
||||
: null))
|
||||
.then((result) => showConfirmation(window.usPayment.message(result)))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ a class at capacity rejects further enrolments.
|
||||
| `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
|
||||
|
||||
`POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value),
|
||||
`accepted_policy_version_ids[]`, and payment data (see `payments.md`).
|
||||
`accepted_policy_version_ids[]`, and payment data (see `payments.md`). The
|
||||
response includes `id`, `status`, and `payment` — a `{id, method, status}`
|
||||
summary, or `null` when the class is free (the front end then skips the
|
||||
payment step).
|
||||
|
||||
`GET /enrollments` returns the caller's own enrolments, or all enrolments for the
|
||||
instructor's group classes if the caller has `view_own_lessons` on those offerings.
|
||||
|
||||
@@ -26,9 +26,10 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment.
|
||||
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (no offering, or a free offering) creates no payment and is `confirmed` immediately.
|
||||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed).
|
||||
|
||||
## Weekly Reservations
|
||||
A weekly reservation creates one `series_id` shared across N lesson rows (one per
|
||||
@@ -45,9 +46,13 @@ offering).
|
||||
|
||||
`POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]`
|
||||
(`question_id` → value), `accepted_policy_version_ids[]`, and payment data
|
||||
(see `payments.md`).
|
||||
(see `payments.md`). The response includes `ids`, the resulting lesson
|
||||
`status`, and `payment` — a `{id, method, status}` summary, or `null` when
|
||||
nothing is owed (the front end then skips the payment step).
|
||||
|
||||
`GET /bookings` returns the caller's own lessons (student view) or upcoming lessons for the instructor if the caller has `manage_availability`.
|
||||
`GET /bookings` returns the caller's upcoming, non-cancelled lessons (their own
|
||||
for students; the instructor's for callers with `manage_availability`), each
|
||||
with the slot's `start_dt`/`end_dt`.
|
||||
|
||||
Group classes follow the same registration flow but enrol against an offering of
|
||||
kind `group_class`; see `group-classes.md`.
|
||||
@@ -68,11 +73,12 @@ kind `group_class`; see `group-classes.md`.
|
||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||
|
||||
> **Payment seam:** payment is deferred to the Payments feature (#7). For now a
|
||||
> booking is created with `status = pending` and `payment_id = null`; the
|
||||
> instructor confirms via `PATCH /bookings/{id}/status`. When payments land, the
|
||||
> pay→confirm + receipt step plugs into this seam. `GET /policies?scope=booking`
|
||||
> returns just the booking-gate policies the form must collect.
|
||||
> **Payment seam:** a priced booking is created with `status = pending` and its
|
||||
> payment linked via `payment_id`; the lesson is confirmed when the payment is
|
||||
> settled (see `payments.md`) or manually via `PATCH /bookings/{id}/status`.
|
||||
> Unpriced bookings skip the seam entirely and are confirmed at creation.
|
||||
> `GET /policies?scope=booking` returns just the booking-gate policies the form
|
||||
> must collect.
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
||||
|
||||
@@ -94,7 +94,7 @@ After booking, the destination on a payment can be corrected per booking:
|
||||
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
||||
|
||||
## Payment Flow
|
||||
1. During registration the front-end calls `POST /payments/intent`, which creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
|
||||
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
|
||||
2. The browser confirms the card payment with Stripe.
|
||||
3. Stripe calls `POST /payments/webhook`; on `payment_intent.succeeded` the payment is marked `paid`, `paid_at` is stamped, and the linked lesson/enrolment is `confirmed`.
|
||||
4. On transition to `paid`, `ReceiptMailer` assigns a `receipt_number`, emails the student a receipt, and stamps `receipt_sent_at`.
|
||||
|
||||
@@ -103,9 +103,24 @@ class BookingEndpoint {
|
||||
$userId = get_current_user_id();
|
||||
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
|
||||
? $this->bookings->findUpcomingForInstructor( $userId )
|
||||
: $this->bookings->findByStudent( $userId );
|
||||
: $this->bookings->findUpcomingForStudent( $userId );
|
||||
|
||||
return new \WP_REST_Response( array_map( fn( Lesson $l ) => $l->toArray(), $lessons ), 200 );
|
||||
return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* A lesson's array form plus its slot's start/end times, so front-end lists
|
||||
* can show when the session happens without a second request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lessonWithTimes( Lesson $lesson ): array {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
|
||||
return $lesson->toArray() + [
|
||||
'start_dt' => $slot?->startDt,
|
||||
'end_dt' => $slot?->endDt,
|
||||
];
|
||||
}
|
||||
|
||||
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
@@ -197,14 +212,30 @@ class BookingEndpoint {
|
||||
|
||||
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||
|
||||
$payment = null;
|
||||
$status = Lesson::STATUS_PENDING;
|
||||
|
||||
if ( null !== $offering && $offering->price > 0.0 ) {
|
||||
$this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||
$payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||
|
||||
if ( null !== $payment && $payment->isPaid() ) {
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
}
|
||||
} else {
|
||||
// Nothing owed: there is no payment step that would confirm these
|
||||
// lessons later, so they are confirmed at booking time.
|
||||
foreach ( $ids as $lessonId ) {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
}
|
||||
|
||||
// `payment: null` tells the front end to skip the payment step entirely.
|
||||
return new \WP_REST_Response(
|
||||
[
|
||||
'ids' => $ids,
|
||||
'status' => Lesson::STATUS_PENDING,
|
||||
'status' => $status,
|
||||
'payment' => $payment?->toSummaryArray(),
|
||||
],
|
||||
201
|
||||
);
|
||||
|
||||
@@ -113,6 +113,33 @@ class BookingRepository {
|
||||
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming lessons for a student (status != cancelled, slot in the future).
|
||||
*
|
||||
* @return list<Lesson>
|
||||
*/
|
||||
public function findUpcomingForStudent( int $studentId ): array {
|
||||
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT l.* FROM %i l
|
||||
JOIN %i a ON a.id = l.slot_id
|
||||
WHERE l.student_id = %d
|
||||
AND l.status != %s
|
||||
AND a.start_dt >= %s
|
||||
ORDER BY a.start_dt ASC',
|
||||
$this->table,
|
||||
$avTable,
|
||||
$studentId,
|
||||
Lesson::STATUS_CANCELLED,
|
||||
current_time( 'mysql' )
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a student's upcoming, non-cancelled lessons (slot in the future).
|
||||
*/
|
||||
|
||||
@@ -110,14 +110,17 @@ class EnrollmentEndpoint {
|
||||
|
||||
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||
|
||||
$payment = null;
|
||||
if ( $offering->price > 0.0 ) {
|
||||
$this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||
}
|
||||
|
||||
// `payment: null` tells the front end to skip the payment step entirely.
|
||||
return new \WP_REST_Response(
|
||||
[
|
||||
'id' => $id,
|
||||
'status' => Enrollment::STATUS_ACTIVE,
|
||||
'payment' => $payment?->toSummaryArray(),
|
||||
],
|
||||
201
|
||||
);
|
||||
|
||||
@@ -84,6 +84,20 @@ class Payment {
|
||||
return round( $this->amount + $this->taxAmount, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal payment info embedded in registration-creation responses: enough
|
||||
* for the front end to decide whether (and how) to run the payment step.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toSummaryArray(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'method' => $this->method,
|
||||
'status' => $this->status,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the payment.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
?>
|
||||
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>">
|
||||
<div id="us-my-lessons"></div>
|
||||
<div id="us-slot-list">
|
||||
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,10 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
@@ -119,8 +121,9 @@ class BookingEndpointTest extends TestCase
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
// Free offering → no payment.
|
||||
// Free offering → no payment, so the lesson is confirmed immediately.
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
|
||||
|
||||
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||
$result = $this->endpoint->book($request);
|
||||
@@ -128,5 +131,110 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(201, $result->get_status());
|
||||
self::assertSame([77], $result->get_data()['ids']);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testBookWithoutOfferingConfirmsImmediatelyWithNoPayment(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
|
||||
|
||||
$request = new \WP_REST_Request(['slot_id' => 10]);
|
||||
$result = $this->endpoint->book($request);
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testBookWithPricedOfferingStaysPendingAndReturnsPaymentSummary(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null)
|
||||
->andReturn(new Payment(
|
||||
studentId: 5,
|
||||
instructorId: 3,
|
||||
registrationType: Payment::REG_LESSON,
|
||||
registrationId: 77,
|
||||
amount: 50.0,
|
||||
method: Payment::METHOD_ETRANSFER,
|
||||
status: Payment::STATUS_PENDING,
|
||||
id: 12,
|
||||
));
|
||||
// Awaiting payment: the lesson must not be confirmed yet.
|
||||
$this->bookings->shouldNotReceive('updateStatus');
|
||||
|
||||
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||
$result = $this->endpoint->book($request);
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
|
||||
self::assertSame(
|
||||
['id' => 12, 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING],
|
||||
$result->get_data()['payment']
|
||||
);
|
||||
}
|
||||
|
||||
public function testBookWithCompedPaymentReturnsConfirmedStatus(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
// Comped students are paid on creation (PaymentService confirms the lesson itself).
|
||||
$this->payments->shouldReceive('createForRegistration')->once()->andReturn(new Payment(
|
||||
studentId: 5,
|
||||
instructorId: 3,
|
||||
registrationType: Payment::REG_LESSON,
|
||||
registrationId: 77,
|
||||
amount: 50.0,
|
||||
method: Payment::METHOD_COMP,
|
||||
status: Payment::STATUS_PAID,
|
||||
id: 12,
|
||||
));
|
||||
$this->bookings->shouldNotReceive('updateStatus');
|
||||
|
||||
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||
$result = $this->endpoint->book($request);
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
self::assertSame(Payment::METHOD_COMP, $result->get_data()['payment']['method']);
|
||||
}
|
||||
|
||||
public function testMyLessonsForStudentIncludesSlotTimes(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, id: 77);
|
||||
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->once()->andReturn([$lesson]);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
|
||||
$result = $this->endpoint->myLessons(new \WP_REST_Request([]));
|
||||
|
||||
$data = $result->get_data();
|
||||
self::assertCount(1, $data);
|
||||
self::assertSame(77, $data[0]['id']);
|
||||
self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']);
|
||||
self::assertSame('2026-07-01 11:00:00', $data[0]['end_dt']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,35 @@ class BookingRepositoryTest extends TestCase
|
||||
self::assertFalse($this->repo->updateStatus(1, Lesson::STATUS_CONFIRMED));
|
||||
}
|
||||
|
||||
public function testFindUpcomingForStudentJoinsSlotAndExcludesCancelled(): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
|
||||
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::pattern('/SELECT l\.\*.*l.student_id = %d.*l.status != %s.*a.start_dt >= %s.*ORDER BY a.start_dt ASC/s'), 'wp_us_lessons', 'wp_us_availability', 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00')
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$row = (object) [
|
||||
'id' => '15',
|
||||
'slot_id' => '10',
|
||||
'offering_id' => null,
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'recurrence' => Lesson::RECURRENCE_SINGLE,
|
||||
'series_id' => null,
|
||||
'status' => 'confirmed',
|
||||
'payment_id' => null,
|
||||
'notes' => null,
|
||||
];
|
||||
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
||||
|
||||
$lessons = $this->repo->findUpcomingForStudent(5);
|
||||
|
||||
self::assertCount(1, $lessons);
|
||||
self::assertSame(15, $lessons[0]->id);
|
||||
}
|
||||
|
||||
public function testCountUpcomingForStudent(): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class EnrollmentEndpointTest extends TestCase
|
||||
{
|
||||
private EnrollmentRepository $enrollments;
|
||||
private OfferingRepository $offerings;
|
||||
private RegistrationGate $gate;
|
||||
private PaymentService $payments;
|
||||
private EnrollmentEndpoint $endpoint;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(5);
|
||||
|
||||
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->gate = Mockery::mock(RegistrationGate::class);
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
|
||||
$this->endpoint = new EnrollmentEndpoint(
|
||||
$this->enrollments,
|
||||
$this->offerings,
|
||||
$this->gate,
|
||||
$this->payments,
|
||||
);
|
||||
}
|
||||
|
||||
private function offering(float $price): Offering
|
||||
{
|
||||
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: $price, id: 8);
|
||||
}
|
||||
|
||||
private function expectSuccessfulEnrollment(): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->enrollments->shouldReceive('countActiveForOffering')->never();
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
}
|
||||
|
||||
public function testEnrollInFreeClassReturnsNullPayment(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0));
|
||||
$this->expectSuccessfulEnrollment();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(201, $result->get_status());
|
||||
self::assertSame(44, $result->get_data()['id']);
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testEnrollInPricedClassReturnsPaymentSummary(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
|
||||
$this->expectSuccessfulEnrollment();
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null)
|
||||
->andReturn(new Payment(
|
||||
studentId: 5,
|
||||
instructorId: 3,
|
||||
registrationType: Payment::REG_ENROLLMENT,
|
||||
registrationId: 44,
|
||||
amount: 120.0,
|
||||
method: Payment::METHOD_ETRANSFER,
|
||||
status: Payment::STATUS_PENDING,
|
||||
id: 12,
|
||||
));
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(
|
||||
['id' => 12, 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING],
|
||||
$result->get_data()['payment']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ class PaymentTest extends TestCase
|
||||
self::assertSame(100.00, $payment->total());
|
||||
}
|
||||
|
||||
public function testToSummaryArrayContainsOnlyClientFacingFields(): void
|
||||
{
|
||||
$summary = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toSummaryArray();
|
||||
|
||||
self::assertSame(['id' => 7, 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING], $summary);
|
||||
}
|
||||
|
||||
public function testToArrayContainsExpectedKeys(): void
|
||||
{
|
||||
$arr = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toArray();
|
||||
|
||||
Reference in New Issue
Block a user