Skip to content

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 stockTaken order property carrying the line id, which also lets cancellation restock it and updateInventory skip the line.
  • Refuse by returning, not throwing. claim returns ClaimResult.refused(activityCode, unavailable). The step then releases everything claimed earlier in the run (of any kind), writes the unavailable list to the kind's refusalProperty(), releases the card authorisation through SubmitFailurePaymentReleaser (outcome stored under <refusalProperty>Payment), saves the order and stops the pipeline with VALIDATION_FAILED. The order stays a basket.
  • Issue never stops the pipeline. The customer has paid. Return IssueResult(complete=false, ...) (or throw) and the step reports ticketsIssueFailed, logs the line and carries on so the confirmation still goes out.
  • Cancel does not restock; refund does. On CANCELLED the cancellation restock step restores what stockTaken records. On REFUNDED the 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:

  • claim only checks: the booking must belong to this order, be PENDING_PAYMENT and not past its paymentDueBy, and it returns ALREADY_CLAIMED (it does not renew heldSince). A paid one retried is also ALREADY_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.
  • release does nothing: the hold is the request's, so a declined card leaves it for the customer to try again.
  • issue and 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.submitOrder calls BookableClaims.releaseAll (every bookable line, through the registered kinds' idempotent release) 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 in PAYMENT_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's BookingHoldReleaseJob, every five minutes) lists PENDING_PAYMENT appointments whose heldSince is older than the site's appointment-hold-release-minutes (default 30, minimum 5; dining-config), loads each one's order through OrderService, 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.claim renews heldSince when 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 their paymentDueBy has 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

  1. Implement BookableKind as a Spring @Component in commerce-core. That is the whole registration: the three steps take List<BookableKind> by injection.
  2. Decide handles(line), usually "the line carries my product decorator".
  3. Pick a refusalProperty() (for example appointmentSlotsUnavailable) and make sure checkout can explain a refusal from it.
  4. Implement claim/release (record what you took on the order), issue, voidOrder and voidLine.
  5. Test it as the dispatcher tests do (BookableKindDispatchTest has 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.