Kessai Payment¶
A lightweight, gateway-agnostic payment state machine for Drupal.
Kessai records a payment and drives it through a fixed lifecycle. It never talks to a payment provider itself: providers plug in as gateway plugins, and the outcome of a payment is reported back to the manager, which records the state change and announces it as an event for consumers to react to.
Kessai is deliberately unopinionated about what is being paid for. A payment may reference any entity as its subject (an order, a booking, a membership, a donation), or none at all.
The lifecycle¶
A payment is created pending. From there:
- A direct sale is captured (the money is taken), fails, or is later refunded.
- A payment the provider only authorized is recorded authorized, then either captured or cancelled (the hold is voided before capture).
- A payment nobody completed ends cancelled (the payer deliberately backed out), failed (the gateway declined it or errored) or expired (its deadline passed while it was still pending). All three are kept apart so a consumer can tell a routine abandonment from a real problem.
pending ──capture──▶ captured ──refund──▶ refunded
│ │
│ └──authorize──▶ authorized ──capture──▶ captured
│ │
│ └──cancel──▶ cancelled
│
├──fail──▶ failed
├──cancel──▶ cancelled
└──expire──▶ expired
Every transition is a locked compare-and-set, so a duplicate provider callback is a no-op and a late one can never move a payment that already settled. See Architecture.
Quick start¶
Take a payment through a gateway and react to the outcome:
use Drupal\kessai\Controller\PaymentHandoffController;
$manager = \Drupal::service('kessai.payment_manager');
// Create a pending payment for a subject entity.
$payment = $manager->create('42.00', 'EUR', 'worldline', 'booking', $subject);
// For a redirect gateway, send the payer to the handoff URL rather than
// calling initiate() yourself: the deliberate click is what announces the
// start of the round-trip.
$url = PaymentHandoffController::handoffUrl($payment, $return_url);
// Later, when the provider reports the outcome (a return, a webhook, an
// operator), record it through the manager:
$manager->capture($payment); // or ->fail($payment)
An offline payment needs no handoff: create it against the manual gateway and
an operator records the outcome later with capture() or fail().
React to \Drupal\kessai\Event\PaymentEvents::CAPTURED to do whatever a taken
payment means for your domain. See Events.
The gateway decides whether a payment expires
create() asks the gateway how long its payments live, so an offline flow
settled by hand days later never lapses and needs no argument to say so,
while a redirect gateway gets the window matching its checkout. Cron records
anything still pending past its deadline as expired, after asking the
provider whether it was in fact paid. Pass an explicit $deadline to override the
gateway, or 0 for a payment that must never lapse. See
Architecture and
Events.
Next steps¶
- Architecture: the entity, the manager, and concurrency.
- Gateways: writing a payment provider integration.
- Events: reacting to payment outcomes.