Skip to content

feat(ucp): negotiate payment method on checkout completion, escalate to browser handoff when none is supported - #152

Open
Lukas Rump (lukasrump) wants to merge 4 commits into
mainfrom
feat/ucp-payment-method-negotiation
Open

feat(ucp): negotiate payment method on checkout completion, escalate to browser handoff when none is supported#152
Lukas Rump (lukasrump) wants to merge 4 commits into
mainfrom
feat/ucp-payment-method-negotiation

Conversation

@lukasrump

Copy link
Copy Markdown
Contributor

What & why

Today the checkout completer places an order regardless of whether the client can
actually pay through any handler the shop advertises. For x402 (order-first,
settle-after) that's by design, but it means a client that supports none of the
advertised payment_handlers either ends up with a placed, never-payable order or
no path forward.

This adds a deterministic, opt-in payment-method negotiation:

  • The client commits a handler via CheckoutUpdateRequest.payment (PaymentInstrument).
  • On completion, if the committed handler is registered in the SDK
    PaymentHandlerRegistry, the order is placed as today.
  • Otherwise (no commitment, or an unsupported/declined handler) the checkout is
    returned as requires_escalation with a continue_url and no order is placed
    the UCP-standard fallback that lets a human finish in a browser.

It's driven by what the client agrees to pay with, not by what the shop offers, so
the shop never silently places an order (e.g. an unpaid invoice order) against a method
the agent didn't choose.

Opt-in / non-breaking

Gated behind a per-channel policy requireCommittedPaymentMethod (default off):

  • off (default): unchanged behaviour — completion proceeds without a committed
    payment method (spec-conformant: the UCP payment object is optional).
  • on: completion requires a committed, settle-able handler; otherwise it escalates.

So this changes nothing unless a merchant opts in — suitable for agent-only channels
that must not accumulate phantom unpaid orders. The flag is read via SystemConfigService
(SwagAgenticCommerce.config.requireCommittedPaymentMethod); an admin toggle (config.xml)
is an easy follow-up.

Spec alignment

  • PaymentInstrument on update and payment_handlers advertisement are standard UCP.
  • requires_escalation + continue_url is the spec's prescribed fallback for a
    capability/negotiation failure.
  • Payment remains optional per the spec; the strict behaviour is opt-in only.

Scope

  • Ucp/Checkout/CheckoutSessionStore.php, CheckoutSessionManager.php — persist/read the committed handler id.
  • Ucp/Adapter/ShopwareCheckoutAdapter.php — capture the commitment on update; policy-gated escalation on complete.
  • Tests + docs/payment-method-negotiation.md.
  • continue_url can point at any merchant checkout; a signed cart-adopting handoff route is deployment-specific and intentionally not included here.

Coordination note

feat/ucp-ap2-mandates reworks completion to a CheckoutCompleteRequest /
verifiedCheckout API. This targets main; once that branch lands, the escalation
gate should move into the request-based flow. Happy to rebase/adapt.

Testing

Unit test added for the escalation path (policy on + no supported handler committed ⇒
requires_escalation, completer never invoked → no order). Please run PHPStan/PHPUnit in
CI — the branch was authored without a local PHP toolchain.

@lukasrump
Lukas Rump (lukasrump) force-pushed the feat/ucp-payment-method-negotiation branch from 8ff49e4 to e0596ed Compare July 29, 2026 12:52
Place an order on completeCheckout only when the client committed a payment handler the shop can settle (registered in the SDK PaymentHandlerRegistry). When the per-channel policy requireCommittedPaymentMethod is enabled and no supported handler was committed, return the checkout as requires_escalation with a continue_url and place no order — the UCP-standard fallback that lets a human finish in a browser.

Off by default: completion proceeds unchanged (UCP treats the payment object as optional), so this is opt-in per channel and non-breaking.
@lukasrump
Lukas Rump (lukasrump) force-pushed the feat/ucp-payment-method-negotiation branch from e0596ed to a65bb2b Compare July 29, 2026 13:08
The branch was 79 commits behind, predating #154/#155/#157 and the UCP address
and error work. Merged rather than rebased so the original history is untouched.
`$a?->b ?? $c` — the nullsafe is unnecessary on the left of `??`, because the
null-coalescing operator already evaluates the chain with isset semantics and
tolerates a null base. PHPStan flags it, and it was the only finding in this
branch's own code.

No behaviour change. Picked up while running the toolchain the PR asked for.
@BrocksiNet

Copy link
Copy Markdown
Contributor

Brought this up to date with main and ran the toolchain you asked for. Two pushes: a merge commit and a one-line lint fix.

It was 79 commits behind — the branch predated #154/#155/#157 and the UCP address/error work. Merged rather than rebased so your history is untouched. No conflicts.

Toolchain results after the merge: composer test 538 green (9 skipped), composer cs clean, and one PHPStan finding in this branch's own code, which I fixed:

- $paymentHandlerId = $request->payment?->handlerId ?? $this->sessionStore->paymentHandlerId($metadata);
+ $paymentHandlerId = $request->payment->handlerId  ?? $this->sessionStore->paymentHandlerId($metadata);

The nullsafe is redundant on the left of ?? — the null-coalescing operator evaluates the chain with isset semantics, so a null base is already tolerated. No behaviour change. The other three findings in your files are the repo-wide CoversClass rule (98 of those lane-wide, nearly all in untouched files).

The important part: your negotiation only worked for off-spec clients until two hours ago

This reads the committed handler from CheckoutUpdateRequest.payment. Until ucp-php-sdk#112 merged, the SDK mapped that property wrongly for the spec shape:

// before #112, in HttpPayloadMapper
isset($payload['payment']) && is_array($payload['payment'])
    ? $this->toPaymentInstrument($payload['payment'])   // reads handler_id at the TOP level
    : null,

payment.json defines {"instruments": [...]}. So a conformant client committing a supported handler —

{"payment": {"instruments": [{"handler_id": "com.shopware.invoice", "type": "delegated", "selected": true}]}}

— arrived as PaymentInstrument('tokenized', ''), with an empty handler id. Your registry lookup would never match it, so with the policy on, a client that had correctly committed a supported handler would be escalated to a browser handoff anyway. It only worked for clients sending the flat {handler_id, type} shape, which is not what the spec describes.

#112 fixed that: create and update now read the spec-shaped list and prefer the instrument marked selected — which is the field that exists precisely to say "this is the one the buyer chose", and which your feature depends on. So the negotiation is now correct for conformant agents, and wasn't before. Worth re-testing with a spec-shaped payload if your harness was sending the flat one.

One design point worth your call

The policy is read through SystemConfigService (SwagAgenticCommerce.config.requireCommittedPaymentMethod), while every other per-channel UCP setting lives in the plugin's own table behind UcpConfigService.

That's not wrong — SystemConfigService::get() is per-channel — but it creates a second config surface for UCP, and ucp:config:show / ucp:channels will not report it. We lost seven CI runs earlier to exactly that divergence: system:config:get and the UCP config disagreeing about the same channel, because channel-scoped UCP config lives in the plugin's own table and system_config is only a legacy read-time fallback. Someone debugging an unexpected escalation will run ucp:config:show, see nothing about payment negotiation, and conclude the flag is off.

Suggestion: fold it into UcpConfig alongside the other per-channel policy fields, so it shows up in ucp:config:show and can be set by ucp:config:set. Not a blocker for this PR — just cheaper now than after someone has debugged it once.

Coordination

Two things ahead of this that you should know about:

  1. fix: never report unpaid orders as completed; advertise delegated payment handlers #130's Part B is superseded by this PR. It reports a placed-but-unpaid order as complete_in_progress; the spec says completed means "Order placed successfully" and complete_in_progress means "Business is processing the Complete Checkout request", so parking a placed order there is wrong — and it is non-terminal, so it invites the platform to wait for a transition that never comes. Your requires_escalation + continue_url + don't place the order is the spec's prescribed shape for this. I've said so on fix: never report unpaid orders as completed; advertise delegated payment handlers #130.
  2. I'm about to build on this branch. The plugin currently registers the UCP shipping address as Shopware's billing address and never sets a shipping address at all. Fixing that needs CheckoutSessionManager, CheckoutSessionStore and ShopwareCheckoutAdapter — the same three files you touch — so I'll base that work on this branch rather than race it. Shout if you'd rather I waited.

Brings in #159 and #160. Merged rather than rebased so the original history stays
intact.
Björn Meyer (BrocksiNet) added a commit that referenced this pull request Aug 5, 2026
The plugin resolved ONE address, from the fulfillment destination, and registered
it as Shopware's `billingAddress` — passing no `shippingAddress` at all, so
Shopware defaulted shipping to billing. Correct when the two are the same, wrong
the moment an agent states them separately, which UCP can:

    fulfillment.methods[].destinations[]      -> shipping address
    payment.instruments[].billing_address     -> billing address

Both are a `postal_address`; `context.json` names both concepts in one sentence
("Higher-resolution data (shipping address, billing address) supersedes
context"). They simply live in different objects, and the plugin only ever read
the first one.

`CheckoutGuestAddressPayloadResolver::resolveAddresses()` now returns the pair,
each from the place the protocol defines for it. Either one alone still fills
both: Shopware cannot register a guest without a billing address, and a digital
cart has no destination to offer — which is the case that motivated this, since
for a cart with nothing to ship the instrument's billing address is the ONLY
address UCP has.

`resolve()` is kept as the single-address entry point, returning the billing
address, so callers that only need what gets registered do not have to know about
the pair.

The pair is threaded through the session metadata as a new `guestShippingAddress`
key, following how #152 added `paymentHandlerId` — a trailing optional parameter
on save/saveForCheckoutId and a getter beside `guestAddress()`. Sessions written
before this have no such key, so they read as "no distinct shipping address" and
behave exactly as before.

`shippingAddress` is sent to the register route only when the agent stated one
that differs from the billing address. Omitted, Shopware defaults shipping to
billing — the behaviour every existing session relies on.

Needs ucp-php-sdk 0.0.4: `PaymentInstrument::$billingAddress` did not exist
before it, and the SDK dropped the field in mapping, so the billing address was
unreachable no matter what an agent sent.

564 tests green. The store journey still reads 22 passed / 1 failed — it sends
only a destination, so it exercises the compatibility path (one address filling
both) and proves no regression; the separate-address path is unit-covered.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants