Payment¶
The orchestra_payment submodule lets a workflow take a payment as a step.
It is a thin layer over the Kessai
payment engine: Kessai owns the money (the payment entity, the gateway plugins,
the settle/fail state machine), and this submodule connects a parked workflow step
to it.
Enable orchestra_payment (which pulls in kessai), plus a gateway submodule
such as kessai_simulator or kessai_worldline.
The payment step¶
A payment step is a wait node carrying the payment interaction:
n_payment:
id: n_payment
label: 'Take payment'
type: wait
config:
payload_variable: payment_outcome
completion_scope: instance
interaction:
plugin: kessai_payment
settings:
payable_resolver: orchestra_payment_variable
gateway: ''
delete_token_on_end: false
When the token reaches the node it parks and the visitor is shown a landing
page (amount due, a "Go to payment" button, and one button per configured
outcome). Going to payment creates a Kessai payment and records it on the
parked token as the payment that token is waiting on, then hands the visitor
to the gateway. When the checkout resolves (authorized or captured) the subscriber
resumes exactly that token, writing the outcome into payload_variable, so
outgoing flows can route on it:
Rendering that page holds nothing still: a visitor looking at a basket may go on editing it, and freezing it just because they opened the page would be wrong. The freeze happens on the click, before anything is priced. See Holding the subject still.
paid: the checkout succeeded, authorized or captured (written by the subscriber, not a button).- each configured outcome: the visitor chose it (a cancel, a "back to the form" step, ...).
- plus any
timeoutoutcome the wait's timeout writes.
Because the payment names the token it was created for, a late or duplicate provider callback can never resume whatever step is parked later: once the token is consumed the signal resumes nothing.
Resuming nothing is not the same as costing nothing, so the step does not leave that to chance. A payment still pending when its step ends for good, whatever ended it (a visitor taking one of those outcomes from a link they still hold, the wait's own timeout, an operator canceling the run), is canceled with the step: otherwise the payer could complete a checkout for a run that no longer exists. A checkout they had already been sent to is logged as needing reconciling by hand, since only the provider knows whether they paid; and an authorization that lands for a step already gone is logged the same way, because the hold is real money and only an operator can release it. A dead-lettered step keeps its payment: an incident can be retried, and the checkout has to still be there when it is.
Naming the payment, and the pin¶
Kessai names no payment kinds of its own: whoever creates a payment says what it
is for, and Kessai only ever compares that word. That word comes from the
payable, not from the step: the resolver knows the domain, and it is the one
that reads the word back later. A booking resolver says booking, a
subscription one says subscription, and a resolver with nothing to say gets
step_payment from Payable::DEFAULT_PURPOSE. The step passes it through
without an opinion, which is why there is no setting for it: a value the step
could be configured with is a value that can drift away from what the resolver
expects to find.
One field ties a payment to a run, and it means one thing:
| Field | On | Means |
|---|---|---|
orchestra_token |
the payment | The token whose step created this payment. It is what the settlement subscriber resumes, and what the run-end cleanups follow to find the payments a run produced. |
Only the payment step writes it. That is the whole rule, and it is what lets the subscriber resume a step without asking anything else about the payment: a pinned payment is that step's payment. A guarantee or a fee a consumer takes against the same subject carries no pin, so settling it resumes nothing.
The corollary is that a consumer must not borrow the pin to have its own payments cleaned up with the run: doing so would wake a step that was never waiting for them. A consumer that wants that cleanup needs its own mechanism, keyed on whatever it does own (its subject, say).
Settling an authorized payment¶
An authorize-mode checkout leaves the payment authorized (money reserved,
not captured), so the capture can happen off the request path, where it can
wait and retry the transient "action not allowed" race; a later settle step
performs it. It is a kessai_settle task node:
n_settle:
id: n_settle
label: 'Settle payment'
type: kessai_settle
config:
payload_variable: settle_outcome
completion_scope: instance
message_variable: settle_message
cancel_when_zero: true
It finds the instance's authorized payment and resolves it by its amount: a
positive amount is captured, a zero-amount authorization (a card verification
that collected nothing) is canceled (or kept, when cancel_when_zero is off,
to hold a standing pre-authorization), and a checkout that already captured (a
direct-sale gateway) passes through. Either way the token advances with
settle_outcome set to settled.
What it will not do is claim more than the hold. The amount is re-resolved from
the instance as it stands now, so a line added on a later step can price more
than the payment step ever authorized; the gateway would take the hold and the
rest would go uncollected while the step recorded settled. Nothing here can
authorize the difference, so the run stops on an incident and an operator
decides. A run that comes back to the payment node authorizes again, and the
authorizations of the earlier passes are released as this one settles, rather
than left standing on the payer's card.
Failure follows Orchestra's generic exception handling (see Incidents):
- a transient gateway failure (the transaction not yet settled enough to act on) is retried per the node's retry policy, and a parked retry can be brought forward from the trace with Retry now;
- a permanent refusal records
settle_outcome = declined(with the gateway's reason onmessage_variable) and advances, so an outgoing flow routes the declined case, no incident; - anything unexpected raises an incident.
Holding the subject still¶
A payment step that prices a thing the visitor can still change has a window in it: the amount is read from a live basket, the payer is sent off to approve it, and anything that happens to the basket in between makes the amount charged and the amount agreed two different numbers. Closing that window is not the resolver's job alone, because only the step knows when the visitor committed.
So the step brackets the checkout with two events, and a domain module that owns something freezable subscribes to them. A third asks whether this run may pay at all:
| Event | When | What a subscriber does |
|---|---|---|
CheckoutEvents::OPENING |
The visitor clicked "Go to payment", before anything is priced | Hold whatever this run owns |
CheckoutEvents::CLOSING |
The step turned out to have nothing to charge | Let go again |
CheckoutEvents::CHECKING |
Twice: as the page is drawn, and on the click just after opening | Refuse, with a reason, or say nothing |
Between the two, and only there, the step asks the resolver what to charge. That is the whole ordering fix: hold, then price, then charge, rather than price, charge, and hold.
public function onOpening(CheckoutEvent $event): void {
$order = $this->orderFor($event->instance);
// $event->timeout is exactly what the payment will be stamped with, so a
// hold sized from it cannot end while the payer can still answer.
$this->freeze($order, $event->timeout);
}
public function onClosing(CheckoutEvent $event): void {
$this->release($this->orderFor($event->instance));
}
Subscribe to both, or a run that turns out to owe nothing stays held.
Refusing a checkout¶
Holding the subject still keeps the amount honest. It says nothing about whether this run is allowed to pay in the first place, which is a question only the domain can answer: a booking whose rules have been broken since the visitor filled in the form can still be priced perfectly well.
CheckoutEvents::CHECKING asks it. A subscriber puts a reason on the event and
the step shows it; the reason is a sentence to render, so the step needs to
understand nothing about the rule behind it.
public static function getSubscribedEvents(): array {
return [CheckoutEvents::CHECKING => 'onChecking'];
}
public function onChecking(CheckoutEvent $event): void {
foreach ($this->violationsFor($event->instance) as $violation) {
$event->refuse($violation);
}
}
Refusing does not stop the event, and every reason given is shown, so a run that breaks two rules is told both at once instead of being sent back twice.
It is asked at two moments, and a subscriber answers the same way at both:
- As the step draws the page. The reasons go above what is due and the payment control is rendered inert, rather than offering a button whose only outcome is the same page with an apology on it.
- On the click, immediately after
OPENING. After, so the answer is given about a subject something is already holding still; before the resolver is asked for anything, so a refused run is never priced and no payment is created for it. The step then closes the checkout, which releases the hold, and renders the page the click came from with the reasons on it.
A subscriber therefore holds nothing while answering. The first moment is a page render, and freezing a subject because a visitor opened a page takes it away from someone who has not asked to pay.
Note what the two moments can differ about. The page is drawn against a subject the visitor may still be editing; the click is answered against a frozen one. A rule that can only speak about a settled subject ("at least two nights") is therefore unanswerable at the first and answerable at the second, so a click can be refused for a reason the page never showed. That is the right way round: the visitor lands back on the page and reads it there.
A run that owes nothing is still asked¶
The check is not about money, so it does not depend on there being any. A run the
resolver reports as no_payment normally never sees the landing page at all: it
is signaled onward and a flow carries it to whatever comes next. A refused one
stops on that page instead, because it is the only page that visitor would have
seen and the reasons have to be read somewhere.
Without this, a free event escapes every rule a paid one obeys, which is the worst possible shape for the exemption to take.
How long the payer gets¶
The payer's window is the step's configured payment timeout, cut short if needed so that it always ends before the wait node's own timeout does. A payment session outliving the node timeout is a payment that can still be completed after the run has routed away; making it lapse first means the step is normally resumed by the payment's own outcome rather than by its timeout. A node with no timeout leaves the step's own (or Kessai's default) standing.
The opening event carries that final amount, so a subscriber holds for exactly as long as the payer can answer, plus whatever margin it wants of its own. It is not a hint the subscriber can revise: it is what the payment is stamped with.
A hold does not need a clock of its own here. Freezing a subject for a checkout suspends whatever ordinary expiry it had (a basket TTL stops running), so the only deadline that remains is the holder's own for giving up on an abandoned checkout, which it sets from this amount.
Charging what was shown¶
The amount on the landing page is priced from an unfrozen subject, so it is not authoritative and is never charged. It is still worth keeping: it is what the visitor agreed to. It travels back with the click, signed with a CSRF token bound to the parked token, and the step compares it against what the frozen subject then prices.
- They agree: charge it.
- They differ: charge nothing, close the checkout, and show the new total with an alert saying so. The visitor decides again.
A payer editing the amount in the URL fails the signature, which is treated the same as a total that moved: nothing is charged. A step set to go straight to the gateway shows no amount, so it carries none, and charges what it prices.
Payable resolvers¶
The step is domain-neutral: a payable resolver plugin decides what to
charge, returning a Payable (amount, currency, gateway, optional subject) or
NULL when nothing is due.
It is asked to price twice, through two methods, and the difference is the point:
| Method | Asked | Answer is |
|---|---|---|
preview() |
To render the landing page, with nothing frozen | Shown, compared, never charged |
resolve() |
Once the checkout has opened | Charged as given |
PayablePreview also carries the optional render array summarizing what is
being paid for (for a booking, the order grouped by event with each ticket and
price), shown above the amount due. A resolver whose subject cannot move under
the visitor (a fixed fee, a process variable) implements only resolve(): the
base class derives the preview from it.
- The bundled
orchestra_payment_variableresolver reads the amount from a process variable (defaultpayment_amount), in a currency variable (defaultpayment_currency, itself defaulting to EUR), through a gateway variable (defaultpayment_gateway, defaulting to manual). Those variable names are configurable on the step. It lets a workflow charge out of the box. - A domain module ships its own: a booking module, for example, prices from the
order. Implement
PayableResolverInterfaceand tag it#[PayableResolver].
#[PayableResolver(id: 'my_resolver', label: new TranslatableMarkup('My resolver'))]
final class MyResolver extends PayableResolverBase {
public function resolve(ProcessInstanceInterface $instance, TokenInterface $token): ?Payable {
// What to authorize, what to capture at settle, the currency, the gateway.
return new Payable('42.00', '42.00', 'EUR', 'simulator');
}
}
An amount is a plain decimal string of at most two decimals, which is what
Kessai stores and what the comparisons here can see; anything else is refused
where the Payable is built rather than truncated into a total nobody priced.
A resolver that overrides preview() (to show an order summary, say) takes its
amount from deriveFromResolve() rather than calling resolve() again, so one
render prices the run once.
Reacting to the outcome¶
The subscriber only resumes the parked step. What a paid step means (confirm an
order, grant access, send a receipt) is a flow decision, or a subscriber to
the Kessai PaymentEvents. A failed payment leaves the step parked so the
visitor can retry, change details or cancel.
Settling a step by hand¶
On the admin instance trace, a parked payment step whose payment is still in
flight offers two operations: Mark payment paid and Mark payment failed
(gated on the administer orchestra permission). Marking paid settles the
payment at the manager, which is the same settlement a verified gateway callback
ultimately performs, so the subscriber resumes the pinned step and the run advances.
It does not go through the gateway's own return route or webhook, and there is
no provider round-trip, so it works whatever the gateway is: it reproduces the
outcome, not the callback. It doubles as a way to complete a run whose webhook never arrived, to
record an offline payment, and to drive a flow end to end in testing without a
live gateway callback. Marking failed leaves the step parked for a retry. The
operations appear only where a module renders the trace (the orchestra_ui
instance view), through the hook_orchestra_ui_token_operations_alter hook.
Stored cards¶
Set delete_token_on_end on the step to have a card tokenized there deleted
when the run reaches any terminal state, rather than kept for the gateway's own
retention period.
Try it¶
Enable orchestra_payment_example for a ready-to-run Example: take a payment
workflow (a fixed demo amount through the simulator gateway).