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 engine, 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 name a subject (an order, a booking, a membership, a donation) as a type and an id in the consumer's own words, which need not be an entity on this site, or name 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, in whole or in part.
  • A payment the provider only authorized is recorded authorized, then either captured or released (the hold is voided before capture). Either can be partial: a capture claims part of the hold, and a release releases part of it while the rest stays claimable.
  • A payment nobody completed ends canceled (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 the balance──▶ refunded
   │  │                  ▲    │
   │  │                  └────┘ refund part of it: still captured, less balance
   │  │
   │  └──authorize──▶ authorized ──capture──▶ captured
   │                     │  ▲    │
   │                     │  └────┘ release part of it: still authorized, less hold
   │                     └──release the rest──▶ released
   │
   ├──fail──▶ failed
   ├──cancel──▶ canceled
   └──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.

Every movement of money is a row

The payment carries the running totals; what actually happened is a record per movement, so a total always has rows behind it and two endings that move the same money stay legible apart.

Record Moves Made by
kessai_claim Takes money: off a hold, or recording what a provider already took capture(), recordPaymentReceived(), chargeStoredPaymentMethod(), recordTakenAndGivenBack()
kessai_refund Gives back money that was collected refund(), recordTakenAndGivenBack()
kessai_release Releases a hold nobody ever collected release(), recordTakenAndGivenBack()

Money the provider took and the gateway handed straight back makes rows on both sides at once, which is why recordTakenAndGivenBack() appears three times: a collected one leaves a claim and a refund, a merely held one leaves a release.

Each carries its amount, its own state, the provider's reference for it where there is one, and the caller's word for what it was. A refund and a release are deliberately not the same thing: one moves settled money, the other only frees the payer's available balance, and providers give them two endpoints and two answers. See Architecture.

Quick start

Take a payment through a gateway and react to the outcome:

$payment_client = \Drupal::service(\Drupal\kessai\PaymentClientInterface::class);

// Which account to take it through, by uuid: that is what a payment records,
// and what means the same thing whether this site settles its own payments or
// a Kessai on another host does. getAvailableAccounts() answers uuid to label,
// so a form offers the label and stores the uuid.
$account_uuid = array_key_first($payment_client->getAvailableAccounts());

// Create a pending payment for a subject entity. Everything the request needs
// is named, so nothing depends on argument order, and anything your own side
// wants to recognize the payment by later travels with it as metadata.
$payment = $payment_client->create(new \Drupal\kessai\PaymentRequest(
  amount: new \Drupal\kessai\Money('42.00', 'EUR'),
  account_uuid: $account_uuid,
  kind: 'booking',
  subject: \Drupal\kessai\PaymentSubject::createFromEntity($subject),
  metadata: ['my_module_order' => '17'],
  // Where the payer comes back to, on either kind of site.
  return_url: $return_url,
));

// For a redirect gateway, send the payer to the handoff URL rather than
// opening the payment session yourself: the deliberate click announces the
// start of the round-trip.
$url = $payment->getHandoffUrl();

// A provider's outcome is recorded by its gateway's own return and webhook
// on the site holding the payments. A payment at an account that is manually
// settled is recorded by whoever received the money, here or from a consumer
// site, and only there:
if ($payment_client->isAccountManuallySettled($payment->getAccountUuid())) {
  $payment_client->recordPaymentReceived($payment);
}

An offline payment needs no handoff: create it against an account of the manual gateway, and an operator records it received later from the payments list (see Manual payments), or code records the outcome with recordPaymentReceived() 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 the bundled manual gateway takes the deadline its account sets (none by default), while a redirect gateway gets the window matching its session. Cron records anything still pending past its deadline as expired, after asking the provider whether it was in fact paid. A caller can end a payment earlier with maximumDeadline, and later only when the account lets a site extend its deadline. See Bounding a checkout, Architecture and Events.

Bounding a checkout

A checkout where the payer can start a payment, give up, and start another one through another account must not restart its time with each payment. Give every payment of the checkout the same maximum deadline:

$maximum_deadline = $checkout_started + 3 * 86400;
$payment_client->create(new PaymentRequest(
  amount: new Money('120.00', 'EUR'),
  account_uuid: $card_uuid,
  kind: 'booking',
  maximum_deadline: $maximum_deadline,
));

With a maximum deadline at 10:00 on day 4, a card account (20 minutes, minimum 1 minute), a transfer account (3 days, minimum 1 day) and a cheque account (10 days, minimum 5 days):

Payment Deadline
Card at 10:00 on day 1 10:20
Transfer at 10:05 on day 1 10:00 on day 4
Cheque on day 1 refused: less than 5 days left
Card on day 4 at 9:50 10:00
Transfer on day 3 at 11:00 refused: less than 1 day left
Any payment after 10:00 on day 4 refused
  • The account decides how long its payments last. A maximum deadline only shortens that. An account whose payments never lapse ends at the maximum deadline.
  • An account can let a site extend its deadline, when its gateway allows it. Its payments then end at the maximum deadline, even after the account's own deadline: a transfer's 3 days can become 5 for one booking.
  • Each account has a minimum time to pay: a card checkout needs a minute, a standard transfer a day, a cheque by post several days. create() refuses a payment that would leave less, and getAvailableAccounts($maximum_deadline) leaves that account out, so a checkout late in its window offers the card and not the cheque.
  • A maximum deadline that has passed is refused.
  • To extend a checkout, give its next payment a later maximum deadline.

Next steps

  • Architecture: the entities, the engine, and concurrency.
  • Gateways: writing a payment provider integration.
  • Events: reacting to payment outcomes.
  • Remote: taking payments through a Kessai on another host.
  • Worldline: the bundled hosted-checkout gateway.
  • Metrics: a measured snapshot of the codebase.