Issue #3614129: Add a per-ticket booking fee to the price shown and charged

A ticket priced at 20,00 has nowhere to say the booker pays 21,00. The fee belongs to the front the booker came in by rather than to the concert, so a new yoyaku_booking_fee submodule hangs it off the booking channel.

What lands

  • A FeePolicy plugin type, so a house prices its fees the way it wants rather than waiting for a setting. A percentage fee, a banded fee, a fee varying by tariff class: all plugins, none of them a change here.
  • Two shipped plugins, examples as much as features: a fixed amount per unit, and a fixed amount per order.
  • A channel carries a list of policies; every enabled one is asked and the amounts sum.
  • Each policy is added or absorbed: absorbed leaves the price alone and only records what the house carried.
  • Four recorded amounts, installed and removed by this module so a site charging no fee gets no columns: unit_fee and unit_fee_absorbed on the line, fee and fee_absorbed on the order.
  • The channel also names its fees: a mention shown beside a price with no figure, a label for the total row, and an optional description. Read live, never stamped, so renaming them updates orders already taken.

The seam in yoyaku_payment

PaymentPolicy is final and nothing there dispatches, so a separate module could not reach what is charged. The resolver now takes !tagged_iterator yoyaku_payment.amount_contributor, the way the engine asks every availability bound provider and takes the lowest. Every contributor is asked per line and once per order, and the amounts sum.

An installation with no contributor changes nothing. That is the first thing the tests assert.

When a fee is collected

At booking, in full, whatever the resource collects. A fee is earned by the act of booking rather than by attending, so it is never a slice of what a resource decided to take online. The price keeps its own split, line by line, untouched.

The reason is retention: 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 does not turn up, which is the whole population it exists to cover. The branch tries the other rule and reverts it for exactly that reason, with the reasoning kept in the commit.

An order-level fee is taken the same way: once, at booking, never proportioned. That is also what makes an order whose lines collect differently a non-question.

Performance

No fee is worked out at the hold. Holding is the hottest write path and runs inside slot row locks, so arbitrary plugin work there would serialize bookers on the same slot. Measured, not assumed:

  • A one-line hold costs 6 queries, 5 per extra line. HoldCostTest pins it so the next thing added to that path arrives as a red test.
  • Disabling the price stamp leaves that unchanged: everything unit_price needs is already in the entity static cache because hold() loaded it for the capacity check. Hence the rule this follows: stamp where it is free and freezing helps, resolve where it is costly and only needed late.

Resolution is memoized per request, and the offer card is only ever allowed one boolean per channel for the whole page, never a fee priced per card.

Rules the tests hold to

  • Nothing free is ever charged a fee, per line or per order.
  • An order naming no channel (back office, drush, the API facade) is charged nothing.
  • A policy switched off keeps its settings and charges nothing.
  • A policy naming a plugin that has gone charges nothing rather than breaking every page that prices an order.
  • An absorbed fee moves no amount and shows nothing to the booker.

Behavior change to note

A resource collecting nothing online has no payment step today. With an added fee it owes the fee at booking, so routeOutcome() starts returning OUTCOME_PAY where it returned OUTCOME_NO_PAYMENT.

Drive-by, kept deliberately

_yoyaku_payment_added_fields() listed neither yoyaku_booking nor yoyaku_tariff, so unit_price and the tariff preset's payment fields were declared in entity_base_field_info() and their storage never installed. Surfaced by writing the same install hook for this module; fixed here rather than left for the next person to hit.

Where an operator configures it

A Fees tab on the booking channel, arranged as the Domains tab already is: the channel form belongs to yoyaku_ui, this tab exists only while the module is enabled, and neither has to know about the other. It lists every policy the site ships, each switchable and configured in place. Switching one off keeps the amount it was tuned with; a channel nobody has opened the tab on stores nothing at all, which is the state every existing channel is in.

What the booker sees

  • The offer card: the channel's wording beside the price, with no figure. That is a performance rule: a booking page shows every offer of a session, and pricing a fee per card would ask every policy about every tariff for a number the booker reads again one click later. One question for the whole page, one memoized config read, and a test asserts no amount reaches the markup.
  • The summary: one row, not one per grain, named from the channel's label, with the optional description as real text beneath it so a screen reader reads it. The total grows by the same amount, so the summary and the payment page cannot disagree. Rendered by all three renderers: screen, mail and plain text.
  • An absorbed fee shows nothing at all, on either surface.

The core gains three money-free slots for this, fees, fees_label and fees_note, filled through hook_yoyaku_summary_alter() exactly as the prices are and documented in yoyaku.api.php. The core still never prices and never names a charge.

When the amounts are recorded

On the pending to locked transition, the one moment every checkout passes through whoever opened it: the cart's payment step, the workflow's lock action or a gateway subscriber. Keying on the transition covers all three rather than one call site.

Re-recorded at every opening, unlike the price, which is written once: a fee can be a function of the whole basket, so an earlier figure is stale as soon as the basket changes, and nothing has been charged in between. Verified rather than assumed: applyLock() refuses anything but a pending order, so a checkout cannot reopen after money was taken.

Display reads the record wherever there is one, and prices live only for a basket that never reached a checkout, through the same contributor that charges it.

Two bugs the tests caught

  • The recorder took its two writes as ||, so a channel holding one charged and one absorbed policy silently dropped the second.
  • Each policy's settings form read getValue('amount') at the top level, so an amount an operator typed came back as zero. Fixed with a SubformState scoped to the policy's own subtree, with #parents pinned first because a container nested in details does not rebuild the parents of a plugin's elements.

Drive-bys, kept deliberately

  • _yoyaku_payment_added_fields() listed neither yoyaku_booking nor yoyaku_tariff, so unit_price and the tariff preset's payment fields were declared and their storage never installed. Surfaced by writing the same install hook for this module.
  • The offer card appended a literal EUR, so a site taking payment in any other currency priced its whole booking page in one it does not use. It now formats through the module that owns the currency, and that formatter is promoted onto PaymentPolicyResolverInterface so money is not formatted three different ways.

Filed separately, not built here

  • Percentage and banded fees: further plugins, which is the payoff of the plugin type.
  • Whatever reporting would make an absorbed fee visible to the house.
  • Fee retention on refund, which the record unblocks.
  • Moving money into the order layer, Commerce-style: a real question, far larger than fees.
Edited by Frank Mably

Merge request reports

Loading
Loading