Skip to content

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 manager (capture() / 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\Attribute\PaymentGateway;
use Drupal\kessai\CheckoutSession;
use Drupal\kessai\PaymentGatewayBase;
use Drupal\kessai\PaymentInterface;
use Drupal\kessai\AuthorizingGatewayInterface;

#[PaymentGateway(
  id: 'my_provider',
  label: new TranslatableMarkup('My provider'),
)]
final class MyProviderGateway extends PaymentGatewayBase implements AuthorizingGatewayInterface {

  public function initiate(PaymentInterface $payment): ?CheckoutSession {
    // Open a session at the provider and describe it. NULL when there is no
    // round-trip to start (an offline gateway, or the provider refused).
    return new CheckoutSession($id, $url, $expires, $return_secret);
  }

  public function refund(PaymentInterface $payment): void {
    // Ask the provider to refund. Raise GatewayRetryableException when it
    // cannot refund yet, or GatewayDeclinedException when it refuses.
  }

}

NULL means "I never redirect", not "I could not"

initiate() 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.

initiate() and refund() are the only methods a gateway must implement. The base class answers the other two required ones with what is true of a gateway that talks to no provider.

The interface

Four methods every gateway answers for:

Method When it runs
initiate() Open a provider checkout session, or NULL if there is none.
refund() Refund a captured payment.
defaultDeadline() How long this gateway's payments live before they lapse.
reconcile() Asked what became of a pending payment, before it is expired.

Capabilities

Anything a provider may not be able to do lives on an interface the gateway opts into. The manager 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.
cancel() Release a hold without claiming it.
reauthorize() Replace a hold about to lapse with a fresh one.

TokenGatewayInterface, for charging a card again with the cardholder gone:

Method
chargeToken() Charge a stored card, no cardholder present.
verify() Zero-amount check that a stored card is still live.
deleteToken() Delete the stored card at the provider.

Claim a capability only if the provider really has it

Implementing one is a promise the engine acts on. It used to be the other way round: the base class answered TRUE from authorize() for every gateway that had not implemented it, so a gateway with no hold support at all had holds recorded against it and the engine believed it was holding money nobody was holding. A gateway that cannot do something should simply not claim it, and the manager 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 TokenGatewayInterface, because there is no card 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.

Deadlines and reconciliation

A gateway says how long its own payments live, and is asked what became of one before the reaper writes it off:

public function defaultDeadline(): PaymentDeadline {
  // never(), siteDefault(), or seconds($n) for a window of your own.
  return PaymentDeadline::seconds($this->checkoutWindowSeconds());
}

public function reconcile(PaymentInterface $payment): ReconcileOutcome {
  // Fetch the authoritative outcome and report it through the manager, the
  // same way your return route does. Never set the state yourself.
  return $this->finalizer->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 never() and NothingHappened, which is right for a gateway that talks to no provider: nothing lapses, and there is nothing that could contradict the local deadline.

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 the reaper has already recorded expired.

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 payments never lapse, inherited from the base class, and it claims AuthorizingGatewayInterface deliberately, because an operator really can hold an amount by agreement. It claims no token support: there is no card to charge again, so the manager refuses a later card-on-file charge on it rather than reporting one it could not have made.

Handing the payer off

A gateway that redirects the payer returns a CheckoutSession from initiate(). Do not send the payer to its URL yourself. Link them to the handoff route instead:

use Drupal\kessai\Controller\PaymentHandoffController;

$url = PaymentHandoffController::handoffUrl($payment, $return_url);

The route verifies a per-payment token, asks the manager 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.

A repeat visit does not open a second session: the manager hands back the open one, so initiate() is only ever called when there is genuinely none. 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().

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.

Idempotency keys

The manager serializes capture(), cancel() and refund() on a per-payment lock, so two callers inside this site cannot both reach your provider. 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\PaymentOperationKey;

$key = PaymentOperationKey::for($payment, 'refund');

The key is stable across retries of the same operation on the same payment 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.

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 manager, not the entity:

$manager = \Drupal::service('kessai.payment_manager');
$manager->capture($payment); // or ->fail($payment)

Because capture() and fail() are idempotent, it is safe to call them from both a browser return and a webhook for the same payment.