feat(ucp): negotiate payment method on checkout completion, escalate to browser handoff when none is supported - #152
Conversation
8ff49e4 to
e0596ed
Compare
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.
e0596ed to
a65bb2b
Compare
`$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.
|
Brought this up to date with 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: - $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 important part: your negotiation only worked for off-spec clients until two hours agoThis reads the committed handler from // before #112, in HttpPayloadMapper
isset($payload['payment']) && is_array($payload['payment'])
? $this->toPaymentInstrument($payload['payment']) // reads handler_id at the TOP level
: null,
{"payment": {"instruments": [{"handler_id": "com.shopware.invoice", "type": "delegated", "selected": true}]}}— arrived as #112 fixed that: create and update now read the spec-shaped list and prefer the instrument marked One design point worth your callThe policy is read through That's not wrong — Suggestion: fold it into CoordinationTwo things ahead of this that you should know about:
|
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.
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_handlerseither ends up with a placed, never-payable order orno path forward.
This adds a deterministic, opt-in payment-method negotiation:
CheckoutUpdateRequest.payment(PaymentInstrument).PaymentHandlerRegistry, the order is placed as today.returned as
requires_escalationwith acontinue_urland 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):payment method (spec-conformant: the UCP
paymentobject is optional).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
PaymentInstrumenton update andpayment_handlersadvertisement are standard UCP.requires_escalation+continue_urlis the spec's prescribed fallback for acapability/negotiation failure.
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.docs/payment-method-negotiation.md.continue_urlcan point at any merchant checkout; a signed cart-adopting handoff route is deployment-specific and intentionally not included here.Coordination note
feat/ucp-ap2-mandatesreworks completion to aCheckoutCompleteRequest/verifiedCheckoutAPI. This targetsmain; once that branch lands, the escalationgate 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 inCI — the branch was authored without a local PHP toolchain.