Skip to content

Events

Kessai is the payment mechanism; what a payment means for your domain is your call. The engine records the money outcome and announces it, then a subscriber decides what happens next (confirm an order, grant a membership, send a receipt).

The event names

All events carry a \Drupal\kessai\Event\PaymentEvent and are dispatched under \Drupal\kessai\Event\PaymentEvents:

Constant Fired when
INITIATED The payer is handed off to the provider. Not a money outcome.
AUTHORIZED An amount is authorized (reserved, not yet captured).
CAPTURED Money is taken. Announced per claim, so a hold claimed twice announces twice.
RELEASED A hold ends with nothing ever taken from it. Carries the release.
PARTIALLY_RELEASED A hold is released without the payment ending released: either part of it went and the rest is still claimable, or the last of it went on a payment that had already taken money.
CANCELED A pending payment is called off: the payer abandoned the checkout, or the consumer withdrew it. Nothing was held.
FAILED A payment fails: the gateway declined it or errored. Usually carries nothing; a checkout refused after the provider took or held the money carries the refund or release that gave it back.
EXPIRED A pending payment lapsed at its deadline without anyone resolving it.
REFUNDED A captured payment is refunded in full: nothing of it is earned any more.
PARTIALLY_REFUNDED Part of a captured payment is refunded, leaving a balance. The payment stays captured.
AUTHORIZED_AFTER_END The provider holds money on a payment that already expired, was released, was canceled or failed. The payment keeps its state.
CAPTURED_AFTER_END The provider took money on a payment that already expired, was released, was canceled or failed. The payment keeps its state.
RELEASED_AFTER_END Money held after the payment ended is released to the payer. Carries the release.
REFUNDED_AFTER_END Money taken after the payment ended is refunded to the payer. Carries the refund.

Six of these are money outcomes: the payment moves to a new state and the money did or did not change hands. Eight need a word of their own.

An event that moved money carries the row that moved it. $event->movement is the claim behind a capture, the refund behind a refund, the release behind a release. It is NULL wherever nothing moved: the six events that never move money, a failure where nothing was given back, and a refund that the end of a hold completed. A subscriber checks rather than assumes. It is put there by whoever announced the event, which is what lets a subscriber read it without asking a service, and lets the answer be the same whether the payment is held on this site or on another. A total cannot stand in for it: two claims against one guarantee move the same captured total, and only the row says which ending took the money.

All of them reach the audit trail

With kessai_audit_trail enabled, every event above is written to a tamper-evident chain, INITIATED included.

INITIATED resolves nothing, but it is the last thing this site knows before the payer leaves for the provider, so a dispute over whether a payer was ever sent to pay is answered there and nowhere else.

A site running a client beside its engine hears the events of payments another host settled, and records those too: it is what that site was told, and it is the record it has. They are named kessai_payment:<account>:<id> rather than kessai_payment:<id>, because a payment's id is a row number and each kessai numbers its own from one. A payment this site settled keeps the plain name.

Each entry records the account, the amounts, the state and the payment's reference in the chain's permanent context, alongside the movement that caused it, and the consumer the payment was taken for where there is one. Entries correlate on the payment's subject, under its consumer's name where there is one (subject:shop:node:42, or subject:node:42 for this site's own, each part URL-encoded and the whole hashed past 128 characters): each consuming site numbers its own content, so one site's node 42 is not another's. The payment method id and the movement's own provider reference go to the transient context instead. Both are aliases a provider resolves back to a payment instrument or to a single operation, and the transient context is the one a site can clear early, on the chain's transient_purge_after clock, separately from the long ladder the rest of the entry rides.

The handoff: INITIATED

INITIATED is the odd one out: the payment stays pending and no money has moved. It marks the moment the payer leaves for the provider, so a consumer can bracket the round-trip (a booking that locks its order while the payer is away, and unlocks it if the payment fails, is canceled or expires).

It is announced once per handoff, not once per payment. A payer can reach the handoff route more than once: a second click, the browser's back button, a link sent again. Each of those is a real departure, so each announces the event, while the provider session behind it is reused rather than reopened. Subscribe as you would to CAPTURED, and keep the subscriber idempotent: locking an order that is already locked has to do nothing. Announcing only the first departure would be the wrong half to keep, since a consumer that released its order on a failed round-trip would never hear about the retry.

It is announced by the handoff route, not by the engine's own PaymentSessionOpenerInterface::openPaymentSession(), which that route is the only caller of. Generating a provider URL can happen at render time, before the payer has committed to anything, so firing there would lock an order for a payment page nobody acted on. Send the payer to $payment->getHandoffUrl(), the payment having been created with its return_url, instead of straight to the provider, and the deliberate click is what announces the handoff. See Gateways.

Money back in part: PARTIALLY_REFUNDED

The other one that leaves the state alone. A payment can stand for more than one thing (a checkout that collects a price and a refundable deposit as one figure), so it can be given back in parts: returning the deposit leaves the price earned. The payment therefore stays captured, carries a running refunded total, and announces PARTIALLY_REFUNDED.

REFUNDED keeps its stricter meaning: the whole amount is back, whether in one refund or as the last of several. That matters because it is the event consumers undo a purchase on, so a deposit going back must not read as the booking being canceled. Subscribe to PARTIALLY_REFUNDED only if you have something to say about money that came back while the rest stayed earned.

Each refund is also a record of its own, so a payment's refunds can be listed and matched against the provider one by one:

$refund = $payment_client->refund($payment, new Money('150.00', 'EUR'), 'guarantee');
$payment->getRefundedAmount()->value;   // '150.00'
$payment->getBalance();          // what is still earned
$payment_client->getRefunds($payment);

Part of a hold back: PARTIALLY_RELEASED

The mirror of PARTIALLY_REFUNDED, on the hold side. A guarantee can stand for more than one outcome, so releasing part of it need not give up the rest: release 140,00 of a 150,00 hold and 10,00 stays claimable. The payment therefore stays authorized, carries a running released total, and announces PARTIALLY_RELEASED.

RELEASED keeps its stricter meaning, and it is about the money rather than about the hold: nothing was ever taken. That is why PARTIALLY_RELEASED also covers the case where a hold is released down to nothing on a payment that already claimed something. Claim a 10,00 no-show fee out of a 150,00 guarantee, release the 140,00 left, and the hold is finished but 10,00 was collected: the payment stays captured and announces PARTIALLY_RELEASED again, not RELEASED. If the 10,00 had already been refunded, the payment is then refunded and announces REFUNDED as well: nothing is earned and nothing more can be claimed.

So read PARTIALLY_RELEASED as "the hold moved and the payment did not end released", and ask the payment which state it is in rather than assuming one. A consumer that treats a release as the end of the guarantee hears RELEASED once, only where nothing was collected at all.

$release = $payment_client->release($payment, new Money('140.00', 'EUR'), 'guarantee');
$payment->getHoldAmount()->value; // '10.00', still claimable
// Claims the 10,00 that is left, and says what it was claimed for.
$payment_client->capture($payment, kind: 'no_show');
$payment_client->getClaims($payment); // one row: 10,00, kind no_show

The deadline: EXPIRED

A payment carries an optional deadline (its expires timestamp). Each cron run sweeps the pending payments now past theirs, records them expired and announces EXPIRED. It is kept distinct from FAILED and CANCELED because nobody acted: the payer walked away and the window closed, which a consumer may well want to treat more quietly than a refusal.

Expiry therefore depends on cron running. The deadline is stamped per payment by create(), and the gateway is what decides it, because a gateway knows how its own payments are settled: the bundled manual gateway takes the deadline its account sets, and by default its payments never lapse, whatever the site default says. A redirect gateway declares the window matching the checkout it opens. kessai_engine.settings:default_payment_deadline (1800 seconds as shipped) is the fallback for a gateway that declares no window of its own, so setting it to 0 switches expiry off only for those. A caller can end a payment earlier with the request's maximumDeadline, and later only through an account that lets a site extend its deadline: see Bounding a checkout and Architecture.

The sweep normally asks the provider before it writes a payment off, so a payer whose browser return and webhook both failed is found and settled rather than expired. Two things bound that, and where neither applies EXPIRED really is only a statement about this site's records: a payment is asked about only if it reached a provider at all (it holds a payment session, or is a stored-card hold or charge whose answer never came back, so an offline payment never is), and only while the run still has reconciliation budget, which a site can switch off entirely by setting reconcile_batch to 0. See Architecture for what each answer does and how the budget behaves.

Expiry releases nothing at the provider

expire() records a local state and no more. Reconciliation asks what happened; it does not undo anything. A provider session left open past the payment's deadline could therefore still take money against a record already written off, which is why a gateway must clamp the session it opens under the payment deadline rather than the other way round (see Architecture). A payment that lapses while its provider cannot be reached at all is expired once the grace period runs out, and the run says so loudly.

When the provider takes money after the end all the same, it is announced: see Money after the end.

Money after the end: AUTHORIZED_AFTER_END and CAPTURED_AFTER_END

use Drupal\kessai\Event\PaymentEvent;
use Drupal\kessai\Event\PaymentEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class AlertOnLateMoney implements EventSubscriberInterface {

  public static function getSubscribedEvents(): array {
    return [
      PaymentEvents::AUTHORIZED_AFTER_END => 'onLateMoney',
      PaymentEvents::CAPTURED_AFTER_END => 'onLateMoney',
    ];
  }

  public function onLateMoney(PaymentEvent $event): void {
    // The booking was released when the payment expired, and the payer has
    // just paid for it at the provider. Tell an operator, or the payer.
  }

}

A payment ends without money when it expires, its hold is released, it is canceled or it fails. The provider can still take the payer's money afterwards: a payer still on the provider's page past the deadline pays, or a provider answers late. Kessai then:

  • keeps the payment's state, because a consumer may already have acted on it, for example by releasing the booking;
  • records no movement, since this site never asked for the money;
  • logs a warning naming the payment, its state and the provider reference, so an operator can release the hold or refund the charge at the provider;
  • announces AUTHORIZED_AFTER_END for a hold, or CAPTURED_AFTER_END for a charge, once;
  • marks the payment (hasMoneyAfterEnd()), so a consumer that missed the announcement finds it with findPayments(new PaymentQuery(..., money_after_end: TRUE)), and the payment cannot be deleted until the money is given back. Once it is, isMoneyAfterEndGivenBack() says so and the query stops finding it, while hasMoneyAfterEnd() still says the provider took it. Money an operator gave back at the provider's console, or settled with the payer by hand, is recorded so with recordMoneyAfterEndGivenBack(), or the "Record money taken after the payment ended as given back" action: nothing is asked of the provider.

$event->payment is the payment as it ended, and $event->movement is NULL. A duplicate report on a payment already authorized or captured announces nothing: it is the same money reported twice. The states that count as ended without money are PaymentInterface::ENDED_WITHOUT_MONEY_STATES.

A gateway that hears the late money through two channels, a return and a webhook say, may announce it twice. The Worldline gateway announces it once, but a subscriber that must act once is idempotent all the same, as for CAPTURED. How a gateway reports it is in Gateways.

Giving it back

An operator gives the money back from the payments list, with the Give back money taken after the payment ended action, which needs its own permission. A consumer does it with giveBackMoneyAfterEnd(), on this site or across the wire:

$movement = $client->giveBackMoneyAfterEnd($event->payment);

The gateway releases the hold or refunds the capture at the provider. The payment keeps its state, and gains the rows that say what came in and went back out: a release for a hold, or a claim and a refund for a capture, so its balance is zero. It announces RELEASED_AFTER_END or REFUNDED_AFTER_END, carrying the release or the refund.

It gives back once. A payment that did not end without money, names no provider transaction, has already had it given back, or sits at a gateway that cannot give money back gives nothing back, and the log says which. When the provider refuses, nothing is recorded, and asking again is safe.

Reacting to a captured payment

use Drupal\kessai\Event\PaymentEvent;
use Drupal\kessai\Event\PaymentEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class ConfirmOnPayment implements EventSubscriberInterface {

  public static function getSubscribedEvents(): array {
    return [PaymentEvents::CAPTURED => 'onCaptured'];
  }

  public function onCaptured(PaymentEvent $event): void {
    // NULL on a host for a payment it took for another site, whose subject
    // is that site's own: only the site the subject belongs to reacts to it.
    $subject = $event->payment->getSubject();
    // Do whatever a captured payment means for your domain.
  }

}

Everything that example touches is in kessai, so a module that only listens declares kessai:kessai and no more. The payment it is handed answers every reader without being a row in this site's database, which is what lets the same subscriber run unchanged on a site whose payments are settled by a kessai somewhere else. A module that also drives payments takes PaymentClientInterface, which is in kessai too, and still declares only kessai:kessai; see Architecture.

Handing off to a workflow

PaymentEvent carries a handled flag. A subscriber that drives the outcome itself (for example, resuming a workflow that was waiting on the payment) calls $event->setHandled(). Any shipped default reaction should check $event->isHandled() first and stand down when another subscriber has taken ownership, so the outcome is driven exactly once.

Events from a Kessai on another host

A site whose payments are settled elsewhere holds no payment rows, so it cannot see a provider call back, a deadline pass, or an operator refund something over there. Those arrive as signed notices and are announced locally under the same constant, carrying the same PaymentInterface.

Nothing above changes for a subscriber. It is the same event name, the same event object and the same handled flag, which travels back to the engine host in the answer, so a default reaction shipped there stands down for a remote consumer exactly as it does for a local one. The subscriber never learns there was a wire.

Two things are worth knowing when writing one for such a site. Delivery is best effort, so a subscriber that must not miss an outcome should read the payment rather than only listen; and the events keep their existing idempotence requirement, which a notice sent twice does not make stricter. See Remote.