Kessai Payment¶
A lightweight, gateway-agnostic payment state machine for Drupal.
Kessai records a payment and drives it through a fixed lifecycle. It never talks to a payment provider itself: providers plug in as gateway plugins, and the outcome of a payment is reported back to the engine, which records the state change and announces it as an event for consumers to react to.
Kessai is deliberately unopinionated about what is being paid for. A payment may name a subject (an order, a booking, a membership, a donation) as a type and an id in the consumer's own words, which need not be an entity on this site, or name none at all.
The lifecycle¶
A payment is created pending. From there:
- A direct sale is captured (the money is taken), fails, or is later refunded, in whole or in part.
- A payment the provider only authorized is recorded authorized, then either captured or released (the hold is voided before capture). Either can be partial: a capture claims part of the hold, and a release releases part of it while the rest stays claimable.
- A payment nobody completed ends canceled (the payer deliberately backed out), failed (the gateway declined it or errored) or expired (its deadline passed while it was still pending). All three are kept apart so a consumer can tell a routine abandonment from a real problem.
pending ──capture──▶ captured ──refund the balance──▶ refunded
│ │ ▲ │
│ │ └────┘ refund part of it: still captured, less balance
│ │
│ └──authorize──▶ authorized ──capture──▶ captured
│ │ ▲ │
│ │ └────┘ release part of it: still authorized, less hold
│ └──release the rest──▶ released
│
├──fail──▶ failed
├──cancel──▶ canceled
└──expire──▶ expired
Every transition is a locked compare-and-set, so a duplicate provider callback is a no-op and a late one can never move a payment that already settled. See Architecture.
Every movement of money is a row¶
The payment carries the running totals; what actually happened is a record per movement, so a total always has rows behind it and two endings that move the same money stay legible apart.
| Record | Moves | Made by |
|---|---|---|
kessai_claim |
Takes money: off a hold, or recording what a provider already took | capture(), recordPaymentReceived(), chargeStoredPaymentMethod(), recordTakenAndGivenBack() |
kessai_refund |
Gives back money that was collected | refund(), recordTakenAndGivenBack() |
kessai_release |
Releases a hold nobody ever collected | release(), recordTakenAndGivenBack() |
Money the provider took and the gateway handed straight back makes rows on
both sides at once, which is why recordTakenAndGivenBack() appears three times: a
collected one leaves a claim and a refund, a merely held one leaves a release.
Each carries its amount, its own state, the provider's reference for it where there is one, and the caller's word for what it was. A refund and a release are deliberately not the same thing: one moves settled money, the other only frees the payer's available balance, and providers give them two endpoints and two answers. See Architecture.
Quick start¶
Take a payment through a gateway and react to the outcome:
$payment_client = \Drupal::service(\Drupal\kessai\PaymentClientInterface::class);
// Which account to take it through, by uuid: that is what a payment records,
// and what means the same thing whether this site settles its own payments or
// a Kessai on another host does. getAvailableAccounts() answers uuid to label,
// so a form offers the label and stores the uuid.
$account_uuid = array_key_first($payment_client->getAvailableAccounts());
// Create a pending payment for a subject entity. Everything the request needs
// is named, so nothing depends on argument order, and anything your own side
// wants to recognize the payment by later travels with it as metadata.
$payment = $payment_client->create(new \Drupal\kessai\PaymentRequest(
amount: new \Drupal\kessai\Money('42.00', 'EUR'),
account_uuid: $account_uuid,
kind: 'booking',
subject: \Drupal\kessai\PaymentSubject::createFromEntity($subject),
metadata: ['my_module_order' => '17'],
// Where the payer comes back to, on either kind of site.
return_url: $return_url,
));
// For a redirect gateway, send the payer to the handoff URL rather than
// opening the payment session yourself: the deliberate click announces the
// start of the round-trip.
$url = $payment->getHandoffUrl();
// A provider's outcome is recorded by its gateway's own return and webhook
// on the site holding the payments. A payment at an account that is manually
// settled is recorded by whoever received the money, here or from a consumer
// site, and only there:
if ($payment_client->isAccountManuallySettled($payment->getAccountUuid())) {
$payment_client->recordPaymentReceived($payment);
}
An offline payment needs no handoff: create it against an account of the
manual gateway, and
an operator records it received later from the payments list (see
Manual payments), or code records the outcome with
recordPaymentReceived() or fail().
React to \Drupal\kessai\Event\PaymentEvents::CAPTURED to do whatever a taken
payment means for your domain. See Events.
The gateway decides whether a payment expires
create() asks the gateway how long its payments live, so the bundled
manual gateway takes the deadline its account sets (none by default),
while a redirect gateway gets the window matching its session. Cron records
anything still pending past its deadline as expired, after asking the
provider whether it was in fact paid. A caller can end a payment earlier
with maximumDeadline, and later only when the account lets a site extend
its deadline. See Bounding a checkout,
Architecture and
Events.
Bounding a checkout¶
A checkout where the payer can start a payment, give up, and start another one through another account must not restart its time with each payment. Give every payment of the checkout the same maximum deadline:
$maximum_deadline = $checkout_started + 3 * 86400;
$payment_client->create(new PaymentRequest(
amount: new Money('120.00', 'EUR'),
account_uuid: $card_uuid,
kind: 'booking',
maximum_deadline: $maximum_deadline,
));
With a maximum deadline at 10:00 on day 4, a card account (20 minutes, minimum 1 minute), a transfer account (3 days, minimum 1 day) and a cheque account (10 days, minimum 5 days):
| Payment | Deadline |
|---|---|
| Card at 10:00 on day 1 | 10:20 |
| Transfer at 10:05 on day 1 | 10:00 on day 4 |
| Cheque on day 1 | refused: less than 5 days left |
| Card on day 4 at 9:50 | 10:00 |
| Transfer on day 3 at 11:00 | refused: less than 1 day left |
| Any payment after 10:00 on day 4 | refused |
- The account decides how long its payments last. A maximum deadline only shortens that. An account whose payments never lapse ends at the maximum deadline.
- An account can let a site extend its deadline, when its gateway allows it. Its payments then end at the maximum deadline, even after the account's own deadline: a transfer's 3 days can become 5 for one booking.
- Each account has a minimum time to pay: a card checkout needs a minute,
a standard transfer a day, a cheque by post several days.
create()refuses a payment that would leave less, andgetAvailableAccounts($maximum_deadline)leaves that account out, so a checkout late in its window offers the card and not the cheque. - A maximum deadline that has passed is refused.
- To extend a checkout, give its next payment a later maximum deadline.