Booking fees¶
A booking fee is what a booking front charges on top of what the booking costs: a ticket priced at 20,00 that a booker pays 21,00 for. It is a property of the front the booker came in by, never of the concert. The same seat booked at the counter and online may carry different fees, or none at the counter and one online, so the whole configuration hangs off the booking channel.
Fees live in their own submodule, yoyaku_booking_fee, under yoyaku_payment. A site
that never charges one never enables it and never gets the columns.
What a channel configures¶
A channel carries a list of fee policies. Each is a plugin with settings of its own, and each is asked separately, so a channel may hold several and the amounts sum. The same plugin may be attached twice with different settings.
Alongside the list, the channel carries what it calls its fees:
| Setting | Where it shows | Default |
|---|---|---|
mention |
beside a price, with no figure | "+ fees" |
label |
the row carrying the total | "Booking fees" |
description |
once beside that row, explaining what the fees cover | nothing |
Wording is read live rather than recorded on a booking. An amount is a requirement about the past and is stamped; a name is presentation, so a house renaming its fees sees the new name everywhere, including on orders already taken. That is also what settles frais de location against frais de réservation: a theatre prints the short form on its cards and a fuller wording on the total row, and neither is hardcoded.
Where an operator configures it¶
A Fees tab on the booking channel, beside the Domains tab and arranged the
same way: the channel form belongs to yoyaku_ui, this tab exists only while
yoyaku_booking_fee is enabled, and neither module has to know about the other.
The tab lists every policy the site ships, each switchable and configured in place, rather than making an operator add one before discovering what the options are. Switching one off keeps the amount it was tuned with, so turning fees off for a season does not lose the settings.
A channel nobody has opened the tab on stores nothing at all, which is what "this front charges nothing" looks like and the state every existing channel is already in.
The two grains¶
| Asked | Recorded on | Charged | |
|---|---|---|---|
| Per unit | once per booking, given its unit price | the booking, as unit_fee |
once per unit |
| Per order | once per order, given everything it holds | the order, as fee |
once |
A plugin implements PerUnitFeePolicyInterface, PerOrderFeePolicyInterface,
or both. The two are separate because they are asked different questions and
recorded in different places. An order-level amount spread across the bookings
would lie about every one it touched and vanish when one left the basket. They
are summed only for display, on one row.
Two shipped plugins, both examples as much as features:
- A fixed amount per unit (
fixed_per_unit): so much a ticket, a bed, a place. - A fixed amount per order (
fixed_per_order): a handling charge on the basket, however many tickets it holds.
Neither is ever charged on something free. Half of what yoyaku books costs nothing, and a fee on a free booking is what would stop a free thing being free. A per-unit fee skips a booking priced at zero; a per-order fee skips an order that holds nothing priced.
When it is collected¶
At booking, in full, whatever the resource collects. A fee is earned by the act of booking: holding the unit, issuing the ticket, running the front. The price is for attending. So the two are collected at different moments and a fee is never a slice of what a resource decided to take online.
| Resource collects | A 20,00 ticket with a 1,00 fee |
|---|---|
full |
21,00 at booking |
none |
1,00 at booking, 20,00 at the door |
percent, 30 |
7,00 at booking (6,00 of price + the fee), 14,00 at the door |
amount, 10,00 |
11,00 at booking, 10,00 at the door |
The reason is retention, not tidiness. A fee is the part a house keeps when a booker cancels, and you can only keep money you are holding. Left to the door it is never collected from a cancellation, a no-show, or a booker who simply does not turn up, which is the whole population it exists to cover.
An order-level fee is taken the same way: once, at booking, never proportioned. A handling charge is not a fraction of anything.
The behavior change this causes, worth knowing before enabling the module: a resource collecting nothing online has no payment step. With a fee it owes that fee at booking, so an order that would otherwise route straight past payment has something to collect. An order the back office, drush or the API facade opens is in its tenant's default channel, so it carries what the default channel charges, which is nothing until fees are attached to it.
On the summary¶
One row, not one per grain. The two amounts are stored apart because they
are different requirements, each answerable on its own, but a booker does not care
whether a euro came from a per-ticket charge or a per-order one. They are summed
for display, named once from the channel's label, and the total grows by the
same amount, so the figure on the summary and the figure the payment page asks
for cannot disagree.
The optional description renders once beneath the row, as real text rather
than a title attribute or CSS generated content, so a screen reader reads it.
The core's summary owns three money-free slots for this, fees, fees_label
and fees_description, filled through hook_yoyaku_summary_alter() exactly as the
prices are. The core never prices and never names a charge.
The record is read wherever there is one. Live computation happens only for an order whose checkout has never opened, which is a basket. Recomputing a recorded booking would print today's configuration beside money taken under yesterday's, which is the mistake recording exists to prevent.
An absorbed fee produces no row at all.
On the offer card¶
Beside the price, the channel's mention, with no figure.
That is a performance rule, not a design preference. A booking page shows every offer of a slot, and pricing a fee per card would ask every attached policy about every tariff on the page, for a number the booker reads again on the summary one click later. So the card asks a single question for the whole page, "does this front add anything", answered from one statically cached config read.
It is a preprocess on the offer card rather than a form alter, so it reaches the
card wherever it is built, and a surface rendering offer cards some other way
gets the mention without asking for it. The card is yoyaku_presentation's and
this module declares it, so the mention appears wherever an offer does rather
than only where a calendar happens to be installed.
Nothing is printed on an offer with no price, since a free thing is never charged a fee, nor for an absorbed fee, which the booker is not told about.
On the plan¶
The same sentence, beside the same prices. A booker who takes a place off a seat map reads its price in the panel beside the plan, and until that panel said so they met the fee for the first time at checkout, while a booker of the very same tariff on the booking page was told before they took anything.
The panel is not rendered by Drupal: it builds its rows in the browser from the
map payload, so the preprocess that dresses the card cannot reach it. The
mention travels in that payload instead, filled through
hook_yoyaku_placement_map_alter() beside the price it belongs to, and printed
where the card prints it. The rule is the card's rule word for word: beside a
charge, never on an offer costing nothing, never with a figure.
An area offered by quantity carries its tariffs the same way, so a standing hall whose only prices are its section counters says it too.
A seat is priced from a select, whose options exist to be compared, and a mention repeated on every one of them is read out option by option for nothing. So the seats say it once, under the running total: the number a booker checks against what they will be charged.
Added or absorbed¶
Every policy carries one setting in common: whether what it charges is already included in the displayed price.
| Offer card | Summary row | Total | Charged | Recorded | |
|---|---|---|---|---|---|
| Added | "+ fees" | yes | price + fee | price + fee | unit_fee / fee |
| Absorbed | nothing | none | price | price | unit_fee_absorbed / fee_absorbed |
An absorbed fee is deliberately invisible to the booker: the house carries it instead of charging it. It gains a record, not a behavior.
Two fields rather than one signed column, because one number cannot say which of
the two it was. Read a signed column back to charge a card and an absorbed fee
gets charged, which is the one thing it must never be; record absorbed as zero
instead and the record disappears, which was its only purpose. A channel may
hold an added and an absorbed policy at once, and +1,00 + -1,00 = 0,00 would
lose both requirements.
The field says absorbed where the payment seam says included, deliberately:
the seam's word covers an inclusive tax too, and in fee vocabulary an included
fee is exactly one the house absorbs.
How it reaches what is charged¶
yoyaku_payment prices what a resource and its tariffs configure. A fee is not
one of those, and PaymentPolicy is final, so the payment module exposes a
seam instead:
yoyaku_payment.amount_contributor → ContributedAmountInterface
Every contributor is asked and the amounts sum, the same way the engine asks
every availability bound provider and takes the lowest. yoyaku_booking_fee registers
one contributor whatever the channel holds, so the payment resolver asks one
question per booking and one per order.
An installation with no contributor at all changes nothing: the amounts a resource configures are the amounts charged, exactly as before the seam existed.
The seam is generic on purpose. A future donation, insurance or deposit module
uses it rather than reopening PaymentPolicy.
When a fee is worked out¶
Never at the hold. Holding is the hottest write path in the engine and it runs inside the locks a hold takes, so work added there does not merely cost time: it serializes every other booker reaching for the same part of the house. A fee policy is a plugin, its cost is unbounded, and nothing it reads is already loaded.
The contrast with the price is worth keeping, because both were measured rather than assumed:
- A one-booking hold costs 6 queries, 5 per extra booking.
HoldCostTestpins it. - Disabling the price stamp leaves that unchanged. Everything
unit_priceneeds is already in the entity static cache, becausehold()loaded the resource, slot and tariff for its own capacity check.
So: stamp where it is free and freezing helps; resolve where it is costly and only needed late. A fee is resolved as the checkout opens, and before that it is computed live for display, which is what an unstamped price already does.
An abandoned basket therefore costs nothing, and most baskets are abandoned.
Writing a fee policy¶
Ship a plugin in src/Plugin/FeePolicy/, attributed #[FeePolicy], extending
FeePolicyBase, implementing one or both grain interfaces, and declare its
settings under yoyaku_booking_fee.policy.<plugin id> in your config schema.
A policy computes and returns; it never writes. It is asked while pricing a basket for display, not only when a checkout opens, so one that queries heavily is paid for on every render of that basket. Read what is handed to it before reaching for storage.
A percentage fee, a banded fee ("1,00 up to 9,99, then 10 percent"), a fee that varies by tariff class: all of these are plugins, and that is the payoff of the type. None of them needs a change here.
What is deliberately not here¶
- Reporting on absorbed fees. Nothing in yoyaku or kessai reports what a house earns, and kessai cannot split a payment between parties.
- Fee retention on refund. Unblocked by the record, filed separately.
- A fee on the resource or the tariff. Rejected: a fee is not a property of a concert. Per-show and per-tariff variation belongs inside a policy.