Skip payment step for unpriced bookings, confirm them immediately, show students their lessons
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 2m46s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Coding Standards (pull_request) Successful in 47s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m35s
CI / Build Plugin Zip (pull_request) Has been skipped

Booking a slot with no priced offering created the lesson but no payment,
yet the front end still called POST /payments/intent, which 400ed with
"Could not start payment for this registration" — the student saw an error
while the backend held a claimed slot and a lesson stuck at pending.

- POST /bookings and POST /enrollments now return a `payment` summary
  ({id, method, status}) or null when nothing is owed; the JS only runs
  the payment step when a payment exists.
- Bookings with nothing owed are confirmed at creation — there is no
  payment step that would ever confirm them later.
- The booking page now shows the student's upcoming lessons (GET /bookings,
  now scoped to upcoming non-cancelled lessons with slot start/end times)
  with a pending-payment/confirmed status badge.

Fixes #53

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-05 17:02:38 -03:00
co-authored by Claude Fable 5
parent 93dccd6352
commit 5888032ed7
15 changed files with 433 additions and 25 deletions
+32
View File
@@ -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
View File
@@ -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;
+5 -1
View File
@@ -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));
}
+4 -1
View File
@@ -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.
+14 -8
View File
@@ -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`
+1 -1
View File
@@ -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`.
+35 -4
View File
@@ -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
);
+27
View File
@@ -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).
*/
+4 -1
View File
@@ -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
);
+14
View File
@@ -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.
*
+1
View File
@@ -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>
+109 -1
View File
@@ -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']
);
}
}
+7
View File
@@ -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();