Cart (yoyaku_cart)¶
yoyaku_cart gives a visitor one basket across every booking surface. A
calendar hold, a place picked on a venue map and a quantity taken on a slot form
all append into the same order, so a session builds one basket and pays once,
instead of one order per surface.
The module is deliberately thin: the basket itself lives in the core, as the
yoyaku.booking_cart service. yoyaku_cart adds the page a booker reviews it
on, and the seam that hands the basket off at checkout.
The basket is a pointer, not a container¶
BookingCart (yoyaku.booking_cart) holds no bookings of its own. It records
which pending transaction is "mine" for this session, and every hold placed while
browsing appends into it through the engine's hold() "into" argument. The
bookings stay where they belong, on the transaction.
That has two consequences worth knowing:
- A booking form can ask the cart what the session already holds for a slot
(
getHeldQuantities()) and prefill its quantities, rather than always starting from zero. Re-submitting the form adjusts what is already held instead of stacking new ones. - Nothing has to be reconciled at checkout. The basket is the order, so checkout hands over a transaction that already exists and is already holding capacity.
The cart tracks that order from the editable stage (empty or pending) through
checkout (locked), which is what lets a booker who steps away mid-payment be
returned to it. Once the order is finished for the session (placed, confirmed,
canceled or gone) the pointer is dropped, and the next hold begins a fresh
basket. current() exposes only the editable basket; getActiveOrder() exposes the
tracked order whatever stage it is at.
The pointer itself is a single order id in the private tempstore, so a
basket is scoped to the session and survives a page reload without requiring an
account. It is read at most once per request: core does not cache the tempstore,
so every get() is a query, and a surface asking the cart several questions
would otherwise read the same row once per question. adopt() and forget()
are the only ways the pointer changes, and both keep the held value in step.
Holding it is also the more correct reading, since the tempstore is shared
across requests: a request that re-read it could act on one basket for its first
question and another for its third.
Separately, yoyaku.held_cookie projects the session's held units into
a small client-read cookie so a place map can shade "yours" apart from "taken"
without a round trip. That cookie is a projection of the live basket, never a
parallel store, and the engine re-checks capacity on submit regardless, so
tampering with it misleads only the visitor's own display.
The cookie is rewritten on any request that changes a booking, and also whenever a booking surface is drawn. The second is what keeps it honest when the visitor is not the one who ended the basket: a workflow confirms an order in its own request, or cron does, and neither can touch the visitor's browser. Nor can the client expire the overlay on its own once a workflow has taken the holds off the clock, because a basket whose realm states no window carries no deadline to expire by. So each drawn surface re-asks what the session holds; for a basket that has moved on the answer is nothing, and the stale cookie is cleared. Without it a finished booking keeps adding its own units back to a day the feed correctly reported as full, and the visitor picks a day the engine then refuses.
How long a basket lives¶
A basket is held for a window, and it is the order that carries it, so the whole basket lives and lapses together rather than crumbling one booking at a time. The first booking starts the window; taking another renews it, where the realm says to; giving one back renews it too; giving the last one back takes the order off the clock entirely.
How long, and whether changing the basket renews it, resolves channel → tenant → site, and a ceiling bounds how far renewal may reach so a basket cannot be kept alive for ever by touching it. An empty window means the basket never lapses, which is how a realm that approves its bookings by hand says so. See Hold expiry for the keys and the sweep.
Going to payment does not spend basket time twice: locking the order suspends the window and unlocking gives back the same deadline, so a booker who is declined returns to the basket they left.
Renewal is cheap by design: the new deadline is written once it has moved by a twentieth of the window, not on every click, while the countdown the booker sees always shows the window they just earned. See Renewal writes are throttled.
A basket on a realm that leaves the window empty carries no deadline at all, so
nothing lapses it and an abandoned one holds its places for ever. abandon_after
ends those after a few hours, on the grounds that an abandoned basket is worth
nothing and its places are worth a great deal. A booking submitted for approval
is unaffected: it is placed rather than pending, and is never auto-released.
See The cost of a hold that never lapses.
An order nobody comes back to is not kept for ever. An emptied one is deleted after a week and a canceled one after a month, unless a workflow run or a payment record still names it. See Keeping orders.
The cart page¶
/booking/cart renders the basket through the shared
transaction summary: one card per
slot, a row per ticket, and the total once a pricing module has filled it. It is
gated by the same place booking permission as the calendar and the per-slot
form, so it is public wherever booking is.
From the page a booker can remove a single ticket, remove a whole slot at once, or check out. Removing releases the held capacity immediately rather than waiting for the basket to lapse, and re-arms the basket window with it: the basket changed, so its clock starts again under the realm's renewal rule.
The page is one visitor's basket and nothing else, so it declares the session
cache context, and declares it before the basket is read: an empty cart is this
visitor's answer as much as a full one, and a shared render of "your cart is
empty" would be exactly as wrong as a shared render of somebody else's tickets.
A form says nothing per-visitor on its own, because the token core adds to a
form is added only for a visitor who has logged in.
Both are refused once the order is frozen. While a payment is in flight the basket is frozen, so a page left open in another tab cannot take a ticket out from under the amount being paid. See the order lock.
A locked order is no longer the basket, so the page does not call the cart
empty: it says the order is being completed and cannot be changed, and is never
cached while it does, since the lock ends in a request that is not the
booker's. A module that can lead the booker back to their order adds the way to
the form; yoyaku_orchestra_payment adds "Return to your order" while the
order's workflow waits on the booker's payment.
What a rule can put in the booker's way¶
The cart page is the only place a basket is committed. The seat map offers a "Go to cart" link and nothing more, deliberately: this page is where a rule gets to ask the booker something before they go on, and a second way to commit would be a way around it.
What gets asked comes from the constraint
policies attached to each slot's resource. A policy
that needs the booker to answer something implements BookerQuestionInterface,
and the cart asks every attachment of that slot's resource what it wants shown:
- the fields land directly below that slot's own card, because a policy is attached to a resource and so speaks for one slot. A basket holding two evenings in two houses may be asked about one of them, about both in different words, or about neither;
- a field that must be answered is required, so the browser refuses the submit natively and the server refuses it again if that is bypassed. A booker asked about two evenings answers twice;
- a basket nothing is attached to adds nothing to the page, and builds no plugin at all: the attachments are matched against the interface from the cached plugin definitions before anything is instantiated.
The Checkout button is never disabled and never conditioned. A control that
will not press reads as a broken page, and a booker who has missed one of two
boxes learns more from being told which than from a button that does nothing. So
the button stays live and the refusal is the required control's: #required puts
required="required" in the markup, so the browser stops the submit and points at
the box, and core refuses it server-side if native validation is bypassed. The
question's own styling turns loud once the form has come back refusing it, which
is how a booker sees which box they missed.
Asking here rather than at the payment step is the whole point of the placement. The payment page comes after the registration form, so a question there would have the booker fill in their details and only then learn their seats are apart; and a booking that owes nothing never reaches a payment step at all.
OrderAcknowledgements builds the one shape of control for "accept this before
you go on", so a module contributing one does not compose its own: the sentence,
the word "(required)" in the visible label, and a box that carries #required. A
policy may contribute any form element it likes, but a question shaped like every
other question is one an operator only has to learn once, and the shared one has
the accessibility requirements
already met. The first policy to use it is
places that are apart.
Checkout is a seam, not a destination¶
The cart does not know what happens after checkout, and that is on purpose. On
submit it dispatches a CartCheckoutEvent carrying the order, and a listener
either sets a redirect URL on it or does not:
- if something sets a redirect, the booker is sent there;
- if nothing does, the cart reports that the basket is held and leaves the booker on the page.
So a site with no workflow at all still has a working basket, and a site that drives bookings some other way can route checkout wherever it likes by subscribing to one event.
yoyaku_orchestra_cart is the listener that ships with Yoyaku. It starts the
order's booking workflow explicitly (the workflow has no automatic start
trigger), finds the instance the correlator just created, and redirects the
booker into its first interactive step, which is normally the form or payment
step of the shipped example workflow.
Filling the cart from a surface¶
A surface does not talk to the cart directly. It books through
yoyaku.booking_manager, the one booking path, which appends into the session's
basket for it. That is why the calendar, the slot booking form and the venue
place map all end up in the same order without any of them knowing the others
exist.
See also Placement for the place map that fills the basket with numbered or pooled places, and Orchestra integration for what happens to the order once checkout hands it over.