Skip to content

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":

  1. hold(asks) → held bookings on a pending order; the basket lapses if nothing is paid.
  2. 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.
  3. 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) or release()/cancel()s (reject).
  4. A rejection dispatches BookingEvents::RELEASED or CANCELED, 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_payment attaches 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 own unit_price.
  • Is it paid? - the payment's state (in Kessai); the booking's own state stays held until an operator or the workflow confirm()s.

This keeps the engine currency-, tax- and provider-agnostic; yoyaku_payment (over the Kessai payment engine) or your own integration owns the money.