Paying for a booking¶
The engine is payment-agnostic. It only knows held vs confirmed;
who calls confirm() and when is a consumer concern. This cleanly models
the common "pay an advance to confirm" flow - including the two-phase
window (a short window to fill the form, then a longer one for the card
payment) the legacy reservation system used.
The mapping¶
| Payment concept | Booking model |
|---|---|
| Cart hold / awaiting payment | held bookings on a pending order carrying hold_expires |
| Form-filling window | the order's hold_ttl, renewed as the basket changes |
| Payment window | the order goes locked, and its basket window is suspended |
| Paid / issued | confirm() |
| Abandoned / failed / timed out | release(), or the window lapses → expired |
Two-phase payment flow¶
sequenceDiagram
actor User
participant App as Booking flow (consumer)
participant BM as BookingManager
participant TM as TransactionManager
participant Pay as Payment provider
User->>App: pick slot + tariff
App->>BM: hold(asks)
BM-->>App: an order, held, on its basket window
User->>App: add another booking
App->>BM: hold(asks, into: order)
Note over BM: the window starts again, up to the ceiling
User->>App: submit registration form
App->>TM: lockTransaction(order, payment window)
Note over TM: the basket window is suspended, and recorded
User->>Pay: pay the advance
alt payment ok
Pay-->>App: success callback
App->>TM: confirmTransaction(order)
TM-->>App: confirmed ✅
else declined
App->>TM: unlockTransaction(order)
Note over TM: back to pending at the deadline it had
else abandoned
Note over TM: the lock lapses, then the basket window does
end
The two windows are never concurrent, which is what keeps them from competing: while the order is locked its basket window is suspended, and unlocking gives back the same timestamp, so time at the provider is time spent and going to payment and back wins no extra window. The engine stays phase-agnostic; the phase durations and sequence live in the consumer (often an Orchestra workflow, whose form step derives its timer from the basket deadline rather than setting one of its own).
Pay first, then operator validation¶
A common requirement: an operator manually validates a booking, but only once it has been paid. This is the two-phase hold again - the second phase is "awaiting operator" instead of "awaiting payment":
hold(asks)→heldbookings on apendingorder; the basket lapses if nothing is paid.- Payment confirmed →
placeTransaction(order)→ the order is frozen awaiting confirmation and its window stops: it is paid and now waits for an operator. The payment layer records the payment and marks the order awaiting validation. - The operator reviews only paid bookings - the "awaiting validation"
queue is a payment-layer query (held bookings whose payment is confirmed) -
and either
confirm()s (approve) orrelease()/cancel()s (reject). - A rejection dispatches
BookingEvents::RELEASEDorCANCELED, which the payment layer turns into a refund.
sequenceDiagram
actor U as Booker
participant App as Booking flow
participant BM as BookingManager
participant Pay as Payment layer
actor Op as Operator
U->>App: book
App->>BM: hold(asks)
BM-->>App: held, on the basket window
U->>Pay: pay the advance
Pay-->>App: payment confirmed
App->>BM: placeTransaction(order)
Note over BM,Pay: held + paid = awaiting validation
Op->>App: review paid bookings only
alt approve
App->>BM: confirm()
BM-->>App: confirmed ✅
else reject
App->>BM: release() / cancel()
BM-->>Pay: Released/Canceled event → refund
end
The booking stays held throughout (capacity reserved from the first click
until the operator decides, so it is never overbooked). "Paid" is
payment-layer state, never in the core - the operator's confirm() is the
single commit; payment confirmation only takes the order off the clock.
Whether a resource auto-confirms or waits for an operator is a
consumer-layer policy - an auto_confirm field that yoyaku_orchestra
adds to the resource, not a core concern. The booking workflow reads it: when
on, it confirms the order once payment settles; when off, it routes to an
operator validation step first. (With no workflow, yoyaku_payment's default
settlement subscriber confirms the held bookings on a settled payment.) The
engine exposes only confirm()/release()
- it never decides when to call them.
Where the amounts live¶
Two distinct values, neither in the capacity core:
flowchart TB
subgraph core[booking core]
CAT[Tariff] --- ST[Booking.state held→confirmed]
end
subgraph pay[yoyaku_payment]
DEP[payment settings: price + guarantee<br/>on Resource, price per-tariff]
TXN[Kessai payment<br/>records the charged amount]
end
DEP -. configures .- CAT
TXN -. references .- ST
- Configured amounts (the price, how much to collect online now, and any
guarantee) - the payment settings
yoyaku_paymentattaches to the Resource, of which a Tariff overrides the collection and the no-show fee. The price is not an override: a tariff's price is required, so a resource that offers by tariff never falls back to its own price, and which of the two applies stops depending on whether a field was filled in. The resource's price answers only for a resource that offers no tariffs. The guarantee belongs to the resource alone, and how it is taken, claimed and given back is its own page. - Agreed amount for a specific booking - recorded on the booking itself,
as
unit_price, stamped from the chain above as the booking is held. A booking is a requirement about the past, so it is stored rather than recomputed: without the stamp, repricing a tariff moved what an existing order said it cost, what a notification quoted and what an unpaid transaction was charged, while the money already taken could not move. - Charged amount for a specific booking - recorded on the Kessai payment, whose subject is the order, not derived from config, so historical bookings stay accurate when prices change later.
- Deriving prices a thing that is bookable; the stamp describes a thing that
was booked. So a calendar or an offer card still asks the chain, through
getPriceForOffer(), while anything describing an existing booking reads the stamp, the booking's ownunit_price. - Is it paid? - the payment's state (in Kessai); the booking's own
statestayshelduntil an operator or the workflowconfirm()s.
This keeps the engine currency-, tax- and provider-agnostic; yoyaku_payment
(over the Kessai payment engine) or your
own integration owns the money.