Issue #3621925: Let a consumer drive payments holding only the contract

A consumer could create a payment and settle it, and could not find one. So every consumer went around the contract into kessai's own table: yoyaku wrote the same query by hand in nine places, orchestra added two base fields to kessai_payment and swapped its storage schema to index one of them. All of it worked, and all of it only ever works while the consumer and the engine share a database.

This gives the contract everything a consumer needs, so that reaching past it is never the answer.

What a caller hands over is values

create() takes a PaymentRequest, naming its amount, currency, account, kind, subject, deadline, whether to keep a reusable token, and whatever the consumer wants remembered. The last of those used to be eight positional arguments ending in two nullable ints and a bool, where transposing any two compiles and means something else.

The subject is a PaymentSubject: two strings, not an entity. Neither means anything to kessai, which only ever compares them; the type has to be an entity type id only for getSubject(), a convenience for the case where the subject really is an entity on this site. A request naming a loaded entity could only be made by a caller sharing this database, which is the one thing the contract exists to avoid.

One finder

findPayments() takes a PaymentQuery describing the payments wanted: the subject, or a subject type alone where the question is about every order of a kind rather than one; the kind and the states; a token, exactly or at all; what the consumer remembered, one value or any of several, with two names meaning both.

Every field narrows and none widens, so a query naming nothing would be every payment on the site and is refused rather than answered - that is what a dropped criterion looks like. It answers newest first, and says so: callers ask 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 released.

It replaces getTokenPaymentsQuery(), which answered with a storage query builder - this site's own machinery rather than a value, and the last thing on the contract a kessai somewhere else could not have handed back.

Answered without an access check, because kessai holds no current user and a payment's audience is a question about the consumer's own domain.

Payment metadata

A consumer has things to say about a payment that kessai has no opinion on: which workflow step asked for it, which order line it pays. It says them as its own names and string values, which kessai stores, indexes and never reads, the way it never reads the kind.

Written once, as the payment is created, and never after: what a consumer wants to recognize its payment by is what it knows when it asks for the payment, so nothing overwrites or forgets a name and two readers of one payment cannot disagree about what it carries. The payment and its metadata are one write or neither.

Both directions are indexed - reading them off a payment in hand, and asking which payments carry a value, which is the direction an outcome arriving from a provider needs because it arrives carrying a payment and nothing else.

A consumer depends on the contract, never on an implementation

The contract module shipped no services, so depending on it alone left every injected client unbound and refused a consumer's container while it was being compiled. Consumers therefore named the engine, and installing any of them put an engine on the site - including the site whose payments are settled elsewhere, which is the one site that must not have one.

kessai declares its own service name now, built by PaymentClientResolver from whichever implementation announced itself with the kessai_payment_client tag. 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 an overwritten name has a winner decided by which module sorts later; the engine sits below the default priority so a client a site installed on purpose outranks it.

A site holding the contract and nothing behind it is told so by its status report, as an error, naming what to install.

Also on the contract

  • PaymentInterface::isAwaitingProvider(), so asking whether the payer is away at a provider no longer means holding the session and the secret a return is bound to.
  • getPayments(), a consumer's only way back from ids it stored.
  • deletePayments(), which takes each payment's claims, refunds and reversals with it, because a movement naming a payment that is gone is a record of money nobody can read. It refuses a list holding a payment this site did not make, and refuses it before anything is deleted.

What is not here

The HTTP API and a RemotePaymentClient. Everything above is what they need in order to exist; neither is built here.

Checked

phpstan level 5, phpcs, cspell against the CI configuration, and the kernel suites. Functional runs in CI, and the manual next-major lanes are played by hand.

AI-Generated: Yes (Claude Code was used to help produce this change.)

Edited by Frank Mably

Merge request reports

Loading
Loading