Skip to content

Architecture

Kessai has three moving parts: the payment entity, the gateway plugin type with the accounts configured against it, and the payment engine that ties them together.

Two modules, and which one you install

The contract and the engine are separate modules, and a site can have either without the other.

Module Holds Depends on
kessai PaymentClientInterface, the values a caller hands it (PaymentRequest, PaymentQuery, PaymentSubject), the reading types it answers with (PaymentInterface, MovementInterface, ClaimInterface, RefundInterface, ReleaseInterface, RecordInterface), PaymentEvent and PaymentEvents, what two hosts must agree on to exchange payments (PaymentWire, PaymentNotice), the exceptions a caller catches, and KessaiLoggerTrait for the channel they all write to nothing
kessai_engine the engine, the entities the records are stored in, the gateway plugin type and the accounts configured against it, and the admin pages kessai

A site that takes its own payments installs kessai_engine, which brings the contract with it. The split is there for the site that does not: kessai names no engine type in any signature and depends on no module, so a site whose payments are settled by a kessai on another host installs the contract and a client that speaks to that host, and never carries an engine, its entity types, its schema or its cron.

Two submodules make that second arrangement real, and a site that takes only its own payments installs neither: kessai_server puts an engine on a network for named consumers, and kessai_client answers the contract against one. See Remote.

A consumer depends on kessai and never on an implementation of it. Which one settles a site's payments is the site's decision, and a module that named one would make it: depending on the engine puts an engine on every site that installs the consumer, including the site that must not have one.

That is what PaymentClientResolver is for. The contract declares its own service name, built from whichever implementation announced itself with the kessai_payment_client tag, so a consumer's container compiles on a site that has not chosen yet and the refusal arrives when something first asks to take a payment. A tag rather than a name an implementation overwrites, because module service files load in installation order and a name simply overwritten has a winner decided by which module happens to sort later; the engine sits below the default priority so a client a site installed on purpose outranks it.

kessai is hidden, because nobody installs it by hand: it arrives as a dependency of whatever uses it. On its own it takes no payment, and says so on the status report rather than leaving a payer to find out.

The short name belongs to the contract because that is what most code holds. A consumer writes Drupal\kessai\PaymentInterface and hears PaymentEvents::CAPTURED; only the engine and the gateways that plug into it write Drupal\kessai_engine\. ContractModuleIsALeafTest is what keeps the contract a leaf, because it would otherwise stop being one quietly: every site and every test that exists today has the engine installed beside it and would go on passing.

A kernel test lists both modules, since kernel tests do not resolve module dependencies.

The payment entity

kessai_payment is a content entity. Its fields:

Field Purpose
amount, currency The money snapshot. Read and written as a Money, stored in a decimal column of precision 19 and scale 4 (see Money).
captured How much of the amount was actually taken, across every claim against it, and the sum of the payment's completed claims.
hold_kept Whether a capture left the rest of the authorization claimable instead of closing it.
released How much of the hold was released without being claimed, the sum of the payment's completed releases.
refunded How much of what was captured has gone back, the sum of the payment's completed refunds.
state One of the STATE_* constants (the lifecycle position).
kind What the payment is for, named by the caller and required. Never interpreted here.
account_uuid The uuid of the account this payment was taken against: the account at a provider, not the provider.
consumer Which consumer asked for the payment, where an engine answers other sites. Empty for a payment this site took for itself. Set by the engine acting for that consumer (PaymentEngineInterface::forConsumer()), never by a request.
reference The gateway-side transaction reference, if any.
source_payment For a payment taken with the payer gone, the payment whose stored payment method it was made with. Chosen on the first ask and kept, so asking again sends the same request.
session_id, session_url, session_expires, session_secret The provider session, if one was started.
return_url Where the payer goes once the payment finishes.
payment_method_id The provider's id of a stored payment method, for a later hold or charge.
store_payment_method Whether the payer agreed that a later hold or charge may be made on their payment method with them gone. Forgetting it withdraws the agreement.
payment_method_valid_until A deadline the payment method must stay valid through, or empty for none.
authenticated Whether the payer completed this payment themselves at the provider. What makes a payment one a later merchant-initiated charge can be made against, so it records what happened rather than being inferred from the state, which moves on: releasing a hold says nothing about who approved it.
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.
created, changed When the payment was created, and when it last changed.

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(), authorizeStoredPaymentMethod() and chargeStoredPaymentMethod() 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. A kind may not start with kessai_: that space is kessai's own. The rows that give back money a provider moved after a payment ended are of kind kessai_money_after_end, so a movement of the caller's own is never taken for one of them.

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.

Every movement takes a kind of its own too, and it may differ from the payment's. Where a payment stands for two things because it was collected as one figure (a price and a refundable deposit), returning the deposit is a refund of a deposit, and the row says so; claiming a no-show fee out of a guarantee is claiming a fee, and releasing what nobody claimed is releasing a deposit. It is optional on all three, unlike on a payment: a claim, a refund and a release are only ever addressed through the payment they belong to, so nothing depends on them being named.

Four amounts, and the two questions derived from them

Four amounts are stored. amount is what was asked for or authorized, and it is never rewritten; the other three are running totals, each the sum of one kind of completed movement:

Stored What it totals
amount What was asked for or authorized. Immutable.
captured What the claims took.
released What the releases released off the hold, never collected.
refunded What the refunds gave back after capture.

Two more are derived, and neither is a column:

  • getHoldAmount() is amount - released - captured, and zero once the authorization is closed: the most a capture can still claim, and the most a release can still release. Whether it is open is derived too, from the state, because an authorized payment holds its amount by definition and a resolved one holds nothing. The one thing the states cannot express is a capture that deliberately kept the rest claimable, and hold_kept records only that.
  • getBalance() is captured - refunded: the most a refund can still return.

A partial capture leaves the amount alone. A guarantee held for 150,00 and claimed for 10,00 says both, so the record can answer what the payer was committed to, and a later refund is measured against the 10,00 that was actually collected rather than the 150,00 that never was.

Whether a capture is the last one is the caller's to say, through $final, because the amount cannot say it: claiming part of a hold is as often the last word on it as the first of two. capture($payment, '10.00') closes the authorization, so the provider releases the rest. capture($payment, '10.00', final: FALSE) leaves 140,00 claimable, which is what lets a no-show fee and a damage claim come out of one guarantee. Something must then claim or release the rest: an authorization left open holds the payer's money until it lapses.

CAPTURED is announced per claim rather than per payment, since each one is money moving. Both shipped consumers are already idempotent about it (confirming a booking that is already confirmed does nothing, resuming a step that is not parked resumes nothing), which is the property to keep if you subscribe.

Released means nothing was ever taken. A hold that was claimed and then released down to nothing stays captured, with its guarantee ended; only a hold that gave everything back untouched reaches released. canceled is a pending payment called off before the payer paid: nothing was ever held.

A hold is not a promise the provider keeps forever

getHoldAmount() says what this engine still considers claimable. It cannot say whether the provider will still honor it: an authorization lapses after some days, and measured against Worldline the refusal arrives only when the capture is attempted, with the transaction reporting itself live until then. Anything holding an amount across weeks should place a fresh authorization when the money is wanted rather than sit on the original one. The transaction underneath can still have a later charge made against it long after its hold has died, which is what makes that possible. See Worldline.

The refund entity

A payment can be returned in parts, so what came back cannot be a single fact on the payment. kessai_refund is one row per return of money:

Field Purpose
payment The payment this refund returns money from. Required.
amount, currency How much went back, and in what.
state pending, completed or failed.
kind What is being returned, named by the caller. Optional.
request_key The caller's name for the request that made it, so the same request asked again is answered by this row. Optional; claims and releases carry it too.
reference The provider's own reference for this refund.

The payment's refunded total is the sum of its completed refunds, cached there so asking what is still earned (getBalance()) is one column read rather than a query.

A row is written before the provider is asked, in pending. That is what lets a retry after a failure mid-call carry the same idempotency key as the attempt it repeats, since the key is derived from the row's id. A row left pending is therefore an attempt whose outcome nobody knows, which is the truth in that case; a provider refusal leaves it failed and the payment's balance untouched. A provider that gives no final answer (it timed out, or cannot act yet) leaves the row pending, and the next refund of the payment asks about that row again under its key, so a refund that did go through is not made twice. A different amount is refused until it is answered. Releases and claims work the same way.

Providers work the same way, which is the other reason for the row: a Worldline or Stripe refund is a transaction in its own right with its own identifier and status, and one payment may carry several. Without somewhere to keep that identifier, one refund of several cannot be matched against the provider again.

The release entity

kessai_release is the same shape for the other direction: one row per release of a hold, carrying the payment, the amount, a state, an optional kind and a provider reference where the provider issues one. The payment's released total is the sum of its completed releases, and getHoldAmount() is what is left.

Deliberately not the same entity as a refund. A refund moves settled money back to the payer; a release frees an amount nobody ever collected, so nothing moves except the payer's available balance. Providers give the two operations two endpoints and two answers, and a record that conflated them could not be reconciled against either. Worldline, for instance, returns an identifier per refund and none per release, so a release row's reference is often empty while a refund's is not.

A release is written pending before the provider is asked, for the same reason a refund is: the row's id is what the request is keyed on.

The claim entity

kessai_claim is the third movement record, and the one that takes money rather than giving it back: one row per claim against a payment, carrying the payment, the amount, a state, an optional kind, whether it closed the hold (final) and a provider reference where the provider issues one. The payment's captured total is the sum of its completed claims.

The row is what makes two endings legible. One guarantee can stand behind a no-show fee and a damage claim, and the payment records either as the same movement of the same total; only the claim says which it was. getClaims() returns them oldest first, so a guarantee that yielded a fee and then a claim reads in the order it happened.

As for a refund and a release, the row is what the request is keyed on (PaymentOperationKey::getCaptureKey()). A claim against a hold is written pending before the gateway is asked, so an attempt whose outcome nobody knows is visible rather than absent, is asked again under the same key, and is saved again with the answer. A claim the provider refused stays a row of its own, so the next claim is a new request, unless it is asked again under the same request key, which is answered with that refusal.

A claim is recorded whichever path took the money, so the total always has rows behind it. Three of those paths ask no gateway at all, because the money was already taken before the engine was told: a direct sale recorded captured, a merchant-initiated charge against a stored payment method, and a checkout the provider collected before the engine rejected it. Their rows are written completed rather than pending, because there is no attempt left that could fail.

A direct sale takes no amount and no finality from the caller: the provider took the whole sum at checkout, so there is no hold to claim part of and none to leave standing. recordPaymentReceived() records the full, final claim. capture() only ever claims from a hold, and refuses a payment still pending: its provider has not reported holding anything yet.

An authorization records nothing

authorizeStoredPaymentMethod() reserves an amount and collects none of it, so it writes no claim and leaves the captured total at zero. What is claimable is derived (getHoldAmount()), not recorded. Only money that actually moved gets a row, which is what keeps the total equal to the sum of the completed claims.

The shape all three share

The three are one type written once. MovementInterface is what a claim, a refund and a release each answer: the payment it moved money on, the amount and currency, a state of its own, an optional kind and the provider's reference for that one operation. Entity\MovementBase holds the storage shape and the readers, and each subclass supplies only the descriptions that say what it is for. They stay three entity types, because each is counted and queried separately and one table with a discriminator would make every per-kind total a filtered scan.

A movement answers with its payment as storage has it, not as it first saw it, which is worth stating because the obvious reading of an entity reference gets it wrong: the reference item keeps whatever it loaded the first time for the rest of the request, and every money write in the engine reloads the payment and saves that copy. So a movement held across a refund would otherwise hand back the totals as they stood before it.

RecordInterface sits above MovementInterface and PaymentInterface alike, naming the id every record is known by and the three questions a payment and a movement answer identically: the amount, the currency and the provider's reference. It is what lets an operation say it hands back one of this module's records without widening to any content entity.

It is deliberately not an entity type, and that is the split that runs through all of these. Each record is stored as a content entity, and RecordEntityInterface is where that is said, together with every setter. PaymentInterface and MovementInterface keep only what a caller reads, so holding one is not holding a row in this site's database: a kessai answering from another host hands back the same types, built from what crossed the wire, and a consumer cannot tell which it holds. PaymentEntityInterface, MovementEntityInterface and the three per-movement pairs are what the engine, its storage and a gateway settling against a provider take, because only those have a row to write to.

Three readers are on the writing side with the setters rather than where a consumer could reach them, and the reason is not write access. The provider session holds the secret a return is bound to, and isReturnSecretValid() and isHandoffTokenValid() each answer from a secret this site keeps: the session's, and the site's own hash salt. None of the three can be answered by a kessai somewhere else, and none of them should be handed out.

What a consumer wants from the session is not the session. It asks whether the payer is away at a provider, because a payment one has seen may still be paid and is not one to cancel, expire or replace on a local deadline. That is PaymentInterface::isAwaitingProvider(), a boolean carrying no secret, which a kessai on another host can answer as readily as this one.

A consumer reads payments back with getPayments(), which is its only way back from ids it stored, and asks at checkout for the payer's payment method to be kept with PaymentRequest::$storePaymentMethod, which travels with the request because that is where the payer agreed to it. Both are on the client contract rather than on the entity: a consumer that had to reach the row for either would be a consumer that can only ever share this database.

A consumer also has things to say about a payment that kessai has no opinion on: which step of a workflow asked for it, which order line it pays. It says them as payment metadata, its own names and string values, which kessai stores and indexes and never reads. They travel with the request and are written once, as the payment is created: what a consumer wants to recognize its payment by is what it knows at the moment it asks for the payment, so nothing overwrites or forgets a name and two readers of one payment cannot disagree about what it carries. PaymentInterface::getMetadata() reads them back off a payment in hand.

What that read costs depends on which kessai answered the payment. A payment that arrived from another host carries its metadata in the values it arrived as, so reading it costs nothing. A payment this site holds carries it in rows of its own, because the names are the consumer's and there is no column for them, so reading it is a lookup. That is the right shape for a payment read one at a time, which is most of them, and the wrong one for a list: a door answering with several payments asks for the whole list at once through PaymentMetadataReaderInterface::readMetadataForPayments() rather than once per payment it writes down.

A value is a string, a name is between one and 128 characters and a value is at most 255, which is what the columns hold, and create(), authorizeStoredPaymentMethod() and chargeStoredPaymentMethod() each refuse anything else. The three of them, because a rule held at one entrance is a rule a caller meets by luck: the same names sent to a door that did not check reach the database, and what comes back is the database's complaint about a column the caller has never heard of, on the path that takes money. On a server that truncates rather than refusing it is quieter and worse, since the payment is then findable by a value nobody asked about. A value of another type is refused rather than cast, so that a consumer that wrote a number never reads a string back out of the payment it wrote it into.

The way back to payments a consumer holds no id for is findPayments(), and it takes a PaymentQuery: the subject they were taken for, or a subject type alone where the question is about every order of a kind rather than one; the kind and the states; a payment method id, exactly or at all; what the consumer remembered. Each field narrows and none widens, so a query naming nothing would be every payment on the site, and is refused rather than answered.

Its named arguments are snake_case, like the parameters of any constructor that does not promote them:

$payments = $payment_client->findPayments(new PaymentQuery(
  subject_type: 'node',
  has_payment_method_id: TRUE,
));

PaymentRequest reads the same way, account_uuid:, store_payment_method:, and so does every named argument kessai takes: the same names as on the wire. The values are read back through camelCase properties, $query->subjectType, $request->accountUuid.

A list asks about a whole page at once. For example, the payments of orders 12, 15 and 40 are one query: new PaymentQuery(subjects: [...]), with up to 100 subjects of one type. An empty list is refused. Read as "no subject given", it would answer with every payment of the consumer. Over the wire the subjects travel as subject_ids, one subject included.

It answers newest first, and that is part of the answer. A caller asks either for all of them, where the order does not matter, or for the current one, where it decides which payment gets settled and which gets let go; an unordered finder answers the second wrongly and says nothing about having done so.

That it takes a value is what lets it cross. A storage query builder handed to the caller to execute is this site's own machinery rather than a value, and a kessai somewhere else could not answer with one.

The in-process engine refuses a payment it did not make, on every door that takes one. Two kessai each number their payments from one, so an id made elsewhere names a row of this site's as readily as its own: a write would settle the wrong payment and a read would hand back the wrong payment's movements. Whether a payment was ever saved is a separate question, and the answer is unchanged: the operations that take a lock need an id and refuse without one, and reading a never-saved payment's movements still answers that it has none.

A subscriber never asks what moved. An event that moved money carries the row that moved it, put there by whoever announced it, so nothing on that path asks a second question about what just happened.

Deleting a payment

Deleting a payment deletes the claims, refunds and releases that hang off it, inside the same storage transaction, so a payment and everything that moved money on it go all or nothing.

They are not left behind for two reasons. A movement holds a required reference to its payment and Drupal does not cascade through an entity reference, so a movement that outlived its payment would sit in permanent breach of its own constraint, owned by nothing. And it would not be free to keep: a movement carries an amount and the provider's reference for that one operation, and a provider reference is re-linkable at the provider by design, which makes these rows pseudonymous rather than anonymous. A site deleting a payment to honor a retention policy has to see them go with it, and deleting the payment is the only signal the module gets that the data is meant to be gone.

The movement types are read from the entity definitions rather than from a list, so a movement type added later is covered by the same rule.

What survives is the trail, if there is one

The record of what happened does not depend on these rows: with kessai_audit_trail enabled, the movement behind every lifecycle event is written to the chain as it happens. That module is optional and its recording is deliberately best effort, so on a site without it a delete leaves nothing behind, which is what a site that installed no audit log asked for.

The engine

kessai.payment_engine (PaymentClientInterface) 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 engine'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.

Every amount is a Money: a figure and its ISO 4217 currency, which refuses a negative figure, a malformed one, and one with more decimals than its currency has (see Money). So what is left for the engine to refuse is a figure larger than the column holds, and create(), authorizeStoredPaymentMethod() and chargeStoredPaymentMethod() refuse it for the reason a metadata value too long for its own is: otherwise it reaches the database, and what the caller gets back is the database's complaint about a column it has never heard of, on the path that takes money. The bound is read off the field, so it cannot drift from it. Zero is legal, for a payment that takes nothing, but it cannot store a payment method: that is stored through a checkout that authorizes money, so something free that needs one on file authorizes a guarantee and releases it.

The three that move money hold the same door, and want a positive amount in the payment's currency. capture(), refund() and release() each take an amount that may be less than the payment's, so each of them checks the one it is given: nothing moves in a zero amount, and a refund in yen of a payment in euros has no row that could record it. NULL is not an amount and does not pass through it — it means "whatever is still held" or "whatever is still refundable", which each operation resolves against the payment and guards for itself.

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, release, 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. A refund adds to the payment's refunded total the same way, against the locked row, since a total is read-modify-write.
  • Every write to a payment goes against the locked row, not just the ones that read it first. Saving an entity writes all of its fields, so saving the object a request came in with writes that request's idea of the others too: a handoff recording where the payer goes next, on a copy loaded a moment before a webhook settled the payment, would put the payment back to pending with a captured total of zero and the claim row still saying money had moved.

Reloading the row first is not the same guarantee, and it is the mistake this rule is easiest to lapse into. The read and the save are two statements, and the save still writes every field, so a settlement committed between them is put back exactly as a stale copy would have put it back. Only the write that sets the named field on a row it holds locked is safe.

That is why a gateway never saves a payment itself. What it learns at the provider goes through a door of its own, each of which writes one field against the locked row and reflects it onto the object the caller passed: recordProviderReference(), recordPayerAuthentication() and recordStoredPaymentMethod(). A gateway's own settlement lock does not cover this. It serializes that gateway's channels against each other, and the payer clicking pay again is not one of them.

Those three, recordTakenAndGivenBack(), failGivingBack() and reportMoneyOnEndedPayment() are PaymentRecorderInterface, which carries them and nothing else. It does not extend PaymentClientInterface, because a gateway never creates or refunds a payment: a gateway that settles asks for both interfaces, and one that only records asks for one. The service behind them is the same, so the split buys nothing at runtime; what it buys is that neither interface promises an audience something that is not theirs. - 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 stored payment method twice. When a subject already has an in-flight record of that kind, it is reused instead of duplicated. With no subject there is nothing to key the lock on, so two concurrent calls place two holds; that is what the subject argument buys.

  • Creating a payment is serialized the same way, per subject and kind, and a still-pending payment of that kind for that subject, at the same account, in the same currency and for the same amount, is handed back rather than duplicated. A checkout page submitted twice, or open in two tabs, asks for two payments: each would open a session of its own at the provider and each would be payable, so one booking could be paid for twice and one of the two given back by hand.

Everything the caller named is part of that question, because reuse must not answer with a payment nobody asked for. A caller asking for 50,00 that received the 42,00 already pending would send the payer to pay the wrong sum, and silently. One asking for an offline payment that received the pending online one would hand the payer to a provider it did not choose, and recording that one captured would claim money from a provider that never took any. The account is often step configuration, so two steps of one flow really do name two of them for one subject. Two clicks on one page carry the same account, currency and amount by definition, so requiring all three costs that case nothing, and a caller whose figure or provider has changed gets a payment for what it asked. What it replaced stays pending until its deadline, so a caller that means to withdraw the old one should cancel it rather than leave it to lapse.

Only a payment the payer can still pay is handed back. A hold or a charge on a stored payment method is never one, even pending: it was sent to the provider with the payer gone, and a checkout opened on it as well would take the money twice. Nor is a payment past its deadline that the sweep has not written off yet: no handoff starts it, so the payer would be sent onward with nothing said. And it has to ask the payer what the request asks: to store the payment method or not, valid until the same date, and ending no later than the request's maximum deadline, or the later charge would find nothing to charge, or the payer could pay after the consumer stopped waiting.

The two stored payment method paths ask the same question without the amount, and that is deliberate: reusing the hold or charge that already stands is their whole point, and answering a larger request with a second authorization would hold the payer's money twice over. So a request the standing figure does not cover is answered with the standing figure and the run says so at warning severity, because nothing else would. A movement that has to stand beside another takes a kind of its own.

With no subject none of this applies, on any of the three: there is nothing to key a lock on and nothing that says two calls are about the same thing, so every call creates a payment. Naming a subject is what buys the deduplication. - Operations that call a provider (capture, release, refund, openPaymentSession) hold a lock on the payment for as long as they run, guard and gateway call together, and re-read the payment inside it. Capture and release share theirs, as the end of this section says. Opening a payment session is one of them because reusing one is a check-then-act across a provider call: two handoff visits arriving together would each read the absence of a session off their own copy and open one, and the second would orphan the first at the provider.

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 is refused with GatewayRetryableException: asking again once the first is done is safe.

A capture and a release share one lock, on the payment's hold, because both draw on it: each reads what is still held before asking the provider, so a release running while a capture was at the provider would release money the capture was claiming. A refund takes the same lock: it draws on the balance, but a payment whose hold is kept is refunded only once the hold ends, so a refund and a release of the rest have to see what the other wrote.

Otherwise 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 guards the record independently, so a lock lost to an expiry or a backend failure costs a wasted provider call rather than a double record.

The state moves before the money is recorded

Not every transition takes the operation lock. expire() and fail() do not: they touch no provider, so they go straight to the compare-and-set. That makes the order of the two writes a capture does load-bearing.

A direct sale therefore moves the payment out of pending first, and only then writes the claim row and the captured total. Recording the money first leaves a window the cron sweep can win: the payment ends expired while carrying a completed claim for the full amount, and no CAPTURED event ever fires, so nothing downstream confirms what was in fact paid for. Past the compare-and-set the payment is not pending, so neither cron nor a late failure can touch it. The state change and the money rows commit in one transaction, so a failure between them leaves neither: never a payment captured with nothing behind it. The event is dispatched after the commit, so a subscriber reads finished figures.

Money taken and given straight back

A gateway sometimes has to undo a payment the engine is about to reject: the stored payment method expires before a deadline it must outlive, or the created provider payment does not match the amount asked for. The provider really took or held the money and the gateway really put it back, so both are movements and both get rows, through PaymentRecorderInterface::recordTakenAndGivenBack(), which is a gateway's to call and no consumer's. Without them the merchant console carries a refund this site cannot account for.

An undo the provider refused is recorded too, as a failed row. That is the case an operator most needs to find: an amount still sitting on the payer's account, on a payment that is about to read failed, so nothing downstream will chase it. The payment's own state is left to the caller, because money taken and given back is a rejected payment and not a refunded sale.

A zero amount leaves no row, because there is no movement to record, and it says so at warning severity. By then the provider has already been asked to hand the money back, so a row skipped there is one nobody will ever write: a gateway that cannot name what moved should record the payment's own amount rather than zero, and say in the log that the figure is its own.

The amount is recorded as the provider named it, in the payment's currency: a figure in fils is a Money of three decimals, and the row holds all three. An amount in another currency is refused, because no row could record it as what it is.

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.

Payment sessions

Kessai never sees a payment instrument, so paying means sending the payer away to the provider's own page and getting them back. A payment session is what makes that round-trip possible, and it is four facts the provider hands over once, at the moment it creates one:

Fact What it is for
The session id The handle to ask the provider, server-side, what became of it.
The URL The page to send the payer to.
A return secret A value the provider echoes on the way back, so a return can be told from a stale or forged one. Not every provider issues one.
When it lapses Whether a later visit reuses this page or needs a fresh one.

They are stored because they cannot be asked for again. Worldline, for one, returns the redirect URL and the return secret when it creates the session and from nowhere else: asking it about the session afterwards gives a status and the created payment, no URL. A session not kept the moment it arrives leaves a payer who cannot be sent anywhere and a return that cannot be recognized.

The round-trip, step by step

1.  A consumer creates the payment. It is pending and holds no session.
        session_id: -   session_url: -   session_secret: -

2.  The consumer sends the payer to kessai's own handoff route, not to
    the provider:  /kessai/pay/42?kessai_token=...

3.  The payer clicks. The handoff route checks the token and the deadline,
    then asks the engine to open the payment session.

4.  The gateway asks the provider to create a session. The provider
    answers, once, with the four facts above.

5.  The engine stores them on the payment, before anything else can go
    wrong:
        session_id: 0d1b...   session_url: https://payment...
        session_secret: 7f2c...  session_expires: now + 30 min

6.  The route redirects. The payer is on the provider's page; kessai is out
    of the loop and knows nothing until they come back or the webhook fires.

7.  The provider returns the payer to the gateway's own return route,
    echoing the secret:
        /kessai/worldline/return/42?kessai_token=...&RETURNMAC=7f2c...

8.  The return route, in order:
      a. the per-payment token, or 403;
      b. the echoed secret against the stored one, or settle nothing;
      c. ask the provider itself, server-side, using the stored session
         id, what became of that session;
      d. settle on that answer. Nothing the browser carried is believed.

Step 8c is why the id is worth storing at all. The payer's browser is untrusted and can be told to say anything, so the query string settles nothing: it is the stored id that lets the engine go and ask the provider directly, out of band, and record what it says.

The two checks in 8a and 8b are not the same check. The token says this URL was minted by this site for this payment; the secret says this return belongs to the session the payment currently holds. A return carrying the secret of a session that has since been replaced fails the second while passing the first, which is exactly the case that must not settle anything.

Reuse, and why one session per payment

The engine owns the session rather than each gateway keeping its own copy. PaymentSessionOpenerInterface::openPaymentSession() is the only place one is started or reused, and the handoff route is its only caller:

  • 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 engine 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, whether the second visit follows the first or arrives with it. 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 refusal is retried inside the session rather than needing a new one.

A gateway hands the four facts back as a PaymentSession, a value object rather than an entity of its own: a payment keeps one at a time, and there is no history to count. A lapsed one is asked about once, through the gateway's reconcile(), before the next one replaces it, so a payer who paid on it as it lapsed is settled rather than offered a second session; a provider that cannot be asked leaves the payment where it is, and the payer is told to try again. Verify the secret with PaymentEntityInterface::isReturnSecretValid(), which passes when either side has none, so a provider that issues no secret is unaffected.

Because the session lives on the payment, writing a session is writing a payment: it goes against the payment's locked row, like every other write to one, for the reason given under Concurrency. The lock that serializes opening a session is a different one, and it is not a substitute: it keeps two handoff visits from each minting a session, and it holds nothing else off. A payer clicking pay again once their first session has lapsed, while the webhook for the payment they already made lands, is two requests that overlap on this payment and hold no lock in common.

No session at all is an answer, not a failure

An offline gateway returns NULL from createPaymentSession(): there is no page, because an operator settles the payment by hand later. That is the only thing NULL may mean. A gateway that meant to open a session and could not has to raise, or the payer is bounced onward in silence with no payment page and nothing to explain it. See Gateways.

The session is also what marks a payment as payer-driven

A deferred hold and a later charge of a stored payment method are merchant-initiated and carry no session. That absence is load-bearing: it is how a gateway's settlement path knows which payments a return or a webhook can be about, and how the expiry sweep knows which payments are worth asking a provider about before writing them off.

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 cron 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 walks away mid-payment 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 how its own payments are settled. getDefaultDeadline() returns a PaymentDeadline:

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

A caller can set the request's maximumDeadline, a timestamp: the payment never ends after it. The payment ends at the account's deadline when that comes first, and an account whose payments never lapse ends at the maximum deadline. Only an account that lets a site extend its deadline, through a gateway that can, ends later than its own deadline. A maximum deadline that has passed, or that leaves less than the account's minimum time to pay, is refused. See Bounding a checkout. A deadline ending after 2038-01-19 is refused, because the expires column cannot hold it.

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 engine; a payment it resolves is not expired. Only a payment that actually reached a provider is asked: one holding a payment session, or a stored-card hold or charge whose answer never came back (PaymentInterface::isAwaitingProvider()). An offline payment costs no API call. A gateway that has nothing to look such a charge up by answers Unavailable, so it is held back for the webhook as long as any payment is.

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_engine.settings:reconcile_batch (20 as shipped). A payment the budget does not reach also stays pending, and the next run asks about it. What the budget counts is calls actually made, so a payment naming a gateway this site no longer has spends none of it: it is answered locally and expired on its own deadline.

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 › Payments › Settings (/admin/payments/settings). A gateway has no settings page of its own: what one needs configured belongs to each account held at it, and is on that account's own form under Payments › Accounts.

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.

Rotating the hash salt

The idempotency key of every provider call, the handoff link a payer is sent through, and a gateway's return link are all derived from the site's hash salt ($settings['hash_salt']). Rotate it only when no movement is pending and no checkout is open. A claim, a refund or a release still unanswered is asked again under a new key, which a provider carries out a second time, and every handoff and return link already handed out stops working.

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.

The surface is shaped by audience, because there are two of them and they want different things, and the module split follows the same line: everything a consumer is given is in kessai, everything a gateway author is given is in kessai_engine.

A consumer gets PaymentClientInterface, and everything on it is something a consumer calls: create(), the transitions, the stored payment method operations, the reads, and the two that answer for the accounts a payment can be taken through, getAvailableAccounts() and isAccountAvailable(). Both speak in account uuids - the list is keyed by one, the check takes one - because that is what a payment records and what a stored choice has to hold if it is to mean the same thing on the site that settles it. Those two are how a checkout offers a choice and how it validates one it stored earlier, and they are the only way: the gateway plugin manager answers about plugins, and since a payment records an account rather than a provider, a list built from plugins offers values create() refuses and hides the second account a site holds at one provider. The questions about one account take its uuid the same way: getAccountCurrencies(), and isAccountManuallySettled() before offering to record a payment received.

AccountOptions goes with those two: it is the one rule for what a form offers, which is the accounts on offer plus the one that form already stored, because an account is retired by being disabled and a select posts what it displayed. Three projects need it and it is written once.

Then PaymentInterface, which answers everything that is a question about one payment, including getHandoffUrl(), the link that begins it; and the movement interfaces (RecordInterface, MovementInterface, ClaimInterface, RefundInterface, ReleaseInterface), which are what it reads; and PaymentEvents and PaymentEvent, which are how it hears. Every one of those is a reader, none of them requires an entity, and all of them are in kessai, which together is what lets the same code run against a kessai that is not this site's.

Two more are public to a consumer and are in kessai_engine, which is the honest place for them: PaymentStorageSchema, which a consumer subclasses to index its own fields on the payment, and OnwardUrl, which resolves where a payer goes next and answers with the redirect that takes them there. Both are about the stored payment and the request that carries it, so neither means anything on a site that holds only the contract. A consumer that uses either is a consumer on an engine site, and saying so is the point of listing them apart.

A gateway author gets PaymentGatewayInterface, the five capability interfaces (AuthorizingGatewayInterface, StoredPaymentMethodGatewayInterface, MoneyAfterEndGatewayInterface, ManuallySettledGatewayInterface and MerchantIdentityGatewayInterface) and PaymentGatewayBase to implement, PaymentSession, PaymentDeadline, ReconcileOutcome, PaymentOperationKey and the three gateway exceptions to answer with, PaymentHandoff for the bearer-link convention a return route mints and reads, and PaymentRecorderInterface to report through. Every record it is handed is one of the Entity types (PaymentEntityInterface, ClaimEntityInterface, RefundEntityInterface, ReleaseEntityInterface), because a gateway is handed a record in order to write the provider's reference onto it, which only something holding the stored row can do. A gateway settles as well as records, and it settles through the consumer's own doors, so a class that only records takes the recorder and a class that does both takes PaymentEngineInterface, which is the two halves under one name.

Not public, deliberately: PaymentEngine itself, because the interfaces are the contract and the implementation is free to move; the Payment, Refund, Release, Claim and MovementBase entity classes, since their interfaces are what to type against; the controllers, forms and hook classes, which are wiring rather than API; and PaymentSessionOpenerInterface and PaymentExpirySweepInterface, which carry the two operations kessai performs on its own schedule. Those two are interfaces rather than methods on the engine exactly so that they are not on it: the handoff route asks for the first and cron for the second, one service answers all of them, and a consumer's contract is left carrying only what a consumer calls. PaymentEngineInterface does carry them, being the union, but a caller that takes it is taking the two public halves and has no business with either of these.

PaymentEngineInterface is the union of all five, the only place any of them are put together. It adds one method of its own, forConsumer(), which returns the engine acting for one consumer on another host (see "Whose payment is whose" in Remote). It is what PaymentEngine implements, what a kernel test driving the engine end to end type-hints, and what a gateway's settlement handler asks for, being both halves of the gateway contract at once rather than the same service twice under two names. A gateway plugin that only records still takes PaymentRecorderInterface, and a consumer still takes PaymentClientInterface and sees neither the recording half nor the two the engine runs on its own schedule.

Extending the storage schema

PaymentStorageSchema adds the indexes the engine's own queries need. There are three: the consumer a payment was taken for, the subject and account a lookup narrows on, and the state and deadline cron sweeps by.

The consumer one is the index every read that crosses the wire uses. Nothing else on a payment says whose it is, so the API's finder always narrows by it. The payment id sits beside it, because the finder returns the newest first and that order is then read straight off the index. The other predicates narrow within what it found.

A consumer that puts its own base fields on the payment entity can index them by extending that class and overriding ::getIndexes(), returning parent::getIndexes() 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::getIndexes() 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.