Issue #3621924: Let a site hold two accounts at one provider: configured gateway instances, not bare plugin ids
A payment recorded a gateway plugin id, which the engine resolved against whichever single settings object the gateway module happened to hold. A site could keep only one account per provider, and switching accounts silently repointed every payment already taken: a capture, a refund, or the expiry sweep asking about one of them reached an account that never saw the transaction.
What a payment names now
A kessai_gateway_instance config entity: the account at a provider, not the provider. loadGateway() loads it, builds the plugin with its configuration, and memoizes per instance.
The gateway column keeps its name, its type and its 64 characters. Only what it points at is richer, and instances are created under the plugin's own id, so no existing value stops resolving.
The plugin type keeps the name kessai_gateway, which it already owns through hook_kessai_gateway_info_alter() — hence kessai_gateway_instance rather than a collision.
The configuration belongs to the gateway
Schema types settings as kessai.gateway.[%parent.plugin], so a gateway declares its own shape under kessai.gateway.<plugin id> and kessai neither reads nor validates it. PaymentGatewayBase becomes ConfigurableInterface; a gateway wanting a form implements PluginFormInterface and GatewayInstanceForm embeds it.
All three shipped gateways move over. manual and simulator configure nothing and contribute no form at all, which is the shape working as intended.
kessai_worldline
- The client factory binds to an account (
forSettings()) rather than to the module, dropping the memoized SDK client on the way: a client built for one account answering for another would charge the wrong merchant, and the provider would answer about transactions it does know. - Secrets move onto
keyentities the account names. Bothsettings.phpoverrides are gone: a site may hold several accounts and an override has one slot. This adds akeydependency. - The webhook endpoint is one URL for a site that may hold several accounts, each signing with its own key.
WorldlineWebhookVerifierbuilds one SDK key store over every enabled account and lets the key id in the notification pick. Verifying against a single account's secret would have rejected every notification from the others. - The status report is made per account, since one account being set up correctly says nothing about the next.
Also fixed
The webhook's "is this one of ours?" compared the payment's column against WorldlineGateway::PLUGIN_ID. Two Worldline accounts are two instances and neither is spelled worldline, so it now asks the instance for its plugin.
API changes
None to any signature. create(), authorizeToken() and chargeToken() still take string $gateway in the same position; only the meaning changed, from a plugin id to an instance id, and getGateway() answers one. The reuse question needed no change and gained the right behaviour: it already keyed on $gateway, so two accounts at one provider are now two answers, which is more correct — they really are two payments.
No update path
Deliberate, per the maintainer: pre-1.0 alpha. A site already running this sets its accounts up by hand.
Rebased onto 1.x after #3621927 (closed) merged
The rebase applied without a conflict and was not therefore correct. #3621927 (closed) added a test asserting the message the engine refuses an unknown gateway with, and this branch moves that refusal: assertGateway() now turns an unknown or disabled account away at the door of create() and both token paths, before anything is saved, so the wording changed. The test asserted the old one and two comments still explained the refusal as coming from the deadline lookup in createPayment(). All three now describe what happens, and that test names the two token doors as what it adds to testAnUnknownAccountIsRefused() rather than repeating it.
What auditing the rebased branch found
kessai_worldlinehard-depends onkey— in itsinfo.ymland in the client factory's constructor — and neithercomposer.jsondeclared it. CI is green only becauseaudit_trailhappens to requirekeyin the project'srequire-dev, so the day that changes phpstan stops resolvingKeyRepositoryInterfaceand phpunit reports an unavailable module. Declared in the project'srequire-dev, where CI reads it, and in the submodule's ownrequire, where a site installing the gateway reads it.- Deleting an account was unguarded. The listing offers Delete beside Disable and nothing was behind it, while this branch's own test said in prose that "deleting one is not an option while payments name it". An account id is the only thing a payment records about where its money went, so deleting one strands every payment it took: each then meets a
GatewayDeclinedExceptionon capture, refund, cancel and reconciliation, and the expiry sweep writes them off on their local deadline with nobody able to ask the provider. The delete form now counts first and, when the account took anything, replaces the question with what it would cost and offers no button — the shape core's own node type delete form uses. - Four config schema labels carried no French (
Label,Gateway plugin id,Gateway configuration,Worldline account), and the payment gateway field's old description was left in the catalogue after the field was reworded.
What test-only changes can and cannot say here
It is red, and on this branch that proves nothing about any single test, which is worth saying rather than leaving to be inferred. Reverting the non-test files removes the gateway instance entity itself, so every class that touches an account errors or fails for want of the entity: GatewayInstanceFormTest reports 7 failed, and the two tests added for the delete guard are among them for the same reason as the five that were already there. A feature MR cannot use this lane as proof.
What does stand behind the delete guard: phpunit green on both core versions with the guard in place, a test in each direction (an account with a payment offers no button, an account with none is deleted and gone), and core's NodeTypeDeleteConfirm as the precedent for returning the form before parent::buildForm() so no submit is added.
The consumer side of the instance model
The audit above looked at this branch for a gateway author and missed the audience that actually has to use the feature. GatewayInstanceInterface is @api and answers getPluginId() and getSettings(), both gateway-author questions; GatewayInstanceStorage::loadEnabledForPlugin() is a gateway module asking which accounts are its own. A consumer's two questions had no answer: which accounts can I offer a payer, and is the id I stored earlier still usable. Each consumer would have reached into storage by entity type id and hand-rolled the enabled filter separately.
Both now sit on PaymentClientInterface, where a consumer already looks:
getAvailableGateways()lists the enabled accounts, keyed by whatcreate()takes and valued with the name the site owner gave each.isGatewayAvailable()answers for one id, off the same storage the creation door reads, so a consumer that checks first and the engine that refuses cannot disagree.assertGateway()keeps its own two refusals rather than delegating, because it has to say which of them applies.
GatewayInstanceStorage::loadEnabled() sits beside the per-plugin reader, and the architecture document's "What is public" section now places both for the consumer audience.
What this means for orchestra and yoyaku
Neither is broken by this branch today, because the shipped instances take the plugin ids (manual, simulator, worldline), so the strings both consumers pass keep resolving. That holds exactly until a site uses the feature and names an account something else. Then:
- yoyaku is the sharp one.
PaymentGatewayResolver::isInstalled()validates a stored id withPaymentGatewayManager::hasDefinition(). A real account fails that check, so the tenant override and the site default are both discarded and the resolver returns itsmanualfallback: a booking that should take a card is created against the offline gateway, with nothing raising.BookingPaymentHooks::addDefaultGatewayField()builds its select from the plugin list, and its#default_valuefalls back to empty when the stored id is not in it, so a configured account reads as "None (manual)" on the form. - orchestra builds
PaymentInteraction's gateway select from the plugin list too, so a workflow step can force a plugin id but never one of the site's accounts — and the step form is exactly where a site would choose between two accounts at one provider.
Both are a swap to the two methods above, and belong in their own queues rather than here.
Verification after the rebase
phpcs Drupal,DrupalPractice over the module root (147 files, 0 errors 0 warnings), phpstan level 5 [OK] No errors after a cache clear, cspell at CI's own expanded config (0 issues), potx per project in both directions across all five catalogues (0 missing, 0 orphaned), and docs/metrics.md regenerated against the merged tree.
Verification
phpcs on CI's ruleset (131/131 files, exit 0), phpcs Drupal,DrupalPractice, phpstan level 5 ([OK] No errors), cspell over the changed files. Docs and both fr.po files updated, including removing the eight strings this orphaned.
AI-Generated: Yes (Claude Code was used to help implement this change.)