MeetyyAPI
Documentation / Pay-First Booking (change notes)

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

  1. Why
  2. The new lifecycle
  3. What the statuses mean now
  4. Creating a booking returns a checkout
  5. Unpaid bookings are invisible to the mentor
  6. Automatic refunds
  7. In-chat appointments — the one exception
  8. Configuration
  9. Bugs fixed along the way
  10. Breaking & behavioural changes
  11. Deployment notes
  12. Files touched
  13. Testing
  14. 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 sessionslive, 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
  • 404 on GET /mentor/bookings/{id} — not probeable
  • confirm() 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:

  1. 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.
  2. A mentor's decline must not be hostage to Stripe latency or an outage.
  3. 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, not status, to tell that a mentor accepted an in-chat appointment. POST /chat/appointments/{booking}/accept now returns "status": "requested" with "awaiting_payment": true for 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 least now()+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. SlotHoldService floors the value at 32 regardless of config, and BookingConfigTest asserts 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:

  1. POST /mentee/bookings response gained payment_url and expires_at. Paid bookings must now redirect to checkout at creation. Additive, but a client that ignores it will create bookings nobody can pay for.
  2. status is no longer sufficient to render a booking. Read awaitingPayment / awaitingMentorApproval. requested means two different things depending on price.
  3. paid now precedes confirmed. Any client ordering, filtering or labelling on the old sequence is wrong. paid is not a terminal success state any more — it means "waiting on the mentor".
  4. meetingUrl at paid is now null. It used to be populated. It appears at confirmed.
  5. Mentor dashboard filters change — Pending is requested,paid, Upcoming is confirmed.
  6. GET /mentor/bookings/{id} returns 404 for an unpaid booking, where it previously returned the booking.
  7. POST /chat/appointments/{booking}/accept returns "status": "requested" for a paid appointment, not "confirmed". Read the new awaiting_payment flag.
  8. GET /mentee/bookings/{id}/payment-link guard flipped — it now requires requested, not confirmed, and returns 422 when the seat reservation has lapsed.

Behavioural:

  1. Mentors are no longer notified of paid booking requests until payment lands. session.booked fires for free bookings only; payment.received became the mentor's new-request alert and now carries requires_action.
  2. Declining a paid booking spends money. It refunds automatically, with no admin step and no confirmation prompt.
  3. bookings:expire-unconfirmed moved 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.
  4. expireStaleRequests() was renamed expireStaleBookings() and now runs three sweeps. The artisan signature is unchanged.

Deployment notes

  1. Run the migration. 2026_09_01_043801_add_lifecycle_timestamps_to_session_bookings_table adds mentor_approved_at, paid_at and an index on (status, paid_at).

  2. ⚠️ 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 confirmed and paid invert 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 UPDATE must run after the first, and the sweep for unanswered paid bookings deliberately skips rows with a null paid_at, so un-backfilled legacy rows are never auto-refunded.

  3. Set the new env vars (see §8), or accept the defaults. Keep BOOKING_CHECKOUT_HOLD_MINUTES above 32.

  4. A queue worker is required for refunds. IssueBookingRefund is 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.

  5. php artisan config:clear after deploying, since config/booking.php is 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 regressionconfirm() on an already-paid booking must not push current_capacity to 2
  • webhook idempotency — a trailing charge.refunded after 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, never refunded; 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:

  1. A superseded checkout is only cancelled locally. PaymentService::cancelOutstandingFor() marks the old Payment row 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.
  2. No admin refund endpoint. flagForRefund() and Payment::scopeAwaitingRefund() are now only the fallback for a refund the gateway refused, but nothing exposes them over HTTP.
  3. A partial refund never reverses its purchaserecordRefund() only calls the settler's reverse() on a full refund.
  4. NotifySlotHoldExpiring self-cancels unless the slot reads Held, so on a group slot with free seats the warning never fires — for exactly the mentees most likely to need it.
  5. No min_notice_hours guard, 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.
  6. The two SessionBookingResource classes are byte-identical but for one field.