Gateways¶
A payment gateway integrates one payment provider. It handles only the
provider-specific interaction; it never changes payment state directly. Instead
the outcome is reported back by calling the engine (recordPaymentReceived(),
authorize() or fail()), which records the state and announces the event.
Writing a gateway¶
Extend PaymentGatewayBase and tag the class with the #[PaymentGateway]
attribute. Place it under src/Plugin/PaymentGateway/ in your module.
namespace Drupal\my_module\Plugin\PaymentGateway;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\kessai_engine\Attribute\PaymentGateway;
use Drupal\kessai_engine\PaymentSession;
use Drupal\kessai_engine\PaymentGatewayBase;
use Drupal\kessai_engine\PaymentEntityInterface;
use Drupal\kessai_engine\RefundEntityInterface;
use Drupal\kessai_engine\AuthorizingGatewayInterface;
#[PaymentGateway(
id: 'my_provider',
label: new TranslatableMarkup('My provider'),
)]
final class MyProviderGateway extends PaymentGatewayBase implements AuthorizingGatewayInterface {
public function createPaymentSession(PaymentEntityInterface $payment): ?PaymentSession {
// Open a session at the provider and describe it. NULL only when this
// gateway starts no round-trip at all: one that should have opened a
// session and could not has to raise instead.
return new PaymentSession($id, $url, $expires, $return_secret);
}
public function refund(PaymentEntityInterface $payment, RefundEntityInterface $refund): void {
// Ask the provider to return $refund->getAmount(), which may be less than
// the payment's: a payment can be given back in parts. Raise
// GatewayRetryableException when it cannot refund yet or gave no answer,
// or GatewayDeclinedException when it refuses. Set the provider's own
// reference for this refund on the row if it issues one; the engine saves
// it.
}
}
NULL means "I never redirect", not "I could not"¶
createPaymentSession() returns NULL only when your gateway starts no
round-trip at all,
the way ManualGateway does. If yours normally redirects and could not this
time, raise: GatewayRetryableException for an outage, a timeout or missing
configuration, GatewayDeclinedException when the provider refused outright.
The difference is what the payer sees. NULL sends them onward in silence, which is right for an offline gateway and wrong for a provider that is down: they clicked pay, landed back where they started, and nothing told them the payment never began. Raising lets the handoff route say so.
createPaymentSession() and refund() are the only methods a gateway must
implement. The base class answers the other four required ones with what is
true of a gateway that talks to no provider.
Why the signatures say Entity¶
Every payment and movement a gateway is handed is typed
PaymentEntityInterface, ClaimEntityInterface, RefundEntityInterface or
ReleaseEntityInterface, and that is deliberate rather than decoration. A
gateway is handed a record so it can write to it: the provider's reference for
the operation it just performed, which only something holding the stored row
can do.
The types without Entity are the other half of the same pair, and they are
what a consumer holds: the readers alone, answerable without a database, so
that a kessai running on another host can answer them too. A gateway is never
on that side of the line. Type against the Entity ones, and a method a
consumer would reach for is on the plain one.
Accounts, and configuring one¶
A gateway plugin says how to talk to a provider. It does not say which account at that provider, because a site may hold more than one: a test account beside a production one, two legal entities, a second contract for a second currency.
That is a payment account, a config entity the site owner administers under Payments > Accounts. It names your plugin and carries that account's own settings. A payment records the account, never the plugin, because which account took the money cannot be recovered from anything else: a capture or a refund made years later has to reach the merchant that took it.
An account also lists the currencies it accepts, for example EUR, CHF. The
contract at the provider decides them, and the provider offers no way to ask,
so the site owner types them in. A payment in any other currency is refused
before the provider is called. A consumer that prices something asks first:
$currencies = $payment_client->getAccountCurrencies($account_uuid);
// ['EUR', 'CHF']: offer this account only for an amount in one of them.
There is no default currency anywhere in kessai. The currency of a payment is the currency of whatever is being paid for, and the consumer that priced it says which.
Your module cannot be uninstalled while accounts are held at it
Deleting them for the site owner is not an option: the payments they took
name them, and so does a checkout's or a workflow step's own stored
configuration, as a plain string no config dependency can see. So kessai
refuses the uninstall instead and names the accounts in the way, the same
shape core's filter and field modules use.
Retiring an account does not lift the refusal, because a disabled account is still one this site holds. The account has to be deleted, and kessai refuses that while payments name it, on the delete form and on any deletion that never reaches one, so on a site that took money the real order is: the payments go, then the account, then the module. That is the same order core asks of kessai itself, which it refuses to uninstall with There is content for the entity type: Payment for as long as any payment exists.
Nothing is asked of your module for this. A configuration import that removes the accounts and the module together is allowed, since that is the order the refusal asks for.
So your plugin is built once per account, with that account's settings as its
plugin configuration. Read them through $this->getConfiguration(), and declare
their defaults:
public function defaultConfiguration(): array {
return ['endpoint' => '', 'merchant_id' => ''];
}
The shape is yours, not kessai's. Declare it in your own config schema under
kessai.gateway.<your plugin id>, and kessai stores it without reading or
validating it:
kessai.gateway.my_provider:
type: mapping
label: 'My provider account'
mapping:
endpoint:
type: uri
label: 'Endpoint'
merchant_id:
type: string
label: 'Merchant id'
A gateway that needs no configuration declares neither, and there is nothing further to do: the bundled simulator gateway is like this. Talking to no provider is not the test: the bundled manual gateway talks to none and still declares one setting, its payment deadline.
To let a site owner edit those settings, implement PluginFormInterface. The
account form embeds your form, and your submitConfigurationForm() decides what
the account stores:
public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
$form['merchant_id'] = [
'#type' => 'textfield',
'#title' => new TranslatableMarkup('Merchant id'),
'#default_value' => $this->getConfiguration()['merchant_id'] ?? '',
'#required' => TRUE,
];
return $form;
}
A secret is a key, not a setting
Never put a credential in the settings above: they are exported
configuration. Store a key id instead
and resolve it at use, the way kessai_worldline does for its API and
webhook secrets. There is no site-wide settings.php override to fall back
on, and deliberately so: a site with two accounts has two secrets, and an
override has one slot.
The interface¶
Six methods every gateway answers for:
| Method | When it runs |
|---|---|
createPaymentSession() |
Create one at the provider, or NULL if this gateway starts no round-trip. |
refund() |
Return money from a captured payment, in whole or in part. |
getDefaultDeadline() |
How long this gateway's payments live before they lapse. |
getMinimumTimeToPay() |
The least time a payer needs before a payment's deadline. |
canExtendDeadline() |
Whether a site may give a payment a later deadline than the account's own. |
reconcile() |
Asked what became of a pending payment, before it is expired. |
Telling your provider which operation is the last¶
If your provider distinguishes a last operation from an intermediate one, here is where each answer comes from:
- capture: the claim says, through
ClaimInterface::isFinal(). Pass it straight through. Only the caller knows whether it means to claim the rest of the hold later, and guessing from the amount would strand the payer's money in the common case where a short claim is the last word. - release: derive it. Final when the release's amount exhausts
PaymentInterface::getHoldAmount(), which does not yet count this release. - refund: derive it. Final when the refund exhausts
PaymentInterface::getBalance().
Capabilities¶
Anything a provider may not be able to do lives on an interface the gateway opts into. The engine checks before it asks, so a gateway that cannot do something is refused rather than assumed to have succeeded.
AuthorizingGatewayInterface, for a provider that really holds funds:
| Method | |
|---|---|
authorize() |
Hold an amount without claiming it. |
capture() |
Claim all or part of a held amount, for the claim it is handed. |
release() |
Release a hold without claiming it. |
StoredPaymentMethodGatewayInterface, for charging a stored payment method
again with the payer gone:
| Method | |
|---|---|
chargeStoredPaymentMethod() |
Charge a stored payment method, no payer present. |
deleteStoredPaymentMethod() |
Delete the stored payment method at the provider. |
MoneyAfterEndGatewayInterface, for giving back money the provider took after
a payment ended:
| Method | |
|---|---|
giveBackMoneyAfterEnd() |
Release the hold or refund the capture the payment's reference names. |
See When the payment has already ended.
ManuallySettledGatewayInterface, for a gateway whose payments an operator
settles by hand. It has no methods: it lets the payments list offer
"Record payment received" on a pending payment of that gateway. See
Manual payments.
Every method here is one the engine calls
A capability interface lists exactly what the engine will ask a gateway for, and nothing else. A method an implementer has to write and the engine never invokes is a cost with no payer, so there are none. That is the bar anything added here has to clear: renewing a hold about to lapse, say, needs an engine method and something that decides when to call it before it needs a line in this table.
Claim a capability only if the provider really has it
Implementing one is a promise the engine acts on. A base class that answered for an unimplemented capability would have holds recorded against a gateway with no hold support at all, and the engine would believe it was holding money nobody was holding. A gateway that cannot do something simply does not claim it, and the engine will record the payment failed instead.
An offline gateway may still claim one deliberately: ManualGateway
implements AuthorizingGatewayInterface because an operator really can
hold an amount out of band. It does not implement
StoredPaymentMethodGatewayInterface, because there is nothing to charge
again.
One way to fail¶
Every operation returns nothing and raises on failure:
GatewayRetryableException when a later attempt could work, or
GatewayDeclinedException when the provider refused.
There are no boolean returns. A boolean cannot tell a decline from an outage, and that is exactly the difference between giving up and trying again.
A request whose answer never came back (a timeout, a dropped connection, a provider error page) is retryable, not a decline: the provider may have done it. The engine then keeps the claim, refund or release row pending, or the payment made with a stored payment method, and the next request for it asks again with that row, so it carries the same idempotency key. Key every request as Idempotency keys says, and the provider answers the first request instead of moving the money twice. A decline is final: the row is marked failed, and a later request is a new one under a new key, unless the caller asks again under the same request key, which is answered with that refusal.
Deadlines and reconciliation¶
A gateway says how long its own payments live, and is asked what became of one before cron writes it off:
public function getDefaultDeadline(): PaymentDeadline {
// createUnlimited(), createSiteDefault(), or fromSeconds($n) for a window of your own.
return PaymentDeadline::fromSeconds($this->sessionWindowSeconds());
}
public function reconcile(PaymentEntityInterface $payment): ReconcileOutcome {
// Fetch the authoritative outcome and report it through the engine, the
// same way your return route does. Never set the state yourself.
return $this->paymentHandler->resolveFromProvider($payment);
}
Return ReconcileOutcome::NothingHappened only when the provider actually said
so: it licenses the sweep to expire the payment. A request that failed is
Unavailable, which defers instead, because a failed request is not evidence
that no money moved. The base class returns createUnlimited() and NothingHappened,
which is the right default for a gateway that talks to no provider: nothing
lapses, and there is nothing that could contradict the local deadline. The
bundled manual gateway keeps NothingHappened and overrides createUnlimited() with the
deadline its account sets.
A redirect gateway should override both, and should keep the session it opens no longer than the payment deadline, or the provider could still take money against a payment cron has already recorded expired.
Two more methods describe what a payer needs and what a provider can wait for:
getMinimumTimeToPay(): the least time, in seconds, a payer needs before a payment's deadline with this gateway. The base class returns 0. Worldline returns 60, its shortest checkout session. Each account adds its own setting on top, and the larger of the two applies.canExtendDeadline(): whether a site may give a payment a later deadline than the account's own, through an account that opted in. The base class returnsFALSE, so a gateway that says nothing is never extended. ReturnTRUEonly when the provider can wait as long as a site asks: Worldline can, because it sizes each session per payment and opens a new one when a session lapses before the payment's deadline.
See Bounding a checkout.
Altering a gateway you do not own¶
The engine discovers a gateway from its attribute, so a site that has to change
a definition it does not own does it in hook_kessai_gateway_info_alter():
relabelling a bundled gateway to the name the merchant account is known by, or
taking one out of the list on a site where it must never be selectable. It
alters the definition and nothing else; which gateway handles a payment follows
from the account named at create(), and what a gateway can do is the
capability interfaces it implements. See kessai_engine.api.php.
The bundled manual gateway¶
Kessai ships a manual gateway for cash, cheque and bank-transfer flows: it
starts nothing and reports success, leaving an operator to settle or fail the
payment out of band. Its account has one setting, a payment deadline in seconds,
up to a year: 259200 gives a bank-transfer payer three days, and 0, the default,
means its payments never lapse. A site with both flows keeps one account for
each, and gives each its own minimum time to pay on the account form: an instant
transfer needs minutes, a standard transfer a day, a cheque by post several
days. A manual payment that expires can no longer be recorded as paid, so money
that arrives after the deadline goes on a new payment. It claims
AuthorizingGatewayInterface deliberately, because an operator really can hold
an amount by agreement. It does not implement
StoredPaymentMethodGatewayInterface: there is no stored payment method to
charge again, so the engine refuses a later stored payment method charge on it
rather than reporting one it could not have made. It implements
ManuallySettledGatewayInterface, so an operator records its payments received
from the payments list (Manual payments).
Handing the payer off¶
A gateway that redirects the payer returns a PaymentSession from
createPaymentSession(). Do not send the payer to its URL yourself. Link them
to the handoff route instead:
$url = $payment->getHandoffUrl($return_url);
The route verifies a per-payment token, asks the engine for the payment's open
session, announces PaymentEvents::INITIATED and only then redirects out. Going
through it means the round-trip is announced on the payer's deliberate click
rather than whenever a checkout page happened to be rendered, and it keeps that
logic in kessai instead of in every caller. A payment that is already resolved,
one past its deadline, or a gateway that opens no session, sends the payer
onward without announcing anything.
Onward is wherever that payment's payer belongs, which is not always this site.
The route asks AllowedOriginsInterface the same question your return route
asks it, so a payment taken on behalf of a site somewhere else sends its payer
home rather than to a front page they have never seen. A payment this site took
for itself registers no origin, so every external address stays refused.
Which address, when the link carries none, is the one the payment recorded -
the same one your return route reads. getHandoffUrl() with no argument mints a
link carrying only a token, and that is the ordinary shape of one: a consumer
says where its payer comes home on the payment request, and passes an address
here only when it learns late.
An address on that link that the payer may not be sent to is not recorded on the payment either, and the log says so. The link's token is signed over the payment and its deadline and not over the address, so recording one would let anyone holding the link replace an address that was checked when the payment was created. What the payment already holds is kept, and it is where the payer then goes: the route falls back to it rather than past it. The front page is still what a payer whose payment holds nothing lands on.
Your return route asks the same two things of OnwardUrl, and both of them:
$origins = $this->allowedOrigins->getOriginsForPayment($payment);
$url = OnwardUrl::resolve($payment->getReturnUrl(), $origins);
return OnwardUrl::buildRedirect($url ?? Url::fromRoute('<front>'));
resolve() says whether the payer may go there; buildRedirect() is how they are
taken. Answering an address on another site with a plain RedirectResponse is
not a redirect the payer ever follows: core replaces it with a 400 and logs
that a trusted one was wanted, so the payer meets an error page having already
paid.
Declare no_cache: TRUE on that route, as kessai declares it on its own two.
A redirect that vouches for an address carries cacheability with it, and the
internal page cache keeps a response by its cache tags rather than by its
max-age: without the route option, one payer's redirect is stored under the
route and answered to the next payer who opens the same link.
A repeat visit does not open a second session: the engine hands back the open
one, so createPaymentSession() is only ever called when there is genuinely
none. The
handoff itself is announced again, because the payer is leaving again; it is the
session that is reused, not the event. See
Events. A gateway
never has to deduplicate.
The route is public, because the payer usually has no account; the per-payment
token is what authorizes it, compared with hash_equals(). Every bearer link
the route and the gateways mint carries it under the same query argument,
PaymentHandoff::TOKEN_ARG, so a gateway never picks its own name for it.
The handoff token is a bearer value in a query string
It therefore reaches web server access logs and browser history, and anyone holding a copy can start the handoff. Two things bound it: the route refuses a handoff once the payment is past its deadline, and the deadline is signed into the token, so re-stamping a deadline invalidates every link minted against the old one. It stays in the query string deliberately, since that is what makes a handoff link usable from a mail or a checkout button. Do not log the full handoff URL, and do not put one somewhere it outlives the payment.
A gateway's return token is deliberately not bounded the same way
The handoff token can be bound to the deadline because a handoff link is
minted fresh every time one is needed. A gateway's return token cannot: it
is baked into the return URL handed to the provider when the session opens,
and the provider keeps that URL. Signing a mutable deadline into it would
mean that re-stamping the deadline strands the payer at a 403 on the way
back from paying. The same goes for a session URL held in session_url
and reused while the session lasts.
So a return token is scoped to the payment id alone, and what bounds it is what the route can do rather than how long it lives: a return resolves the outcome from the provider server-side and settles nothing from the query string, so a leaked one buys a reconciliation of a payment that has already happened. Do not "fix" the asymmetry without moving the token out of the provider-held URL first.
Settling from more than one channel¶
A gateway that can hear the same outcome twice (a browser return and a webhook, a webhook and the expiry sweep) has to settle it once. Reporting the outcome through the engine is already idempotent per transition, because each one is a locked compare-and-set. What is not covered is everything a gateway does around that: storing the provider reference, deleting an alias it did not ask for, and recording what an undone payment moved. Two channels arriving together each hold a payment loaded for that request alone, so both read it as still pending and both run all of it.
So serialize your own settlement per payment, the way the engine serializes a
capture: take a lock named for the payment, re-read the payment's state inside
it, and stand down when another channel has already settled it. Refresh the
state onto the object your caller passed rather than a copy, or a caller that
reads the outcome back off it (cron's reconciliation does) will report that
nothing happened. kessai_worldline's payment handler is the worked example.
Never save the payment yourself¶
Your lock keeps your own channels apart from each other. It does not keep them apart from everything else writing to that payment, and the payer clicking pay again is the one that matters: their handoff records where they go next, on the locked row, while your settlement is still in a provider call.
Saving an entity writes all of its fields, so saving the payment your request loaded also writes that request's idea of every field someone else has changed since. Write what the provider told you through the engine instead, and each field goes against the locked row on its own:
$recorder->recordProviderReference($payment, $response->getId());
$recorder->recordPayerAuthentication($payment);
$recorder->recordStoredPaymentMethod($payment, $payment_method_id);
Each of them also reflects the value onto the payment you passed, so reading it
back off your own object still works. There is no door for the state or for an
amount: those are the engine's, through recordPaymentReceived(),
authorize(), fail() and cancelPending().
Those three, plus recordTakenAndGivenBack(), failGivingBack() and
reportMoneyOnEndedPayment(), are PaymentRecorderInterface, and that
interface carries them and nothing else. It does not extend
PaymentClientInterface: the transitions you report outcomes through are the
consumer's own doors, and a plugin that only records has no business reaching
them. So ask for what your class actually is:
// A plugin that only reports what the provider said.
private readonly PaymentRecorderInterface $recorder,
// A settlement handler, which reports an outcome and records beside it.
private readonly PaymentEngineInterface $engine,
Both resolve to kessai.payment_engine; what the type hint says is which part
of the contract your class is holding. Take the recorder alone where that is all
you do: a plugin that cannot reach a transition cannot start one from inside a
provider call, which is the mistake the settle lock exists to survive.
WorldlineGateway takes the recorder; WorldlinePaymentHandler, which settles,
takes the engine.
Idempotency keys¶
The engine makes every call that can move money under a lock: per payment for
createPaymentSession(), capture(), release(), refund() and
giveBackMoneyAfterEnd(), and per subject for a stored-card authorize() or
chargeStoredPaymentMethod(). So two callers inside this site cannot both reach
your provider. Each lock lasts 120 seconds
(PaymentEngine::OPERATION_LOCK_SECONDS), so give every provider call a
timeout, and keep the calls one operation makes well under it. A call that
outlasts the lock lets a second caller reach your provider while the first is
still waiting for an answer. A lock cannot see a retry that starts outside the
engine: a proxy replaying a POST, a job runner re-running a failed task, a
second application server acting on the same payment through its own code. Only
the provider can collapse those.
If your provider supports idempotency keys, send one:
use Drupal\kessai_engine\PaymentOperationKey;
// An operation a payment has at most one of, such as undoing a rejected payment.
$key = PaymentOperationKey::getOperationKey($payment, 'undo_payment');
// A claim, a refund and a release are keyed on their own row, not on the
// payment.
$key = PaymentOperationKey::getCaptureKey($claim);
$key = PaymentOperationKey::getRefundKey($refund);
$key = PaymentOperationKey::getReleaseKey($release);
The key is stable across retries of the same operation and different for every other one, and it is derived rather than stored, so it survives the failure it exists for: a retry happens precisely when the first attempt left no usable record behind.
Refunds and releases are the operations a payment can have several of, so keying
them on the payment would make the provider collapse the second one into the
first and silently return nothing. getRefundKey() and getReleaseKey() key
on the row instead, which is written before your gateway is called precisely so
that its id exists to be derived from. The two derive in separate namespaces,
because the tables number independently and release 1 and refund 1 exist at
once.
It is capped at 40 characters, ASCII, which is the shortest bound known
across supported providers. Check your provider's own limit: an over-long key is
not a longer key, it is a request whose idempotency may silently not apply at
all, which is worse than sending none. If yours accepts less than 40, that cap
is PaymentOperationKey::MAX_LENGTH and it should come down.
Whatever your provider raises when it sees a key already in flight is retryable, never declined. The duplicate was refused rather than performed and the original is still settling, so failing the payment there would write it off on the strength of a request that was correctly ignored.
Reporting an outcome¶
When the provider tells you the result (a return url, a webhook, an operator action), load the payment and call the engine, not the entity:
$engine = \Drupal::service(\Drupal\kessai_engine\PaymentEngineInterface::class);
$engine->recordPaymentReceived($payment); // or ->authorize(), ->fail()
Because these are idempotent, it is safe to call them from both a browser
return and a webhook for the same payment. capture() is not one of them: it
claims money from a hold at the provider, and refuses a payment still pending.
When the payment has already ended¶
The provider can answer after the payment expired, was released, was canceled or failed: a
payer still on the provider's page past the deadline pays. Do not call
recordPaymentReceived() for it: on a payment that ended it records nothing.
Report it instead:
if (in_array($payment->getState(), PaymentInterface::ENDED_WITHOUT_MONEY_STATES, TRUE)) {
$engine->recordProviderReference($payment, $response->getId());
$engine->reportMoneyOnEndedPayment($payment, collected: TRUE);
}
The payment keeps its state and gains no movement. The engine logs a warning
naming the provider reference, so record it first, and announces
CAPTURED_AFTER_END (or AUTHORIZED_AFTER_END for a hold) for a consumer to
act on: see Events.
authorize() reports a hold on an expired, canceled or failed payment the same
way on its own, since it only ever records what a provider did. On a released
payment it reports nothing: that payment was authorized, so the hold is the one
already released, reported again. A gateway that sees a different transaction
held on a released payment calls reportMoneyOnEndedPayment() itself.
To let an operator give that money back, implement
MoneyAfterEndGatewayInterface. Its one method asks the provider to release
the hold or refund the capture on the transaction the payment's reference names,
and answers with what went back:
public function giveBackMoneyAfterEnd(PaymentEntityInterface $payment): MoneyGivenBack {
$taken = $this->provider->getPayment($payment->getReference());
$this->provider->undo($taken, idempotencyKey: PaymentOperationKey::getOperationKey($payment, 'money-after-end'));
// The provider's figure, in the payment's currency.
$amount = new Money($taken->amount, $payment->getAmount()->currency);
return new MoneyGivenBack($amount, collected: $taken->isCaptured());
}
Record nothing yourself: the engine checks the payment first and writes the
rows from your answer. Throw GatewayRetryableException or
GatewayDeclinedException when the provider refuses, so nothing is recorded
and the operator can ask again.