Pay-first session booking — 1 Sep 2026
Standalone notes for this round of work. Deliberately not the API reference — that
lives in docs/booking-api.md, which has been updated to match.
This file records what changed and why, for anyone who knew the old flow.
Session bookings are now paid for up front. The mentee is charged when they book; the paid request then goes to the mentor, who either accepts it (the session goes live) or declines it (the mentee is refunded automatically, within 5 working days). Free sessions are unaffected.
Items that will break a client or need attention on deploy are collected under Breaking & behavioural changes and Deployment notes.
Contents
- Why
- The new lifecycle
- What the statuses mean now
- Creating a booking returns a checkout
- Unpaid bookings are invisible to the mentor
- Automatic refunds
- In-chat appointments — the one exception
- Configuration
- Bugs fixed along the way
- Breaking & behavioural changes
- Deployment notes
- Files touched
- Testing
- Known follow-ups
1. Why
Under the old flow a mentee requested a session, the mentor confirmed it, and only then was the mentee asked to pay. Two problems fell out of that ordering:
- Mentors triaged requests from people who never paid. Every request cost them a decision, and a good share of those decisions bought nothing.
- A confirmed booking could sit unpaid. The mentor had committed the time; the seat stayed held until the payment deadline lapsed, with nothing to show for it.
Charging first removes both. A mentor only ever sees a booking that has already been paid for, and their acceptance is the last step rather than the first.
2. The new lifecycle
free: Requested ──mentor accepts──▶ Confirmed ──▶ Completed
paid: Requested ──mentee pays──▶ Paid ──accepts──▶ Confirmed ──▶ Completed
│ └──declines / timeout / mentee cancels──▶ Cancelled ──▶ Refunded
└──checkout expires──▶ Cancelled (mentor never notified)
The old paid order was Requested → Confirmed → Paid. It is now
Requested → Paid → Confirmed. No new enum cases were added — the existing ones were
re-pointed, which is why paid now precedes confirmed.
3. What the statuses mean now
| Status | Means | Seat | Meeting link | Mentor sees it |
|---|---|---|---|---|
requested |
checkout open, unpaid — or free and awaiting the mentor | held only | no | free bookings only |
paid |
money in, awaiting the mentor's decision | claimed | no | yes |
confirmed |
mentor accepted — the session is live | claimed | yes | yes |
completed · cancelled · refunded |
unchanged |
The important simplification: confirmed now means the same thing for free and paid
sessions — live, agreed, and paid for. A paid booking can only reach it through paid,
so the meeting-link and completion rules no longer branch on price at all.
Three predicates on SessionBooking carry this:
| Method | Answers | True when |
|---|---|---|
requiresPayment() |
is there a real amount to charge? | unchanged — a "paid" type priced at 0.00 is free in effect |
isSettled() |
is the session live? | confirmed or completed — gates meetingUrl and completion |
hasClaimedSeat() |
new — does this booking occupy a seat? | paid, confirmed or completed — gates capacity release |
isSettled() used to be overloaded to answer both of the last two. Splitting them is what
lets a paid booking hold its seat while the mentor is still deciding, without also handing
the mentee a meeting link.
Resource fields
SessionBookingResource (both mentee and mentor variants) gained four fields, because
status alone is now ambiguous — requested means "pay now" for a paid booking and
"waiting on the mentor" for a free one:
| Field | Use |
|---|---|
awaitingPayment |
requested and requiresPayment — send the mentee to checkout |
awaitingMentorApproval |
paid — money is in, the mentor has yet to accept |
mentorApprovedAt |
when the mentor accepted (set before payment on in-chat appointments) |
paidAt |
when the money landed — the mentor-response clock runs from here |
4. Creating a booking returns a checkout
POST /api/v1/mentee/bookings now returns the checkout to redirect to:
{
"status": "success",
"message": "Booking created successfully.",
"data": {
"booking": { "…" },
"payment_url": "https://checkout.stripe.com/c/pay/cs_test_…",
"expires_at": "2026-09-01T09:35:00+00:00"
}
}
Both new fields are null for a free booking. For a paid one, send the mentee straight
to payment_url; the seat is held only until expires_at.
The gateway call deliberately runs outside the booking transaction — it needs the
committed booking id, and a Stripe round-trip while holding lockForUpdate on every
clashing slot would serialise the whole calendar.
If checkout cannot be opened, the booking is rolled back and the endpoint returns 502.
Leaving it behind would be worse than not creating it: invisible to the mentor, holding a
seat, and blocking the mentee's obvious retry — assertNotAlreadyBooked() would refuse to
let them re-book their own slot.
GET /mentee/bookings/{id}/payment-link still exists and is now the retry path, for a
mentee who closed the tab. It refuses a booking whose seat reservation has already lapsed
(422) rather than selling a seat we are no longer holding.
5. Unpaid bookings are invisible to the mentor
An unpaid paid-type booking is a checkout in progress, not a request. A new
SessionBooking::scopeVisibleToMentor() enforces that everywhere:
- absent from
GET /mentor/bookings 404onGET /mentor/bookings/{id}— not probeableconfirm()refuses it (422, "This booking has not been paid for yet.")- no notification, ever — including its cancellation
That last point matters more than it looks. Abandoned checkouts, the create-rollback above, and a mentee changing their mind before paying all end in a cancellation; announcing any of them would be the first the mentor ever heard of that booking. The mentee is still told.
Dashboard tabs shift accordingly:
| Tab | Old filter | New filter |
|---|---|---|
| Pending | requested |
requested,paid |
| Upcoming | confirmed,paid |
confirmed |
| Cancelled | cancelled,refunded |
unchanged |
Pending spans two statuses because a free booking arrives at requested and a paid one at
paid. Use awaitingMentorApproval to flag the ones where money is already at stake.
6. Automatic refunds
The rule is one line: money goes back whenever a booking leaves paid — it was paid for
but never agreed to.
| Trigger | Refund? | Reason recorded |
|---|---|---|
Mentor declines a paid booking |
yes | mentor_declined |
| Mentor never answers (expiry sweep) | yes | mentor_no_response |
Mentee cancels a paid booking |
yes | requested_by_customer |
| Group seat lost during checkout | yes | capacity_conflict |
Either party cancels a confirmed booking |
no — the session was agreed | — |
| Checkout expired / never paid | n/a — no money was taken | — |
Because the condition is a property of the booking rather than of who is acting, it lives in
BookingManagementService::cancel(). The mentor-decline, mentee-cancel and chat-decline call
sites needed no changes at all.
Ordering: cancel first, refund second. The booking is marked cancelled immediately and
reaches refunded only through the money path (recordRefund() → settler reverse() →
markRefunded()), once the gateway confirms. So the mentee is never told funds are on the
way when they are not, and a crash mid-refund still leaves a booking that records who
cancelled it and why.
The gateway call is a queued job, App\Jobs\Booking\IssueBookingRefund, dispatched
->afterCommit(). Three reasons it is not a direct call:
- Settlement runs inside
PaymentService::markSucceeded()'s open transaction. A Stripe round-trip in a transaction that may roll back would refund money the database then forgets. - A mentor's decline must not be hostage to Stripe latency or an outage.
- One expiry sweep can owe refunds on many bookings at once.
On exhausted retries the job's failed() falls back to the pre-existing
flagForRefund() admin queue, so nothing goes quiet. RefundIssuedNotification states the
5 working days ETA in both the mail body and the in-app payload.
This finally gives
PaymentService::refund()a caller. It had been written, tested through the webhook path, and never invoked by anything.
The capacity race is now the common case
A group session's last seat can sell while a checkout page sits open, and Stripe has no view of the seat count — so the charge succeeds for a seat that no longer exists. Under pay-first the mentee has always paid before any seat is claimed, which promotes this from an exotic edge to the primary failure mode of a group session. It was upgraded from flag for an admin to a full automatic refund.
7. In-chat appointments — the one exception
The in-chat flow cannot be pay-first: the mentor sets the price when accepting, so there is nothing to charge for until then. It stays approve-then-pay.
Its approved-but-unpaid state is now requested + mentorApprovedAt, not confirmed —
otherwise confirmed would stop meaning live and paid for, and an unpaid chat mentee would
be handed a meeting link. A new BookingManagementService::approveForPayment() records the
acceptance and extends the holds to the longer payment deadline; when that payment settles,
pay() sees mentorApprovedAt and takes the booking straight to confirmed, since the
mentor has already agreed.
Clients must read
awaiting_payment, notstatus, to tell that a mentor accepted an in-chat appointment.POST /chat/appointments/{booking}/acceptnow returns"status": "requested"with"awaiting_payment": truefor a paid appointment.
8. Configuration
New file config/booking.php. Everything that was a hardcoded constant is now env-backed.
| Key | Env | Default | Notes |
|---|---|---|---|
mentor_response_hours |
BOOKING_MENTOR_RESPONSE_HOURS |
24 | Measured from paid_at for a paid booking, created_at for a free one |
checkout_hold_minutes |
BOOKING_CHECKOUT_HOLD_MINUTES |
35 | Must exceed 32 — see below |
payment_deadline_hours |
BOOKING_PAYMENT_DEADLINE_HOURS |
24 | In-chat appointments only |
hold_expiry_warning_minutes |
BOOKING_HOLD_EXPIRY_WARNING_MINUTES |
5 | How early the mentee is warned |
Why the hold went from 10 minutes to 35.
StripeGateway::clampExpiry()raises every checkout expiry to at leastnow()+31min, because that is Stripe's floor. A 10-minute hold would therefore be outlived by the very checkout it is reserving the seat for — the seat would be resold while the gateway was still taking money for it.SlotHoldServicefloors the value at 32 regardless of config, andBookingConfigTestasserts it.
The trade-off: a 35-minute hold reduces how many mentees can be in checkout for the same
group slot at once. Stripe's floor forces it, and abandoned checkouts are released early by
checkout.session.expired.
BookingManagementService::REQUEST_TIMEOUT_HOURS and PAYMENT_DEADLINE_HOURS were deleted —
constants cannot read config, and a lying constant is worse than touching its call sites.
9. Bugs fixed along the way
Three pre-existing defects that the new flow either made reachable or made worse:
| Bug | Effect | Fix |
|---|---|---|
markRefunded() left follow-ups Reserved |
completeDueFollowups() would later mark a refunded booking's follow-ups Completed |
Cancels them inside the same transaction |
SessionBookingSettler::abandon() attributed the cancellation to the mentee |
SendBookingCancelledNotification rejects the canceller, so only the mentor was told an unpaid booking lapsed — exactly backwards |
Passes null: nobody cancelled it, the window closed |
PostBookingChatMessage::body() branched on is_free |
A paid, already-live booking was told to "complete payment"; a BookingPaid at the awaiting-mentor step would post a bare "Meeting link: " |
Branches on meeting_url — whether there is a link to hand out is the actual question |
Breaking & behavioural changes
For API clients:
POST /mentee/bookingsresponse gainedpayment_urlandexpires_at. Paid bookings must now redirect to checkout at creation. Additive, but a client that ignores it will create bookings nobody can pay for.statusis no longer sufficient to render a booking. ReadawaitingPayment/awaitingMentorApproval.requestedmeans two different things depending on price.paidnow precedesconfirmed. Any client ordering, filtering or labelling on the old sequence is wrong.paidis not a terminal success state any more — it means "waiting on the mentor".meetingUrlatpaidis nownull. It used to be populated. It appears atconfirmed.- Mentor dashboard filters change — Pending is
requested,paid, Upcoming isconfirmed. GET /mentor/bookings/{id}returns404for an unpaid booking, where it previously returned the booking.POST /chat/appointments/{booking}/acceptreturns"status": "requested"for a paid appointment, not"confirmed". Read the newawaiting_paymentflag.GET /mentee/bookings/{id}/payment-linkguard flipped — it now requiresrequested, notconfirmed, and returns422when the seat reservation has lapsed.
Behavioural:
- Mentors are no longer notified of paid booking requests until payment lands.
session.bookedfires for free bookings only;payment.receivedbecame the mentor's new-request alert and now carriesrequires_action. - Declining a paid booking spends money. It refunds automatically, with no admin step and no confirmation prompt.
bookings:expire-unconfirmedmoved from hourly to every 15 minutes — one of its three sweeps reaps abandoned checkouts, and until it runs the mentee cannot re-book the slot they just abandoned.expireStaleRequests()was renamedexpireStaleBookings()and now runs three sweeps. The artisan signature is unchanged.
Deployment notes
-
Run the migration.
2026_09_01_043801_add_lifecycle_timestamps_to_session_bookings_tableaddsmentor_approved_at,paid_atand an index on(status, paid_at). -
⚠️ There is no data backfill, by decision — this was built against a dev-only database. If you are deploying against a database with real bookings, the meanings of
confirmedandpaidinvert underneath the existing rows and two populations break:Legacy row Read as Damage paid(was: paid, confirmed, live)"awaiting mentor" meeting link disappears; never auto-completes confirmed+ unpaid"live and paid for" free meeting link, and it auto-completes Both map cleanly onto the new model, so the backfill is three idempotent
UPDATEs:-- Legacy PAID meant paid AND accepted AND live. That is CONFIRMED now. UPDATE session_bookings SET status='confirmed', mentor_approved_at=updated_at, paid_at=updated_at WHERE status='paid'; -- Legacy CONFIRMED-but-unpaid is the new "approved, awaiting payment". UPDATE session_bookings SET status='requested', mentor_approved_at=updated_at WHERE status='confirmed' AND is_free=0 AND price_snapshot>0 AND payment_id IS NULL; -- Anything already CONFIRMED and legitimately live keeps a truthful stamp. UPDATE session_bookings SET mentor_approved_at=updated_at WHERE status='confirmed' AND mentor_approved_at IS NULL;Note the second
UPDATEmust run after the first, and the sweep for unanswered paid bookings deliberately skips rows with a nullpaid_at, so un-backfilled legacy rows are never auto-refunded. -
Set the new env vars (see §8), or accept the defaults. Keep
BOOKING_CHECKOUT_HOLD_MINUTESabove 32. -
A queue worker is required for refunds.
IssueBookingRefundis queued; without a worker, declines will cancel bookings and never return the money. It has no dedicated queue name, so the default queue must be worked. -
php artisan config:clearafter deploying, sinceconfig/booking.phpis new.
Files touched
New
| File | Purpose |
|---|---|
config/booking.php |
The four lifecycle windows |
app/Jobs/Booking/IssueBookingRefund.php |
Queued gateway refund with admin-queue fallback |
database/migrations/…_add_lifecycle_timestamps_to_session_bookings_table.php |
mentor_approved_at, paid_at |
tests/Feature/Booking/PayFirstBookingTest.php |
Create → pay → accept, and the silences |
tests/Feature/Booking/BookingRefundTest.php |
Every refund trigger, idempotency, gateway failure |
tests/Feature/Booking/BookingConfigTest.php |
The 32-minute floor |
Changed — domain
app/Models/SessionBooking.php (predicates + three scopes) ·
app/Services/Mentor/BookingManagementService.php (the bulk: confirm, pay,
approveForPayment, cancel, cancelAndRefund, three expiry sweeps) ·
app/Services/Mentee/BookingService.php (createBookingWithCheckout,
checkoutDeadlineFor) · app/Services/Mentor/SlotHoldService.php (configurable window) ·
app/Services/Payment/Settlers/SessionBookingSettler.php · app/Enums/Payment/RefundReason.php
· app/Services/Chat/ChatAppointmentService.php
Changed — HTTP
Api/V1/Mentee/BookingController.php · Api/V1/Mentor/BookingController.php ·
Api/V1/Chat/AppointmentController.php · both SessionBookingResources
Changed — events & notifications
BookingCancelled (carries previousStatus) · SendBookingCancelledNotification ·
SendBookingRequestedNotification · PostBookingChatMessage ·
PaymentReceivedNotification · BookingRequestedNotification ·
BookingConfirmedNotification · RefundIssuedNotification
Changed — other
routes/console.php · ExpireUnconfirmedBookings command · SessionBookingFactory ·
NotificationSeeder · .env.example · seven files under docs/
37 files, +1270 / −382.
Testing
php artisan test --compact
2051 passed / 0 failed (7293 assertions), verified 1 Sep 2026 — up from 2034 before this round. The booking, payment and in-chat suites alone are 153 passed / 650 assertions.
17 new tests, plus rewrites of the suites that encoded the old ordering
(BookingPaymentTest, GroupSessionTest, AppointmentInChatTest,
ExpireUnconfirmedBookingsTest, SlotHoldExpiringTest).
Cases worth knowing are covered:
- the seat double-claim regression —
confirm()on an already-paid booking must not pushcurrent_capacityto 2 - webhook idempotency — a trailing
charge.refundedafter an automatic refund is a no-op, and capacity cannot go negative (four independent guards) - mentor silence — no notification for an abandoned checkout, a rolled-back create, or a pre-payment mentee cancel
- gateway failure — the booking stays
cancelled, neverrefunded; the payment lands in the admin queue; the mentee is not told money is coming - the capacity race end to end — two mentees, one seat, both pay, loser auto-refunded
- config is read at call time, so overriding the response window at runtime works
vendor/bin/pint --dirty clean.
Known follow-ups
Not in scope for this round, in rough priority order:
- A superseded checkout is only cancelled locally.
PaymentService::cancelOutstandingFor()marks the oldPaymentrow cancelled but never tells Stripe to expire the session. If that older session then settles,markSucceeded()sees a final status and returns silently — money taken, nothing delivered. Pay-first makes reopening checkout an ordinary path, so this is now worth closing. - No admin refund endpoint.
flagForRefund()andPayment::scopeAwaitingRefund()are now only the fallback for a refund the gateway refused, but nothing exposes them over HTTP. - A partial refund never reverses its purchase —
recordRefund()only calls the settler'sreverse()on a full refund. NotifySlotHoldExpiringself-cancels unless the slot readsHeld, so on a group slot with free seats the warning never fires — for exactly the mentees most likely to need it.- No
min_notice_hoursguard, so a paid session can still be booked inside the mentor-response window. The expiry sweep compensates by refunding as soon as the session starts, but refusing the booking up front would be better. - The two
SessionBookingResourceclasses are byte-identical but for one field.