Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
57 changes: 57 additions & 0 deletions src/Ucp/Checkout/Payment/PaymentInstrumentResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Swag\AgenticCommerce\Ucp\Checkout\Payment;

use Shopware\Core\Framework\Log\Package;
use Ucp\Sdk\Exception\ValidationException;
use Ucp\Sdk\Model\Checkout\PaymentInstrument;
use Ucp\Sdk\Model\RequestContext;
use Ucp\Sdk\Service\PaymentHandlerRegistryInterface;

/**
* Resolves a UCP payment instrument to the concrete Shopware payment method a
* provider can charge, without deciding who charges or how.
*
* This is the "intended path" docs/completion-payment.md names but which every
* earlier release left unimplemented: the SDK's
* PaymentHandlerInterface::prepareInstrument() existed and
* ShopwareInvoicePaymentHandler implemented it, yet nothing ever called it. A
* payment integration had to reimplement handler lookup + prepareInstrument
* for itself -- or skip it and fall back to the sales channel default, which is
* how an agent's instrument used to disappear without trace.
*
* It is deliberately rail- and provider-agnostic: it only turns the instrument
* into the pair (paymentMethodId, token) the handler declares. Deciding whether
* to switch the order to that method, refusing, or settling on-chain stays with
* the caller's applier, exactly as the seam intends.
*
* @internal
*/
#[Package('checkout')]
final class PaymentInstrumentResolver
{
public function __construct(
private readonly PaymentHandlerRegistryInterface $paymentHandlerRegistry,
) {
}

/**
* @return array{paymentMethodId: string, token: string, displayLast4?: string, displayBrand?: string}
*/
public function prepare(
PaymentInstrument $instrument,
RequestContext $context,
): array {
$handler = $this->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);
}
}
83 changes: 83 additions & 0 deletions tests/Unit/PaymentInstrumentResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Swag\AgenticCommerce\Tests\Unit;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Swag\AgenticCommerce\Ucp\Checkout\Payment\PaymentInstrumentResolver;
use Ucp\Sdk\Contract\PaymentHandlerInterface;
use Ucp\Sdk\Exception\ValidationException;
use Ucp\Sdk\Model\Checkout\PaymentInstrument;
use Ucp\Sdk\Model\RequestContext;
use Ucp\Sdk\Service\PaymentHandlerRegistryInterface;

/**
* Pins the documented seam: a UCP payment instrument resolves, through the
* handler that published it, to the concrete payment method a provider can
* charge. This was the "intended path" of docs/completion-payment.md that no
* release ever wired up, so a payment integration had to reimplement handler
* lookup + prepareInstrument for itself.
*
* The resolver deliberately does NOT decide who charges or how. It only turns
* the instrument into the (paymentMethodId, token) pair the handler declares;
* switching the order or settling stays with the caller's applier.
*/
#[CoversClass(PaymentInstrumentResolver::class)]
final class PaymentInstrumentResolverTest extends TestCase
{
#[Test]
public function itResolvesTheInstrumentThroughItsHandler(): void
{
$handler = $this->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'),
);
}
}