Skip to content

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 booking_orchestra submodule provides the glue.

Task types

booking_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.
Extend extendHold() Move from a form window to a deposit window.
Confirm confirm() Commit on approval / payment.
Release release() Free capacity on rejection / timeout.

Example: deposit-gated reservation workflow

flowchart TD
    START([Start]) --> HOLD[Hold capacity<br/>ttl = form window]
    HOLD --> FORM[User task: fill form<br/>⏱ timer]
    FORM -->|submitted| EXT[Extend hold<br/>ttl = deposit window]
    FORM -->|timeout| REL1[Release]
    EXT --> PAY[Payment task<br/>⏱ timer]
    PAY -->|paid| CONF[Confirm booking]
    PAY -->|timeout / fail| REL2[Release]
    CONF --> END([Confirmed])
    REL1 --> ENDX([Cancelled])
    REL2 --> ENDX

Each timed step uses an Orchestra timer; on timeout the workflow routes to a Release task, which frees the capacity. This is the two-phase deposit flow expressed as a workflow - the booking engine stays phase-agnostic while Orchestra sequences the phases.

Payment workflow actions

yoyaku_payment_orchestra exposes the deposit and no-show 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 the payment manager, so the gateway stays the engine's concern.

Action Does Key settings
Authorize deposit Holds the deposit, writing yoyaku_deposit_result (held / declined / none) for branching. gateway
Capture deposit Claims the held deposit, in full or a set amount (damage). gateway, amount
Release deposit Voids the held deposit (no claim). gateway
Charge no-show fee Charges the resource's no-show fee against the stored card. gateway

Every action takes a gateway setting (the gateway plugin id, e.g. worldline or manual); capture adds an amount. The no-show fee amount is not on the node: it is the resource's noshow_fee payment-block field (resolved per booking, summed across the order), so the fee is a pricing decision that lives with price and the deposit, 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 deposit is a damage hold that 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 booking starter seeds 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 deposit; 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 deposit and gateway design for the full rationale (tokenization, deferred holds, no-show, re-authorization).

The shipped example

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 deposit window<br/>timer: until arrival -P3D]
    WAIT --> AUTH[Authorize deposit]
    AUTH -->|declined| MGR[Notify managers]
    AUTH -->|held / none| DONE[Deposit pre-authorized]
    MGR --> DONE
    CW -->|cancelled / timeout| JOIN{{Quorum join}}
    DONE --> JOIN
    JOIN -->|not cancelled| ATT[Record attendance]
    ATT -->|attended| MC[Mark order completed] --> REL[Release deposit]
    ATT -->|damaged| MCD[Mark order completed] --> CAP[Capture deposit]
    ATT -->|no-show| MU[Mark order unused] --> FEE[Charge no-show fee]
    FEE --> REL
    REL --> OK([End: confirmed])
    CAP --> OK
    JOIN -->|cancelled| RH[Release hold] --> CO[Cancel order] --> RG{Refund due?}
    RG -->|yes| RV[Operator: approve refund]
    RG -->|no| NC[Notify: cancelled]
    RV -->|approve| RF[Refund] --> NC
    RV -->|deny| NC
    NC --> KO([End: cancelled])

Once confirmed, the run forks into two concurrent branches that rejoin at a value-aware (quorum) join:

  • the deposit branch: a timer holds until near arrival, then pre-authorizes the deposit; a declined hold routes to a manager-notification task. With the manual gateway 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, capturing or charging the deposit) happens after the join, on the not-cancelled 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 cancelling 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 cancelled), so a fulfilled booking is told apart from a no-show long after the slot. Dropped lines never count against them: an order stays completed even when the holder cancelled some of its lines.

If the booker cancels, the join fires early and tears down the deposit 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 run continues down the deposit branch to its confirmed end.

Customer notifications

Customer emails go out at three milestones: confirmed, released (a hold abandoned before confirmation) and cancelled (a confirmed booking undone). They are built by the yoyaku_orchestra_order OrderNotifier, which does not send mail itself: it dispatches Orchestra's channel-neutral notification for the running instance, to the order's customer_email, with the order attached as [yoyaku-order:*] token data and the instance's process variables in the event context, so whichever channel a site enables delivers it. orchestra_mail sends a plain message; orchestra_easy_email renders the rich booking templates the submodule ships (orchestra_booking_confirmed / _released / _cancelled).

Staff steps (operator validation, deposit-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.

Cancelling a confirmed booking

A confirmed booking is cancelled two ways, both running the graceful workflow path (deposit 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 cancelled wins, and if all arms time out the booking stays confirmed, so neither can double-cancel.

Customer 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 cancelling on arrival, so an email client prefetching the link cannot cancel a booking; only the explicit confirm signals cancelled. The customer 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 customer 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) 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 cancelled. 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): cancelling before it is refunded, later is allowed but not refunded. cancellation_deadline bounds whether the customer 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.

Overriding an email template

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_booking_confirmed_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.