Skip to content

Architecture

Kessai has three moving parts: the payment entity, the gateway plugin type, and the payment manager that ties them together.

The payment entity

kessai_payment is a content entity. Its fields:

Field Purpose
amount, currency The money snapshot, kept exact as a decimal string.
state One of the STATE_* constants (the lifecycle position).
kind What the payment is for, named by the caller and required. Never interpreted here.
gateway The gateway plugin id handling this payment.
reference The gateway-side transaction reference, if any.
checkout_id, checkout_url, checkout_expires, checkout_secret The provider checkout session, if one was started.
return_url Where the payer goes once the payment finishes.
token A reusable card token, for a later hold or charge.
store_card Whether the gateway should keep a reusable token.
card_valid_until A deadline the stored card must stay valid through.
expires When a still-pending payment lapses, or empty for one that never does.
subject_type, subject_id An optional reference to the thing paid for.

The subject is a plain type-and-id pair rather than a typed entity reference, so Kessai never has to know the entity types its consumers pay for.

Naming a kind

create(), authorizeToken() and chargeToken() all require a kind, and kessai ships none of its own: a booking site names its payments booking, guarantee and no_show_fee, a rental shop names them something else. The value is a machine name (lowercase letters, digits and underscores), because it is built into lock names and matched in queries.

Give two purposes two kinds. A payment is deduplicated per subject and kind, so a fee charged under the checkout payment's kind is treated as that payment: the charge reuses the record instead of creating one, and a refund of the fee reads as a refund of the booking. That is what the required argument is for, and why there is no default to inherit by accident.

The manager

kessai.payment_manager (PaymentManagerInterface) is the only place state changes. It is gateway-agnostic: it asks the gateway to do the provider work, then records the resulting state and dispatches an event. Consumers decide what a settled or failed payment means for their domain.

The manager's amount is always supplied by the caller. Kessai does not price anything; a consumer computes the amount (from an order, a cart, a policy) and hands it in.

Concurrency

Payment callbacks arrive more than once and out of order (a browser return and a webhook for the same payment, a retried signal). Kessai is built for that:

  • State changes (capture, authorize, cancel, cancelPending, fail, expire, refund) use a locked compare-and-set on the payment row: the transition only happens if the payment is still in the expected source state, so a duplicate callback is a silent no-op and a late fail can never flip an already-captured payment.
  • Holds and charges are created once per subject and kind under a lock, so two concurrent callers cannot both place a hold or charge the same card twice. When a subject already has an in-flight record of that kind, it is reused instead of duplicated.
  • Operations that call a provider (capture, cancel, refund) hold a lock named for the payment and the operation for as long as they run, guard and gateway call together, and re-read the payment inside it.

That last one exists because a compare-and-set protects the record and not the provider. It runs after the gateway call, so without the operation lock two concurrent callers both read the same state, both pass their guard, both reach the provider, and only then race to write the outcome: recorded once, performed twice. For a capture or a refund that is money.

A caller that cannot take the lock does nothing at all rather than falling through, which is the opposite of the create path above and is deliberate: there the in-flight re-check still catches a duplicate before any provider is called, whereas here the provider call is the operation. Whoever holds the lock is already doing the work, so the second caller re-reads the state, logs, and returns.

The lock is scoped to one operation on one payment, so it never queues unrelated work, and it is a lease with an expiry, so a process that dies holding it recovers on its own. It is not treated as a proof: the compare-and-set stays exactly where it was, so a lock lost to an expiry or a backend failure degrades to the old behavior rather than to a double record.

Kernel tests see no locking unless they ask for it

KernelTestBase registers a NullLockBackend whose acquire() always returns TRUE, so contention is invisible under it and a test can pass while the lock does nothing. A test that means to exercise locking has to register a real backend in register(), as PaymentOperationLockTest does.

Checkout sessions

A redirect gateway sends the payer to a provider-hosted page. That page is a session, and the engine owns it rather than each gateway keeping its own copy. PaymentManager::initiate() is the only place one is started or reused:

  • a payment already holding a session that has not lapsed gets that one back, and the gateway is not called;
  • otherwise the gateway starts one, and the manager persists it.

So a payer who reaches the handoff route twice lands back on the page they left instead of on a second session that orphans the first. That matters because the return resolves against the stored session: minting one per visit means a payer can come back against a session the site no longer tracks. Keeping one session also preserves the attempt allowance the provider granted it, so a declined card is retried inside the session rather than needing a new one.

A gateway hands back a CheckoutSession: an id, the URL, when it lapses, and optionally a return secret the provider echoes on the way back. Verify one with PaymentManagerInterface::checkoutReturnMatches(), which passes when either side has no secret, so a provider that issues none is unaffected.

Two clocks, and which one wins

The session window belongs to the provider; the expires deadline belongs to the engine. A gateway must not open a session that outlives the payment deadline, or the provider could take money against a record the reaper has already written off. Clamp at the moment the session is started, since the remaining window shrinks as the payment ages.

Expiry

A payer who abandons a hosted checkout would otherwise leave the payment pending forever, and anything a consumer was holding for it with it. So create() stamps an expires deadline and a cron hook sweeps the pending payments past theirs, up to 200 per run, recording each expired and announcing it.

The gateway decides how long its payments live, because it is what knows the shape of its own checkout. defaultDeadline() returns a PaymentDeadline:

PaymentDeadline::never() Never lapses. The base class default, so an offline gateway settled by hand days later needs no code.
PaymentDeadline::seconds($n) A window of the gateway's own, e.g. matching the checkout session it opens.
PaymentDeadline::siteDefault() Defer to kessai.settings:default_payment_deadline (1800 seconds as shipped).

An explicit $deadline passed to create() overrides all of it, and a non-positive one opts the payment out entirely.

Reconciling before writing a payment off

The sweep asks the provider before recording an expiry. A local deadline is a statement about this site's records, not evidence that no money moved: when the payer's browser return and the webhook both failed, they paid and only the provider knows. PaymentGatewayInterface::reconcile() fetches the authoritative outcome and reports it through the manager; a payment it resolves is not expired. Only a payment that actually reached a provider (one holding a checkout session) is asked, so an offline payment costs no API call.

Reconciling must not change which outcome gets recorded, only how soon it is known. Asked about a checkout nobody paid, a gateway reports NothingHappened and leaves the payment alone, so the deadline still decides and a run with the budget to ask reaches the same expired a run without it reaches by the clock. What a gateway must never do here is record a failure: a checkout that took no money is not one the provider refused, and failed is reserved for the refusal.

The sweep uses the same locked compare-and-set as every other transition, so an outcome arriving while cron runs still wins the row. A payment with no deadline is never swept, and a run that hits the 200 cap says so in the log.

reconcile() answers with a ReconcileOutcome, and only one of the three licenses an expiry:

Answer What the sweep does
Resolved Nothing. Whatever happened, happened.
NothingHappened Expires the payment. The provider confirmed it.
Unavailable Leaves it pending and asks again next run.

The distinction is the point. A boolean cannot tell "the provider says nothing happened" from "the provider could not be reached", and treating the second as the first means an outage writes off every due payment at once, unasked, which is the failure this exists to prevent. A failed request is never NothingHappened.

Reconciling costs a network call each, and a provider SDK may offer no way to time one out, so the number of payments a single run may ask about is bounded by kessai.settings:reconcile_batch (20 as shipped). A payment the budget does not reach also stays pending, and the next run asks about it.

Setting the budget to 0 is a different thing from a run spending it, and the sweep treats them differently. A run that spends its budget defers what it did not reach, because a later run will ask. A site that configured no budget defers nothing, because no run ever will: holding those payments back would wait out the whole grace period for an answer that is never coming, then expire them anyway and blame a provider nobody contacted. So 0 means what it says, no reconciliation and expiry on the local deadline alone. It is the same reasoning that makes PaymentGatewayBase::reconcile() answer NothingHappened rather than Unavailable.

Deferral is bounded, or payments pile up forever

A payment left pending for want of an answer is expired anyway once it is a day past its deadline, and the run logs a warning naming how many. Without that bound a permanently unreachable provider, a decommissioned gateway or pulled credentials, would accumulate pending payments without limit and pay for a doomed request on each of them every run. A day is the line between a provider having a bad hour, which resolves itself, and one being gone, which an operator has to deal with either way.

Settings

Both engine-wide settings live at Administration › Configuration › Web services › Payment settings, and the gateway submodules hang their own pages underneath it.

Setting Purpose
default_payment_deadline The fallback deadline, used only by a gateway that declares no window of its own.
reconcile_batch How many payments one sweep may ask a provider about.

Both are integers constrained to zero or above in config schema, so a negative value is refused however the configuration is written, not only through the form.

What is public

Anything marked @api is what a consumer or a gateway author may build on, and what a deprecation would have to be announced for. Everything else is internal and may change in any release.

Public: PaymentManagerInterface, PaymentInterface, PaymentGatewayInterface and the two capability interfaces, PaymentGatewayBase, the #[PaymentGateway] attribute, CheckoutSession, PaymentDeadline, ReconcileOutcome, PaymentEvents and PaymentEvent, the three gateway exceptions, and PaymentStorageSchema (a consumer subclasses it to add an index).

Not public, deliberately: PaymentManager itself, because the interface is the contract and the implementation is free to move; the Payment entity class, since PaymentInterface is what to type against; the controllers, forms and hook classes, which are wiring rather than API. The one exception is PaymentHandoffController::handoffUrl(), marked on its own, because sending a payer to the handoff route is something every consumer has to do and the alternative is each of them building the URL and its token by hand.

Extending the storage schema

PaymentStorageSchema adds the indexes the manager's own queries need. A consumer that puts its own base fields on the payment entity can index them by extending that class and overriding ::indexes(), returning parent::indexes() merged with its own entries, then swapping the handler in from hook_entity_type_alter():

#[Hook('entity_type_alter')]
public function entityTypeAlter(array &$entity_types): void {
  $entity_types['kessai_payment']->setHandlerClass('storage_schema', MyPaymentStorageSchema::class);
}

Merging with parent::indexes() keeps kessai's own indexes in place instead of replacing them.

An index added later is not applied to an existing site

Core applies a storage schema's indexes when the entity type is installed, and an entity definition update does not add one that the schema only started declaring afterwards. On an existing site the table has to be altered directly and the schema core keeps in key/value updated to match. While kessai is in alpha, reinstalling is the supported way to pick up a schema change.