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'), + ); + } +}