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 latefailcan 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.