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_ENDfor a hold, orCAPTURED_AFTER_ENDfor a charge, once; - marks the payment (
hasMoneyAfterEnd()), so a consumer that missed the announcement finds it withfindPayments(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, whilehasMoneyAfterEnd()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 withrecordMoneyAfterEndGivenBack(), 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.