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
lockedstage. - Guarantee actions (the
yoyaku_orchestra_paymentsubmodule) - 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_modeandcollect_valueare 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, orkessai.expiredit 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(thekessai_worldlinesubmodule) - 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.