Bookable kinds¶
A bookable order line is one that, when bought, must claim something scarce (event stock, a
practitioner's time slot), issue an entitlement once it is paid for, and void that entitlement when
the order is cancelled or refunded. BookableKind (package booking in commerce-core) is the one
seam for this. Event tickets are the first kind (EventTicketKind); paid appointments are the second
(AppointmentKind).
How it fits the order pipelines¶
Three existing pipeline steps are thin dispatchers. Their names, bean ids, phases and priorities are unchanged, so existing pipeline configuration keeps working.
| Step | Pipeline and phase | Calls on the line's kind |
|---|---|---|
ReserveTicketStockProcessor |
submitOrder, validate (before capture) |
claim, and release if a later line is refused |
IssueTicketsProcessor |
submitOrder, fulfilment (early, before the confirmation email) |
issue |
VoidTicketsProcessor |
cancelOrder and refundOrder |
voidOrder or voidLine |
For each line the step asks every registered kind handles(line) and uses the first that says yes
(kinds must not overlap). Lines no kind handles are left to the normal steps, and a step only runs
for orders that have at least one bookable line.
Rules every kind follows¶
- No transactions, no saving. Kinds change the in-memory order (order properties, line attributes) and report that they did; the dispatching step saves the order once.
- Claim is idempotent per line. A retried submit finds its earlier claim recorded on the order
and takes nothing more. The event kind records an entry in the
stockTakenorder property carrying the line id, which also lets cancellation restock it andupdateInventoryskip the line. - Refuse by returning, not throwing.
claimreturnsClaimResult.refused(activityCode, unavailable). The step then releases everything claimed earlier in the run (of any kind), writes the unavailable list to the kind'srefusalProperty(), releases the card authorisation throughSubmitFailurePaymentReleaser(outcome stored under<refusalProperty>Payment), saves the order and stops the pipeline withVALIDATION_FAILED. The order stays a basket. - Issue never stops the pipeline. The customer has paid. Return
IssueResult(complete=false, ...)(or throw) and the step reportsticketsIssueFailed, logs the line and carries on so the confirmation still goes out. - Cancel does not restock; refund does. On
CANCELLEDthe cancellation restock step restores whatstockTakenrecords. OnREFUNDEDthe kind gives capacity back itself and removes it from the order's record so a later cancellation doesn't give it back twice. - Refund quantities are cumulative.
voidLine(..., quantity, ...)receives the total quantity refunded on that line so far, not the increment. Voiding until that many units are void makes the call idempotent. A part-amount refund naming no line voids nothing; staff void from the kind's own admin view. - Tenancy and logs. Every method receives the
SiteContext; stay inside it. Log ids only (order, line, site), never customer details.
The appointment kind¶
AppointmentKind handles a line that carries the appointment product decorator and a
LineItem.booking. The scarce thing is a practitioner's time; the entitlement is a Reservation
(kind APPOINTMENT) linked to the order line (orderId, lineItemId, productSkuId). It delegates to
ReservationService, so the booking engine's rules are the ones the free path uses; the two share
ReservationServiceImpl.holdAppointmentSlot.
| Step | What it does |
|---|---|
claim |
Re-runs availability for the slot as a customer, resolves Any available with the type's assignment strategy, writes the TableHold rows and records a PENDING_PAYMENT booking (holdAppointmentForOrder). The booking id goes on line.booking.reservationId. A slot that has gone is a refusal (appointmentSlotsUnavailable, listing the line id), not an exception. |
release |
Frees the holds and deletes the held booking (discardHeldAppointment), clearing line.booking.reservationId. |
issue |
Turns the held booking into BOOKED (bookHeldAppointment), publishing the usual BOOKED event for the guest profile and diary. The booking sends no confirmation email: ReservationEmailListener skips a CONFIRMATION for a booking that has an orderId, because the order's confirmation covers it. |
voidOrder / voidLine |
Cancels the booking and frees its holds (cancelForOrder). The booking has one unit, so any refunded quantity of 1 or more cancels it. No money moves here: the order has been cancelled or refunded already. |
Idempotent claim. A retried submit finds its hold by the id on the line, or, if the first run died
before the order was saved, by (orderId, lineItemId).
Payment requests: a line claimed before submit. When staff book a paid appointment by phone and send a
payment link (BookingPaymentRequestService), the slot is held when staff book, as a PENDING_PAYMENT
booking with a paymentDueBy, and the order (marked paymentRequest, see
direct checkout) carries a line whose booking.reservationId is already
set. For such an order, or a line whose booking has a paymentDueBy:
claimonly checks: the booking must belong to this order, bePENDING_PAYMENTand not past itspaymentDueBy, and it returnsALREADY_CLAIMED(it does not renewheldSince). A paid one retried is alsoALREADY_CLAIMED. Anything else (freed by the sweep, withdrawn by staff, gone, another order's) is refused with the usual slot-unavailable refusal, and the slot is never claimed afresh, even if it is free again.releasedoes nothing: the hold is the request's, so a declined card leaves it for the customer to try again.issueand the voids are unchanged.
The line is filled by the appointment decorator from the held booking: BookingPaymentRequestService
adds the product with the hidden heldReservationId input, which the decorator accepts only for an unpaid
payment request made for that very order and slot, skipping the diary and online-rule checks (staff may book
what customers can't).
BookingPaymentRequestService is the same for every kind of booking. What differs is a PaymentRequestLine
for the request: the product, how long the booking may wait for payment, how it is held, and the line's
decorator inputs. Appointments make theirs with AppointmentPaymentRequestLines (the type's product, held
for appointment-pay-link-hours but never later than two hours before the start). The sweep's query for due
requests isn't filtered by kind.
Who cancelled, and what was refunded. The refund pipeline carries only the line and the quantity, so
whoever cancels a paid appointment first notes {by, reason, refundMinor} for the line under the
appointmentCancellations order property (PaidBookingCancellationImpl); voidOne reads it to stamp the
booking (cancelledBy, cancelReason, refundedMinor) for the cancellation email. Without a note, a
customer's own order cancellation reads as the guest's and anything else as the venue's.
The refusal in the basket. SoldOutTicketsService.returnToBasket handles both kinds' refusals: for
appointments it puts an APPOINTMENT_SLOT_GONE StockNotice on the line, which the basket templates draw
(with a link to /book), and leaves the line for the shopper to remove.
Cancelling for the guest. ReservationService.cancel for a paid booking hands a guest to
PaidBookingCancellation, which refunds the line by the type's terms through refundOrder
(amount, lineItemId, quantity) and lets this kind's void step cancel the booking. Before refunding it
claims the booking (ReservationService.claimRefund: refundClaimedAt set in one conditional update while
the booking isn't cancelled), so two cancels at once (a double-click, the guest and the venue together)
refund once; the second is told the booking is already being cancelled, or gets the cancelled booking. A
failed refund gives the claim back, and a claim left by a node that died lapses after 15 minutes. See
Appointments.
Abandoned holds. A claim is made before the card is charged, so it must be given back if the order never completes. Two things do that, neither inside the kind:
- A failed submit releases the claims.
OrderServiceImpl.submitOrdercallsBookableClaims.releaseAll(every bookable line, through the registered kinds' idempotentrelease) when the pipeline fails and no money was taken (OrderPaymentStatus.moneyTaken), then saves the order. A card declined at capture therefore leaves no hold and no event stock taken; a retried submit claims again. This is for every kind: event tickets had the same leak (their stock stayed taken by an order sitting inPAYMENT_FAILED) and are fixed by the same call. An order whose payment was captured keeps its claims. - A sweep covers the rest (a browser closed mid-payment, a process that died after the claim).
BookingHoldReleaseService.releaseStale(the worker'sBookingHoldReleaseJob, every five minutes) listsPENDING_PAYMENTappointments whoseheldSinceis older than the site'sappointment-hold-release-minutes(default 30, minimum 5;dining-config), loads each one's order throughOrderService, and releases the hold only when the order is gone or is still a basket with nothing captured. It never decides from the hold's age alone.AppointmentKind.claimrenewsheldSincewhen a retried submit finds its hold, and the delete is conditional on the hold still being stale, so a retry and the sweep can't both win. Ticket stock needs no sweep: a failed submit releases it, and an order that never reached submit never took it. Payment requests are not aged by this rule: the same sweep frees them once theirpaymentDueByhas passed (BookingPaymentRequestService.releaseOverdue), cancelling the booking as not paid and its order, unless the order was placed or a checkout payment started on it in the last 30 minutes.
What the customer is shown. The kind sends nothing. AppointmentOrderDisplayService builds the appointment
card from each line's booking and the Reservation behind it (so Any available shows the practitioner given),
and the order confirmation email, the order page and the my-account order page draw it; see
Appointments and
Transactional email.
Not done by the kind. A soft hold while the line sits in the basket is a later phase.
Adding a kind¶
- Implement
BookableKindas a Spring@Componentincommerce-core. That is the whole registration: the three steps takeList<BookableKind>by injection. - Decide
handles(line), usually "the line carries my product decorator". - Pick a
refusalProperty()(for exampleappointmentSlotsUnavailable) and make sure checkout can explain a refusal from it. - Implement
claim/release(record what you took on the order),issue,voidOrderandvoidLine. - Test it as the dispatcher tests do (
BookableKindDispatchTesthas a fake second kind): only your lines reach you, a refusal stops submit, earlier claims are released.
Attendance (scanning a ticket, marking an appointment seen) is not part of the SPI: it is driven by staff screens, not by the order pipelines.