Skip to content

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 timeout outcome 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 on message_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_variable resolver reads the amount from a process variable (default payment_amount), in a currency variable (default payment_currency, itself defaulting to EUR), through a gateway variable (default payment_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 PayableResolverInterface and 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).