Skip to content

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.