Skip payment step for unpriced bookings, confirm them immediately, show students their lessons #54
@@ -34,6 +34,38 @@
|
|||||||
margin-top: 8px;
|
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 {
|
.us-view-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
+44
-2
@@ -6,6 +6,7 @@
|
|||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
|
||||||
const slotList = document.getElementById('us-slot-list');
|
const slotList = document.getElementById('us-slot-list');
|
||||||
|
const myLessons = document.getElementById('us-my-lessons');
|
||||||
const confirm = document.getElementById('us-booking-confirmation');
|
const confirm = document.getElementById('us-booking-confirmation');
|
||||||
const errorBox = document.getElementById('us-booking-error');
|
const errorBox = document.getElementById('us-booking-error');
|
||||||
const { restUrl, nonce } = usScheduler;
|
const { restUrl, nonce } = usScheduler;
|
||||||
@@ -274,11 +275,51 @@
|
|||||||
accepted_policy_version_ids: accepted,
|
accepted_policy_version_ids: accepted,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.then((res) => window.usPayment.collect('lesson', (res.ids || [])[0], slotList))
|
// A booking with nothing owed has no payment, so there is no payment
|
||||||
.then((result) => showConfirmation(window.usPayment.message(result)))
|
// 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));
|
.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) {
|
function showConfirmation(message) {
|
||||||
confirm.textContent = message;
|
confirm.textContent = message;
|
||||||
slotList.style.display = 'none';
|
slotList.style.display = 'none';
|
||||||
@@ -289,6 +330,7 @@
|
|||||||
clearError();
|
clearError();
|
||||||
slotList.style.display = 'block';
|
slotList.style.display = 'block';
|
||||||
confirm.style.display = 'none';
|
confirm.style.display = 'none';
|
||||||
|
loadMyLessons();
|
||||||
apiFetch('availability')
|
apiFetch('availability')
|
||||||
.then((slots) => {
|
.then((slots) => {
|
||||||
allSlots = slots;
|
allSlots = slots;
|
||||||
|
|||||||
@@ -142,7 +142,11 @@
|
|||||||
accepted_policy_version_ids: accepted,
|
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)))
|
.then((result) => showConfirmation(window.usPayment.message(result)))
|
||||||
.catch((err) => showError(err.message));
|
.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` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
|
||||||
|
|
||||||
`POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value),
|
`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
|
`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.
|
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`).
|
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
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`.
|
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.
|
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`.
|
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
|
## 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
|
||||||
@@ -45,9 +46,13 @@ offering).
|
|||||||
|
|
||||||
`POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]`
|
`POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]`
|
||||||
(`question_id` → value), `accepted_policy_version_ids[]`, and payment data
|
(`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
|
Group classes follow the same registration flow but enrol against an offering of
|
||||||
kind `group_class`; see `group-classes.md`.
|
kind `group_class`; see `group-classes.md`.
|
||||||
@@ -68,11 +73,12 @@ kind `group_class`; see `group-classes.md`.
|
|||||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||||
|
|
||||||
> **Payment seam:** payment is deferred to the Payments feature (#7). For now a
|
> **Payment seam:** a priced booking is created with `status = pending` and its
|
||||||
> booking is created with `status = pending` and `payment_id = null`; the
|
> payment linked via `payment_id`; the lesson is confirmed when the payment is
|
||||||
> instructor confirms via `PATCH /bookings/{id}/status`. When payments land, the
|
> settled (see `payments.md`) or manually via `PATCH /bookings/{id}/status`.
|
||||||
> pay→confirm + receipt step plugs into this seam. `GET /policies?scope=booking`
|
> Unpriced bookings skip the seam entirely and are confirmed at creation.
|
||||||
> returns just the booking-gate policies the form must collect.
|
> `GET /policies?scope=booking` returns just the booking-gate policies the form
|
||||||
|
> must collect.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
- `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 |
|
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
||||||
|
|
||||||
## Payment Flow
|
## 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.
|
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`.
|
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`.
|
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();
|
$userId = get_current_user_id();
|
||||||
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
|
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
|
||||||
? $this->bookings->findUpcomingForInstructor( $userId )
|
? $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 {
|
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() );
|
$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 ) {
|
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(
|
return new \WP_REST_Response(
|
||||||
[
|
[
|
||||||
'ids' => $ids,
|
'ids' => $ids,
|
||||||
'status' => Lesson::STATUS_PENDING,
|
'status' => $status,
|
||||||
|
'payment' => $payment?->toSummaryArray(),
|
||||||
],
|
],
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,6 +113,33 @@ class BookingRepository {
|
|||||||
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
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).
|
* 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() );
|
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||||
|
|
||||||
|
$payment = null;
|
||||||
if ( $offering->price > 0.0 ) {
|
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(
|
return new \WP_REST_Response(
|
||||||
[
|
[
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'status' => Enrollment::STATUS_ACTIVE,
|
'status' => Enrollment::STATUS_ACTIVE,
|
||||||
|
'payment' => $payment?->toSummaryArray(),
|
||||||
],
|
],
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,6 +84,20 @@ class Payment {
|
|||||||
return round( $this->amount + $this->taxAmount, 2 );
|
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.
|
* 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-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>">
|
||||||
|
<div id="us-my-lessons"></div>
|
||||||
<div id="us-slot-list">
|
<div id="us-slot-list">
|
||||||
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
|||||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
|
use Unsupervised\Schedular\Booking\Lesson;
|
||||||
use Unsupervised\Schedular\Offering\Offering;
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
use Unsupervised\Schedular\Payment\PaymentService;
|
use Unsupervised\Schedular\Payment\PaymentService;
|
||||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
@@ -119,8 +121,9 @@ class BookingEndpointTest extends TestCase
|
|||||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||||
$this->gate->shouldReceive('record')->once();
|
$this->gate->shouldReceive('record')->once();
|
||||||
// Free offering → no payment.
|
// Free offering → no payment, so the lesson is confirmed immediately.
|
||||||
$this->payments->shouldNotReceive('createForRegistration');
|
$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]);
|
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||||
$result = $this->endpoint->book($request);
|
$result = $this->endpoint->book($request);
|
||||||
@@ -128,5 +131,110 @@ class BookingEndpointTest extends TestCase
|
|||||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||||
self::assertSame(201, $result->get_status());
|
self::assertSame(201, $result->get_status());
|
||||||
self::assertSame([77], $result->get_data()['ids']);
|
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));
|
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
|
public function testCountUpcomingForStudent(): void
|
||||||
{
|
{
|
||||||
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
|
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());
|
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
|
public function testToArrayContainsExpectedKeys(): void
|
||||||
{
|
{
|
||||||
$arr = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toArray();
|
$arr = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toArray();
|
||||||
|
|||||||
Reference in New Issue
Block a user