Orchestra integration¶
Orchestra is a lightweight,
plugin-based workflow engine. Booking pairs with it naturally: Booking owns
capacity, Orchestra owns the process (the steps, timers and approvals
around a reservation). The yoyaku_orchestra submodule provides the glue.
Task types¶
yoyaku_orchestra ships Orchestra #[TaskType] plugins that call the
booking API as workflow steps:
| Task type | Calls | Use |
|---|---|---|
| Hold | hold() |
Reserve capacity when the process starts. |
| Confirm | confirm() |
Commit on approval / payment. |
| Release | release() |
Free capacity when somebody cancels. |
And, for a step whose deadline is the basket's, one deadline provider and one timeout action:
| Plugin | Does | Use |
|---|---|---|
yoyaku_basket_deadline |
Answers when the order's basket window runs out. | As a step's deadline. |
yoyaku_basket_lapsed |
Expires the order, if its window has really run out. | As that step's task timeout action. |
The hold clock is the order's¶
An instance does not take the basket's clock, and there is no step that lets it. The
window belongs to yoyaku_transaction.hold_expires and stays there for the
whole run, which is what lets it be renewed as the basket changes even after
the workflow has started - a booking added at the payment step re-arms it
exactly as one added before the form did.
The invariant: a step may carry a deadline derived from the basket's, and may
observe that it has passed, but never decides it. A wait node whose window is
the basket's names yoyaku_basket_deadline as its deadline, and the provider
is asked as the step parks. An order that is off the clock - a realm stating no
window, a checkout holding it frozen, an order holding nothing - answers
nothing, and a step deriving its deadline from nothing parks without one and
waits to be resumed. That is the right reading of all three: none of them ends
by running out of time.
Because it is asked rather than read, no step exists to keep it current and nothing goes stale between computing it and waiting on it. A basket renewed while the step waits moves the deadline, and the step is asked again the next time its timeout fires without resuming it, so it follows the window rather than being left with none. The timer is still an optimization, not the authority: it may wake early, find the basket alive and disarm, and the real lapse is caught by the sweep.
yoyaku_basket_lapsed is the timeout action, and it decides nothing. It
calls the one expireTransaction() that cron calls, which re-reads the state and
the deadline under the order's row lock and does nothing at all if the basket is
still running. So a step that wakes early because the basket was renewed under
it costs that basket nothing.
Whoever notices a lapse - the cron sweep, the step's own timeout, an operator emptying the order - goes through that one function, so the end state cannot depend on who got there first. A site whose cron runs every ten minutes ends a lapse exactly as a site whose cron runs every minute does.
Ending the instance is BookingReconciliationSubscriber's, on the EXPIRED the expiry
announces. Where a step is waiting on the basket it resumes that step with an
expired outcome, so the model routes the lapse on a branch anybody can see;
where nothing is waiting, because the instance is past that step or between two, it
tears the instance down as every other terminal transition does. One subscriber
decides, so a resume and a cancel cannot race each other.
An instance that ends still owes the units a release, and
BookingTerminationSubscriber covers the instance that is canceled or fails. A
lapse needs no such backstop: the bookings are already expired by the time
the instance is torn down, so the releaser finds nothing held and stands down.
Nothing of this reaches the engine: yoyaku announces that a booking was held and knows nothing of workflows.
Only a decision reaches the release¶
The shipped model sends neither timeout to yoyaku_cancel_order.
A form step that lapses ends at its own end node. A basket running out is
not a cancellation: nobody decided anything, the order ends empty and stays
reusable, and the instance ends normally, which also gives "your basket expired"
somewhere to be said.
A payment step whose window lapses returns to the form. Nobody said they had finished with it either, and the release rides on the payment's own event, which restores the window the checkout suspended verbatim - so a booker who had forty minutes left when they went to pay comes back with forty minutes and a basket still intact. The dead case needs no special handling: the form step parks on a deadline already past, the sweeper fires it, and the instance leaves by the one expiry route.
So n_release is reached only by somebody choosing to cancel.
Order variables¶
Almost every branch in a booking workflow routes on something derived from the order: how it settles, whether it may be self-canceled, what guarantee it takes. None of that is baked at start, because the basket can still change afterwards. It is computed at a variables node (Orchestra's task type of that name) placed once the order is final, just before placement, so each branch reads the order as it stands there.
Yoyaku ships one VariableProvider plugin per rule, each resolving the instance's
order from the instance:
| Provider | Writes | Meaning |
|---|---|---|
yoyaku_arrival |
yoyaku_arrival |
Earliest slot start across the active bookings; anchors the guarantee and cancellation timers. |
yoyaku_auto_confirm |
yoyaku_auto_confirm |
Whether the order confirms itself or waits for operator validation. |
yoyaku_settlement |
yoyaku_settlement |
The settlement mode shared by every active booking, when they agree. |
yoyaku_cancellation |
yoyaku_allow_cancellation, yoyaku_cancel_deadline |
Whether the booker may self-cancel, and until when. |
yoyaku_guarantee |
yoyaku_guarantee_amount |
The held guarantee total, when the order carries one. |
A node selects the providers it wants, so a model takes only the variables it branches on, and the workflow config records which modules it depends on. A provider whose rule does not apply to this order contributes no variable at all: the branch gated on it is simply not taken. An instance that carries no order at all stops at the node with an incident instead (see Subprocesses). See Computed variables in the Orchestra documentation for the mechanism.
Subprocesses¶
Say the booking workflow for order 42 calls a "Collect the guest list"
subprocess, which shows a booking form and confirms the order. Embedded, the
subprocess runs inside the booking instance and shares its variables, so it
knows the order. Run as an instance of its own, it starts with only the
variables the subprocess node passes in. So pass the order down: add a line
yoyaku_transaction_id to the node's input variables.
Nothing is passed down on its own, and that is deliberate. You choose which subprocesses act on the order, and a subprocess on another site never receives an id that means nothing there.
A booking step that runs in an instance carrying no order cannot do anything right. A confirm would confirm nothing, and a gateway would branch on variables nobody computed. So the step stops with an incident naming the variable to pass down, rather than letting the workflow go on as if it had worked. The booking steps are the order actions, the payment actions, the variables providers above and the basket deadline. The payment page is the exception: a booker is looking at it, so an instance with no order shows it with nothing to pay.
A branch can work on another order than its instance: when its token carries
yoyaku_transaction_id as a variable of its own, the booking steps and the
order pages on that token act for that order, as Orchestra resolves the
variable for them. The booking form does the same: it checks the policies of,
and captures the answers onto, the order its step's token sees.
The order keeps one owner: the instance it started with. That is the instance the order's signals and links reach, the one deleted with the order, and the only one whose cancel or failure releases the order's places. A subprocess given the order's id acts for it without taking it over, and so does any other workflow started with that id while the owner runs. Once the owner has ended, the next instance started with the id becomes the owner.
When the order is confirmed, the booking form submissions of every instance acting for it are sealed alike: the owner, a subprocess given its id, and any other workflow started with it.
Example: payment-gated reservation workflow¶
flowchart TD
START([Start]) --> FORM[User task: fill form<br/>⏱ the basket window, asked as it waits]
FORM -->|submitted| PAY[Payment task<br/>⏱ the payer's window]
FORM -->|expired| LAPSE[The basket lapsed]
FORM -->|canceled| REL[Release]
PAY -->|paid| CONF[Confirm booking]
PAY -->|expired| FORM
PAY -->|back| FORM
PAY -->|canceled| REL
CONF --> DONE([Confirmed])
LAPSE --> EXPIRED([Expired])
REL --> CANCELED([Canceled])
The form step's deadline is the basket window, and it is asked for as the
step waits, through the yoyaku_basket_deadline deadline provider. The window
belongs to the order rather than to the instance - a booker who adds a place renews
it - so a value recorded once and carried in the model would describe a moment
that has since moved. Asking also means the step can be asked again: one woken
on a window the booker has extended waits on the new one instead of being left
with no deadline at all. The payment step's window is its own, because it sits
in the locked phase where nothing refreshes: locking suspends the basket window
and unlocking gives back that same timestamp, so a lapsed checkout returns the
booker to a basket with exactly the time it had left.
Neither step releases anything itself. The payment step's timeout runs
payment_lapsed, which makes the payment lapse for real, and the release rides
on the payment's own event, exactly as it does when the reaper notices first.
That is why the diagram shows no unlock step: it would be one half of a bracket
whose other half, taken when the payer was handed off, was never drawn. Only an
explicit cancellation reaches Release.
Both roads back from payment return straight to the form, because there is no number to refresh on the way. This is the two-phase payment flow expressed as a workflow - the booking engine stays phase-agnostic while Orchestra sequences the phases.
Payment workflow actions¶
yoyaku_orchestra_payment exposes the guarantee operations as Drupal
Action plugins, so each is a step you drop into an Orchestra action node
(the orchestra_action task type) and configure in the modeler. The action
resolves the order from the running instance (which carries the transaction id
as the yoyaku_transaction_id process variable) and delegates the money
operation to Kessai's payment client, so the gateway stays the engine's
concern.
| Action | Does | Key settings |
|---|---|---|
| Authorize guarantee | Holds the guarantee, writing yoyaku_guarantee_outcome (held / declined / none) for branching. |
gateway |
| Claim guarantee (damage) | Keeps the whole guarantee, or a set amount, and gives back the rest. | gateway, amount |
| Release guarantee | Gives the guarantee back, keeping nothing (or an amount set on the node). | gateway, keep |
| Claim the no-show fee | Keeps the resource's no-show fee out of the guarantee and returns the rest. | gateway |
Every action takes a gateway setting (the gateway plugin id, e.g.
worldline or manual); the claim and release steps add an amount. The no-show
fee amount is not on the node: it is the resource's noshow_fee field
(resolved per booking, summed across the order), so the fee is a pricing
decision that lives with price and the guarantee, not in the workflow. The
attendance decision itself is an ordinary user task (outcomes
attended,damaged,no_show), and these actions hang off its branches.
Deferring the hold with an Orchestra timer¶
A guarantee covering damage only needs to be valid during the stay, and
a card authorization expires in roughly 7 to 30 days, so for a booking made
months ahead a hold placed now would lapse before arrival. The hold is therefore
deferred with a Wait node + an Orchestra "until" timer, not a separate cron:
the variables step produces a yoyaku_arrival process variable (the earliest
slot start), and the wait's timeout is configured until: yoyaku_arrival with
until_offset: -P3D (three days before). Orchestra's own timer resumes the wait
near arrival and the flow runs Authorize guarantee; a short-lead booking whose
date has already passed simply fires at once. What bridges the long gap between
booking and that hold is the card token captured at checkout. See
the guarantee and gateway design for the full
rationale (tokenization, deferred holds, no-show, re-authorization).
The shipped example¶
Every exit taken after a payment has succeeded shares one path, so the money is always handed back the same way: notify, refund, cancel the order. Two outcomes reach it, an operator rejecting the booking and a confirmation that fails (an order constraint violated by a race between the hold and the confirm), and the second joins after the notification because the confirm action has already told the booker itself. The exits that mean no payment ever succeeded, the registration form and the payment step timing out or being canceled, go straight to canceling the order: there is nothing to give back.
None of those paths releases a guarantee, because none can hold one: the branch that authorizes it is reached only through a successful confirmation. The release belongs to the cancellation path, which runs after confirmation, and is there.
The yoyaku_orchestra_example yoyaku_booking workflow wires these in after an
order is confirmed:
flowchart TD
CONF[Confirm order] --> PREP[Compute refund deadline]
PREP --> FORK{{Fork}}
FORK --> CW[Await cancellation<br/>timer: until slot start<br/>booking_cancel interaction]
FORK --> WAIT[Await guarantee window<br/>timer: until arrival -P3D]
WAIT --> AUTH[Authorize guarantee]
AUTH -->|declined| MGR[Notify managers]
AUTH -->|held / none| DONE[Guarantee in place]
MGR --> DONE
CW -->|canceled / timeout| JOIN{{Quorum join}}
DONE --> JOIN
JOIN -->|not canceled| ATT[Record attendance]
ATT -->|attended| MC[Mark order completed] --> REL[Release guarantee]
ATT -->|damaged| MCD[Mark order completed] --> CAP[Claim guarantee]
ATT -->|no-show| MU[Mark order unused] --> FEE[Claim no-show fee]
FEE --> OK([End: confirmed])
REL --> OK
CAP --> OK
JOIN -->|canceled| RH[Release hold] --> CO[Cancel order] --> RG{Refund due?}
RG -->|yes| RV[Operator: approve refund]
RG -->|no| NC[Notify: canceled]
RV -->|approve| RF[Refund] --> NC
RV -->|deny| NC
NC --> KO([End: canceled])
Once confirmed, the instance forks into two concurrent branches that rejoin at a value-aware (quorum) join:
- the guarantee branch: a timer waits until near arrival, then holds the
guarantee; a declined hold routes to a manager-notification task. With the
manualgateway this is a safe no-op; with Worldline it places a real hold; - the cancellation branch: an interaction the booker reaches from a link in their confirmation email (see below). Cancellation is always allowed, right up to the slot start.
Fulfillment (recording attendance, then releasing or claiming the guarantee) happens after the join, on the not-canceled path. Because the join resolves only once the cancellation window has closed at the slot start, a booking can never be both attended and still cancellable: the point of no return strictly follows the window, and canceling an already-attended booking is impossible by construction.
Recording attendance also sets the order's terminal state: attended and damaged mark it completed, a no-show marks it unused. These are order and booking states in their own right (alongside pending, confirmed, partial and canceled), so a fulfilled booking is told apart from a no-show long after the slot. Dropped bookings never count against them: an order stays completed even when the holder canceled some of them.
If the booker cancels, the join fires early and tears down the guarantee branch (voiding any authorized hold via a release-hold step), then cancels the order and runs an operator refund review only when a refund is due (a real payment was captured and the cancellation is within the refund deadline). If the window instead times out at the slot start without a cancellation, the instance continues down the guarantee branch to its confirmed end.
Booker notifications¶
The workflow's "Notify about an order" action hands the milestone to
yoyaku_order's notifier, which sends the booker email exactly as it does for an
order placed without a workflow: see Booker emails for the
templates, the tokens and when the email goes. The instance's
yoyaku_refunded_amount makes the message mention a refund, and its
orchestra_notification_template_suffix asks for a template variant.
Staff steps (operator validation, guarantee-hold-failed, attendance, refund
review) are separate: they notify the resource managers, not the booker. Each
is an interaction_task carrying the order_review interaction, so the
operator is shown the order (its summary, and any constraint-policy violations)
before choosing an outcome, rather than deciding blind. Because the review is an
interaction, the same screen works whether the step is reached from the inbox,
a pull operation, or a signed link.
Canceling a confirmed booking¶
A confirmed booking is canceled two ways, both running the graceful workflow
path (guarantee release, refund gate, cancellation notice) rather than a blunt
state change. They are the two arms of a cancel fork feeding one quorum join:
the first arm to signal canceled wins, and if all arms time out the booking
stays confirmed, so neither can double-cancel.
Booker self-cancel. The confirmation email carries the cancel link.
Because the link is a signed, token-scoped URL that only exists once the cancel
step has parked, the confirmation is sent when that step opens (a
TokenParkedEvent subscriber), and it renders a "Cancel booking" button plus
the clickable link. Clicking lands the booker on a confirmation screen (with the
order summary, and a notice when the cancellation is past the refund deadline)
rather than canceling on arrival, so an email client prefetching the link
cannot cancel a booking; only the explicit confirm signals canceled. The
booker may self-cancel until the resource's cancellation_deadline (+ unit):
the workflow resolves it (earliest slot start minus the strictest resource
cutoff) into a yoyaku_cancel_deadline variable and the booker wait times out
then, so a link replayed after the cutoff hits a token that is no longer parked
and is a no-op. A zero cutoff keeps self-cancel open to the slot start.
Operator graceful cancel. An operator assigned to the resource
(the resource_managers audience, which yoyaku_orchestra_manager ships) can
cancel up to the slot start through the n_cancel_window_operator node, a
comment_operation that requires a cancellation comment before it records
canceled. This is the graceful, workflow-driven cancel; it is distinct from
the admin full/forced cancel in the orders operations column, which
hard-cancels outside the workflow.
The refund gate is separate. refund_deadline (+ unit) decides only whether
a cancellation is refunded (resolved into a yoyaku_refund_deadline
variable): canceling before it is refunded, later is allowed but not refunded.
cancellation_deadline bounds whether the booker may still self-cancel;
refund_deadline bounds whether the cancellation is refunded. They are
independent, so a resource can, say, refund up to two days out yet stop online
self-cancellation five days out.
Surfacing the operator window. The orders overview shows a Workflow
column listing the current operator's assigned operations for each order (the
graceful "Cancel booking" when assigned and before the slot starts), read from
orchestra_ui's pending-actions surface and kept separate from the standard
lifecycle operations column so the graceful cancel is never confused with the
admin full cancel. The column is contributed by yoyaku_orchestra_order through
the hook_yoyaku_order_overview_columns / hook_yoyaku_order_overview_cell
extension point, which any module may use to add its own order column. Before
any cell is built, hook_yoyaku_order_overview_prepare hands the module the
whole page of orders. The Workflow column uses it to read the operator's
actions on every order of the page in one query, instead of one query per row.
Overriding an email template¶
The booker emails are yoyaku's (see above): yoyaku_order_easy_email sends the
first of yoyaku_<tenant>_<type>_<suffix>, yoyaku_<tenant>_<type>,
yoyaku_<type>_<suffix> and yoyaku_<type> that exists. The rest of this
section is about the workflow's own notifications, such as a task assigned to
an operator.
orchestra_easy_email resolves a template with a Drupal-suggestion-style
cascade, most specific first, over the instance tenant, the workflow,
the notification type, and an optional suffix:
orchestra_<tenant>_<workflow>_<type>_<suffix>
orchestra_<tenant>_<workflow>_<type>
orchestra_<tenant>_<type>_<suffix>
orchestra_<tenant>_<type>
orchestra_<workflow>_<type>_<suffix>
orchestra_<workflow>_<type>
orchestra_<type>_<suffix>
orchestra_<type>
orchestra_generic
Empty dimensions are skipped and the first existing template wins. The tenant
comes from the instance, so per-tenant (for example per-domain) templates need
no wiring: ship orchestra_<tenant>_booking_confirmed and it is used for that
tenant's bookings. The suffix comes from an
orchestra_notification_template_suffix process variable for any other
per-context variant (orchestra_generic_vip). Everything falls back
to the plain orchestra_<type> default with no code.
Division of responsibility¶
flowchart LR
subgraph O[Orchestra]
P[Process / steps / timers / approvals]
end
subgraph B[Booking]
CAP[Capacity / holds / availability]
end
P -->|hold / extend / confirm / release| CAP
CAP -->|lifecycle events| P
Booking can also react to nothing in Orchestra and simply be driven by it;
or a booking event (e.g. CONFIRMED) can in turn signal an
Orchestra token. The two compose in either direction.
Where a booker notification is rendered¶
A booking is made on one surface and confirmed on another: an operator validates from a back office, cron releases a hold with no request at all, a queue worker mails hours later. Everything the confirmation is built from would otherwise resolve against whichever request is running, so the cancel link, the site name and the ticket PDF's branding would be that of the surface doing the work rather than the one the booker booked on.
Orchestra records where each instance started, and the order notifier dispatches the whole booker notification there. That single wrap covers more than the message: an attachment enricher runs during the same dispatch, so the ticket PDF, its QR code and its logo follow too.
Operator-facing notifications deliberately do not follow the booking. A task waiting in an inbox links to the inbox, wherever that is; Orchestra calls that place the workspace. See Orchestra's own notification documentation for how the two places are configured, and install Orchestra Domain on a Domain Access site to give them meaning.