From 6ac188c727a0bff36959e0f05e0621da856cf7dd Mon Sep 17 00:00:00 2001 From: "Rai (AI agent)" Date: Thu, 24 Sep 2026 00:46:26 +0000 Subject: [PATCH] feat(payment): ship the documented PaymentInstrumentResolver seam docs/completion-payment.md says PaymentHandlerInterface::prepareInstrument() is the intended path for turning the instrument an agent presented into the concrete payment method a provider can charge, but nothing ever called it. This adds a general-purpose PaymentInstrumentResolver service that resolves the instrument through the handler that published it and returns the (paymentMethodId, token) pair it declares, wired by DI so any payment integration can alias or decorate it. Rail-agnostic: it carries no opinion on which merchant payment method is used, does not switch the default order, and leaves refusal to the caller's applier. Adds a focused unit test pinning the documented seam. CI owns validation. --- src/Resources/config/services.php | 6 ++ .../Payment/PaymentInstrumentResolver.php | 57 +++++++++++++ tests/Unit/PaymentInstrumentResolverTest.php | 83 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/Ucp/Checkout/Payment/PaymentInstrumentResolver.php create mode 100644 tests/Unit/PaymentInstrumentResolverTest.php diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 1a8e5920..1582dbca 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -89,6 +89,7 @@ use Swag\AgenticCommerce\Ucp\Checkout\CheckoutWebhookUrlGuard; use Swag\AgenticCommerce\Ucp\Checkout\DoctrineDbalCheckoutCompletionStore; use Swag\AgenticCommerce\Ucp\Checkout\Payment\AbstractCompletionPaymentApplier; +use Swag\AgenticCommerce\Ucp\Checkout\Payment\PaymentInstrumentResolver; use Swag\AgenticCommerce\Ucp\Checkout\Payment\UnappliedCompletionPayment; use Swag\AgenticCommerce\Ucp\Command\SeedSmokeCatalogCommand; use Swag\AgenticCommerce\Ucp\Config\DoctrineDbalUcpConfigRepository; @@ -330,6 +331,11 @@ $services->set(ShopwareInvoicePaymentHandler::class) ->tag('ucp_sdk.payment_handler'); + // The documented seam to turn the instrument the agent presented into the + // concrete payment method a provider can charge. Registered as a service so + // a provider plugin can alias or decorate it; see docs/completion-payment.md. + $services->set(PaymentInstrumentResolver::class); + // Scheduled tasks. $services->set(CleanupExpiredOAuthTokensTask::class) ->tag('shopware.scheduled.task'); diff --git a/src/Ucp/Checkout/Payment/PaymentInstrumentResolver.php b/src/Ucp/Checkout/Payment/PaymentInstrumentResolver.php new file mode 100644 index 00000000..994ac48e --- /dev/null +++ b/src/Ucp/Checkout/Payment/PaymentInstrumentResolver.php @@ -0,0 +1,57 @@ +paymentHandlerRegistry->find($instrument->handlerId); + if (null === $handler) { + throw new ValidationException(\sprintf( + 'No UCP payment handler is registered for id "%s". The business must publish a payment handler for it before an agent can settle a completion with it.', + $instrument->handlerId, + )); + } + + return $handler->prepareInstrument($instrument, $context); + } +} diff --git a/tests/Unit/PaymentInstrumentResolverTest.php b/tests/Unit/PaymentInstrumentResolverTest.php new file mode 100644 index 00000000..d79f5046 --- /dev/null +++ b/tests/Unit/PaymentInstrumentResolverTest.php @@ -0,0 +1,83 @@ +createMock(PaymentHandlerInterface::class); + $handler->method('prepareInstrument')->willReturn([ + 'paymentMethodId' => 'pm-nano', + 'token' => '', + ]); + + $registry = $this->createMock(PaymentHandlerRegistryInterface::class); + $registry->method('find')->with('com.nano.xno')->willReturn($handler); + + $resolved = (new PaymentInstrumentResolver($registry))->prepare( + new PaymentInstrument('crypto', 'com.nano.xno', ['network' => 'nano:mainnet']), + new RequestContext('shop.example'), + ); + + self::assertSame('pm-nano', $resolved['paymentMethodId']); + } + + #[Test] + public function itPassesTheInstrumentAndContextThroughToPrepareInstrument(): void + { + $instrument = new PaymentInstrument('crypto', 'com.nano.xno', ['network' => 'nano:mainnet']); + $context = new RequestContext('shop.example'); + + $handler = $this->createMock(PaymentHandlerInterface::class); + $handler->expects(self::once()) + ->method('prepareInstrument') + ->with($instrument, $context) + ->willReturn(['paymentMethodId' => 'pm-nano', 'token' => '']); + $handler->method('supportsTokenization')->willReturn(false); + + $registry = $this->createMock(PaymentHandlerRegistryInterface::class); + $registry->method('find')->with('com.nano.xno')->willReturn($handler); + + (new PaymentInstrumentResolver($registry))->prepare($instrument, $context); + } + + #[Test] + public function itRefusesWhenTheHandlerIsNotPublished(): void + { + $registry = $this->createMock(PaymentHandlerRegistryInterface::class); + $registry->method('find')->willReturn(null); + + $this->expectException(ValidationException::class); + + (new PaymentInstrumentResolver($registry))->prepare( + new PaymentInstrument('crypto', 'com.never.published'), + new RequestContext('shop.example'), + ); + } +}