Skip to content

Payment (yoyaku_payment)

yoyaku_payment connects the booking engine to the standalone Kessai payment engine: it decides what to charge and which gateway, and reacts to Kessai's payment outcomes to drive the booking. It is optional - the core engine never depends on it, and Kessai never knows about bookings.

flowchart LR
    subgraph yoyaku
        ENG[Booking engine]
        YP[yoyaku_payment]
    end
    YP -- policy + gateway --> K[Kessai]
    K -- payment events --> YP
    YP -- confirm / release / lock --> ENG
    OW[orchestra_payment] -. workflow drives .-> YP

Kessai is mechanism: it owns the money state machine, the gateways (manual, worldline), and the Payment entity. yoyaku_payment is policy: it prices the booking, picks the gateway, and turns a settled, failed, or refunded payment into a booking outcome. Every payment carries the booking order (a yoyaku_transaction) as its Kessai subject, so the two stay linked without Kessai depending on yoyaku.

What it adds

  • Payment settings on the resource (price and how it is collected), which a tariff overrides one value at a time.
  • PaymentPolicyResolver (yoyaku_payment.policy_resolver) - the one place that resolves the effective price and its split, so checkout, the workflow gate, and the UI all agree.
  • PaymentAccountResolver (yoyaku_payment.account_resolver) - picks the account an order is paid into, by uuid (its tenant, then the site default), and answers empty when neither names one.
  • Settlement and lock subscribers - react to Kessai events to confirm or release the booking, and to bracket the checkout with the order's locked stage.
  • Guarantee actions (the yoyaku_orchestra_payment submodule) - card-on-file operations a workflow drives.

Configuration: the payment settings

yoyaku_payment attaches these fields to the resource:

Field Meaning
price The booking price.
collect_mode How much to collect online now: none, amount (a fixed collect_value), percent (of the price), or full.
collect_value The amount or percentage, when collect_mode is amount or percent.
guarantee_enabled / guarantee_amount A guarantee, separate from the price, given back unless something goes wrong. The amount is what a damage claim may take.
guarantee_mode How the guarantee is taken: authorize (held, uncharged) or charge (charged now, given back later).
noshow_fee What a no-show claims from the guarantee. Also settable per tariff.

A tariff carries price, collect_mode, collect_value and noshow_fee again, as overrides: each one is empty by default and an empty field inherits the resource, so a tariff that names a price keeps the resource's collection mode, guarantee and no-show fee. Two exceptions to the field-by-field rule:

  • The guarantee is not overridable. It stands behind the resource being reserved, not the audience paying for it, so it lives on the resource alone.
  • collect_mode and collect_value are inherited as a pair, because the value reads as currency or as a percentage depending on the mode. A tariff that names a mode supplies its own value (empty meaning zero); a tariff that leaves the mode empty inherits both.

A slot tariff may also override just the price, for one slot. So a price resolves slot tariff, then tariff, then resource.

The payment policy

PaymentPolicyResolver turns that config into an immutable PaymentPolicy for a booking or transaction:

Field Meaning
price The effective price.
collectNow What to collect online at checkout.
balanceDue The remainder, due later or on site.
guaranteeAmount / guaranteeMode What a damage claim may take, and how the guarantee is taken.
noShowFee What a no-show claims, if anything.

Resolving in one place means the checkout amount, the workflow's payment-required gate, and any price shown to the booker can never disagree.

Taking a payment

A payment is a Kessai Payment whose subject is the booking order. The workflow's payment step (see Orchestra integration) creates it through the kessai_payment interaction, pricing it from the policy and pointing it at the resolved gateway; the payer is handed off to the provider, and Kessai reports the outcome back as an event.

An order can carry more than one payment, so each says what it is for. Kessai stores that word and never reads it, which is why the vocabulary is yoyaku's own (PaymentPurpose):

Purpose What it is Ends as
booking what the visitor pays for the booking captured, or refunded if the booking is undone
guarantee what stands behind the booking, and whatever is claimed from it: a damage claim, or a no-show fee released when nothing goes wrong, claimed when something does

A charged guarantee is the one guarantee with no payment of its own. It is collected inside the checkout amount, so returning it is a partial refund of the booking payment, and the refund carries the guarantee purpose even though the payment it comes out of carries booking. That is how a returned guarantee stays legible in reporting without pretending it was ever charged apart from the price.

One guarantee covers both claims, and it is held for the larger of them, since only one can happen: a visitor who never arrived cannot also have damaged anything. Which claim took the money is recorded on the claim itself (kessai's claim rows carry the caller's word for it, damage or no_show), so two endings that move the same total stay legible apart.

Settlement answers for the booking payment alone: a captured guarantee confirms nothing and a returned one cancels nothing, because neither says whether the booking is paid for. The one exception is failure, which answers for both, since a booking accepted on the strength of a guarantee has no basis once the guarantee is refused.

The booking payment names itself through the payable the resolver builds, so the settlement paths here recognize it by a value this module chose rather than by a constant of the payment layer or a setting a site can edit.

The order lock

While the payer is at the provider the order must not be edited. It must also not be editable while the amount they are about to be asked for is being computed, or the total charged and the total agreed can differ. So the lock is taken at the earliest moment the visitor has committed, and released on every path that turns out not to charge:

  • On orchestra_payment.checkout_opening, which the payment step fires on the click that commits to paying and before it prices anything, the order is locked (yoyaku_orchestra_payment.checkout_lock_subscriber). The step then asks the resolver what to charge, from an order that can no longer move.
  • On orchestra_payment.checkout_closing, fired when that checkout will not charge after all (the order owes nothing, or the total moved since the visitor was shown one), it is unlocked back to the editable basket. Only a lock this subscriber took is released: an order already locked when the checkout opened belongs to whoever locked it.
  • On orchestra_payment.checkout_checking, which the step fires as it draws the page and again on the click just after locking, the order's constraint policies are evaluated (yoyaku_orchestra_payment.checkout_policy_subscriber) and each violation is put on the event as a reason for the booker. A refused checkout is never priced and the order never reaches a gateway. See Constraint policies.
  • On kessai.initiated (the payer's deliberate departure for the provider) the order is locked (yoyaku_payment.order_lock_subscriber), which is a no-op when the checkout already locked it, and still the first lock for a payment taken outside a workflow checkout.
  • On kessai.failed, kessai.canceled, or kessai.expired it is unlocked back to the editable basket.
  • A settled payment leaves the order locked; the workflow's place step moves it to placed.

Rendering the payment page locks nothing. A visitor reading it may still be editing their basket, and may never come back, so freezing it there would take units away from somebody who has not asked to pay.

That stage is enforced, not advisory. A frozen order (locked or placed) refuses basket edits: hold() takes nothing new into it, and BookingManager::release() and cancel() refuse to take one out. The guard sits in the booking manager, so the cart, the seat map, the quantity stepper, the admin per-booking operations, the bulk actions and the API all give the same answer without each having to remember. Settling the order is exempt, through the unguarded doRelease() and doCancel(): a failed payment has to let go of the bookings it holds, and an order-wide release or cancellation does the same.

Kessai's own payment expiry releases a payer who never returns: a pending payment past its deadline is reaped on cron, fires kessai.expired, and unlocks the order. The deadline is set per payment step (payment_timeout), shortened by the step where needed so it ends before the payment node's own timeout does. The checkout-opening event carries that same figure, so the lock taken for the checkout is sized from the window the payer actually gets rather than from a guess. See Orchestra integration.

That covers most abandonments but not all, so the lock carries a deadline of its own (locked_expires, stamped when it is taken). Two cases need it: a gateway may give its payment no deadline at all, and a payment that leaves the pending state without resolving the order (authorized in a reserve-first checkout, or captured while the place step never ran) is never reaped. The deadline is the payment's own plus an hour, so the sweep can never race a payer who can still answer, and a day when the payment has no deadline.

A lock a workflow step takes itself (lockTransactionIndefinitely(), what the "Lock order for checkout" action does) carries no deadline on purpose: the workflow's node timeouts are the authority on that window, and a sweep of yoyaku's own would compete with them.

Cron returns a lapsed checkout to an editable basket: the order goes back on the clock at the deadline it had when the checkout froze it, and the ordinary hold sweep reaps it if the visitor never comes back. An order whose money did arrive is deliberately left locked and logged instead, because putting a paid booking back on the clock would let it lapse; that one needs an operator.

A resumed order gets its own deadline back, never a fresh window. The clock keeps running while the visitor is at the payment page, so going to payment and canceling wins no extra time, and a basket cannot be held indefinitely by starting and abandoning checkouts. Granting more time is what changing the basket does, under the realm's renewal rule and its ceiling, not a side effect of a canceled payment.

Only the deadline the checkout itself took is given back. An order already off the clock when the checkout locked is one whose realm states no window at all, and unlocking leaves it exactly as it found it rather than inventing a deadline for a basket the operator alone is meant to end. See The hold clock is the order's.

Settlement without a workflow

DefaultSettlementSubscriber is the shipped default for a deployment with no workflow driving the order: a settled payment confirms the held bookings, a failed one releases them and frees any held guarantee, and a refund cancels the order. It acts only on payments whose subject is a booking order, and it stands down when a workflow has already handled the event (the orchestra bridge runs first and marks it handled), so the two never compete.

An order carries more than its checkout payment, so each path answers for a particular kind:

Event Kind it answers for Why
capture the checkout payment A claimed guarantee stands behind a booking rather than paying for one, so it confirms nothing.
refund the checkout payment, refunded in full Returning a charged guarantee takes part of that payment and announces a partial refund instead, which nothing here answers for: it is how a guarantee ends normally, after the slot, and it must not cancel the order it stood behind.
failure either kind A booking accepted on the strength of a guarantee has no basis once the guarantee is refused, so a failed authorization releases the bookings it was taken for.

Only bookings still held are ever touched, so a path that no longer applies is a no-op rather than a correction.

Guarantees, no-show, and card-on-file

The yoyaku_orchestra_payment submodule provides the workflow actions that operate a stored card: hold the guarantee, claim it for damage or for a no-show, or release it; refund the order; and delete the card. These map to Kessai's PaymentClientInterface operations against the card token captured at checkout.

Every ending answers for both modes, because a workflow branches on how the stay went and not on how a resource is configured. Each says only how much of the guarantee the site keeps: nothing when the stay passed without incident, the damage claim when it did not, the no-show fee when nobody came. A held guarantee is claimed for what is kept, which closes the hold so the provider frees the rest; a charged one has the difference refunded out of the checkout payment, leaving the price captured. Give back the guarantee, keep the rent. See Paying for a booking and Guarantees, no-show, and the real-gateway design.

Gateways

The gateways are provided by Kessai, not yoyaku:

  • manual - an operator records an offline payment (cash / cheque / transfer); no external integration.
  • worldline (the kessai_worldline submodule) - a Worldline (Direct) hosted-checkout gateway. It settles via the browser return and an HMAC-verified server-to-server webhook (so a payer who never returns is still settled), and places deferred guarantee holds and claims them as card-on-file subsequent merchant-initiated transactions. Turn the webhook on for reliable abandoned-browser settlement: see the Kessai Worldline README.

To add a payment solution, write a Kessai payment gateway plugin (see the Kessai documentation); yoyaku consumes whatever gateways Kessai exposes.

Prices in the admin lists

With yoyaku_payment installed, the resource Tariffs list and the slot Tariffs list each gain a Price column. It shows the price defined on the row itself: a slot tariff's own price override, or the tariff's. Where it defines none the column reads Inherited, so a resource manager sees at a glance where a price is set and where it comes from elsewhere. The column shows the defined value, not the resolved one: the final price a booker pays is shown on the booking and place-map pages.

yoyaku_ui builds those tables without any price concept and exposes hook_yoyaku_ui_entity_table_alter(); yoyaku_payment fills it with the Price column, so the base UI keeps no dependency on payment.

Amounts this module cannot know

A booking may cost more than what a resource and its tariffs configure. A booking fee is the first of those: it belongs to the front the booker came in by, not to the concert, so it cannot be resolved from anything this module owns.

PaymentPolicy is final and nothing here dispatches, so the resolver takes a tagged iterator instead:

tags:
  - { name: yoyaku_payment.amount_contributor }

Every contributor is asked per booking and once per order, and the amounts sum. An installation with none changes nothing, which is what keeps a site without fees exactly as fast as before the seam existed. yoyaku_booking_fee is the first user; see Booking fees.