Skip to content

fix: never report unpaid orders as completed; advertise delegated payment handlers - #130

Open
Robin Schulte (relativvv) wants to merge 6 commits into
mainfrom
fix/ucp-payment-state-and-handler-discovery
Open

fix: never report unpaid orders as completed; advertise delegated payment handlers#130
Robin Schulte (relativvv) wants to merge 6 commits into
mainfrom
fix/ucp-payment-state-and-handler-discovery

Conversation

@relativvv

Copy link
Copy Markdown
Contributor

Draft — extracted from the agent-shop integration project for upstream review.

Two related fixes on the UCP checkout path, both discovered while hardening an agent-only shop end-to-end (UCP discovery → complete_in_progress + x402 → on-chain USDC settlement → order paid).

Problem

  1. payment_handlers always empty — paying was undiscoverable. /.well-known/ucp (and checkout responses) advertised payment_handlers: {}, so an agent could not discover how to pay and had to guess handler_id: "x402".
  2. "Free checkout" — unpaid orders reported as completed. checkout.complete returned status: completed with payment: null for an order placed on the sales-channel default (offline/invoice) method with an open (unpaid) transaction. The agent "bought" without paying, and it appeared successful — the most dangerous failure mode.

Root cause

  1. CapabilityFilteringProfileContributor::contribute() set the profile's paymentHandlers to [] unless the payment-tokenization capability descriptor was enabled — and that descriptor is only enabled when some registered handler returns supportsTokenization() === true. Both registered handlers (com.shopware.invoice, com.shopware.x402) are delegated (non-tokenizing → false), so a correctly registered delegated handler could never be advertised.
  2. CheckoutCompleter::complete() (and ShopwareDataMapper::toCompletedCheckout()) hardcoded CheckoutStatus::Completed regardless of the order's real transaction state.

What changed

Advertise delegated payment handlers (§2)

  • src/Ucp/Profile/CapabilityFilteringProfileContributor.php — advertise the registry's handlers whenever the sales channel is active ($config->active ? $profile->paymentHandlers : []), decoupled from the tokenization capability.
  • tests/Unit/CapabilityFilteringProfileContributorTest.php — extended with delegated-handler-advertised and inactive-channel cases.

Never report unpaid as completed (§3)

  • src/Ucp/Checkout/OrderPaymentState.php (new) — pure helper: is the order's most recent transaction's state-machine state paid? Safe default: unknown/unloaded ⇒ not paid.
  • src/Ucp/Checkout/CheckoutCompleter.php — compute paid ? Completed : CompleteInProgress and pass it to the mapper and the session store, on both the fresh-order and replay paths.
  • src/Ucp/Gateway/ShopwareDataMapperInterface.php + ShopwareDataMapper.phptoCompletedCheckout() takes an optional CheckoutStatus (default Completed, so existing callers are unaffected) instead of always assuming Completed.
  • src/Ucp/Gateway/ShopwareOrderGateway.php — additionally load transactions.stateMachineState (oldest-first sort) so the replay path reflects the true paid state.
  • tests/Unit/OrderPaymentStateTest.php (new) + tests/Unit/CheckoutCompleterTest.php (extended: unpaid ⇒ complete_in_progress, paid ⇒ completed).

Testing

  • php -l clean on all changed/added files (PHP 8.3).
  • New/extended unit tests cover: delegated handler advertised when channel active, no handlers when inactive; unpaid order ⇒ complete_in_progress; paid order ⇒ completed; OrderPaymentState paid/unpaid/unknown.
  • Verified live end-to-end against a running shop (Base Sepolia): a freshly placed x402 order reports complete_in_progress; after on-chain USDC settlement via the surfaced extra.x402 pay route, the transaction flips to paid.
  • CI in this repo runs the full PHPUnit suite (needs the Shopware bootstrap, not run locally in extraction).

Notes for reviewers

  • Reconcile against upstream divergence (important). This branch was replayed onto current main, which has independently evolved the order/payment area — it now ships src/Ucp/Order/OrderStateSubscriber. That subscriber publishes order.updated webhooks on state-machine transitions; it does not change the synchronous checkout.complete response status. I verified CheckoutCompleter/ShopwareDataMapper still hardcoded CheckoutStatus::Completed on main, so §3 is not yet fixed upstream and this change is still required. The two mechanisms are complementary (webhook notification vs. correct synchronous status).
  • One conflict resolved in ShopwareOrderGateway::orderCriteria(): main added a stateMachineState association; I kept it and added transactions.stateMachineState alongside (both are needed). The ShopwareDataMapperInterface::toOrderView() $checkoutId param added upstream was preserved by the 3-way merge.
  • Design point worth a decision: consider whether the SDK should offer a first-class "payment required / pending" surface for this case rather than reusing complete_in_progress.
  • Known follow-up (not fixed here): the complete replay path requires a Shopware context token, which a pure-UCP agent does not hold, so a token-less agent cannot re-complete to observe the flip to completed. Tracked separately.

…ot only under tokenization

payment_handlers was wiped from the UCP profile unless the payment-tokenization
capability was enabled, and that capability only turns on when a handler reports
supportsTokenization()=true. x402 is a delegated (non-tokenizing) handler, so it
was never advertised (payment_handlers: {}) and agents had to guess handler_id.
Advertise the registry's handlers whenever the sales channel is active.
…ually paid

CheckoutCompleter hardcoded CheckoutStatus::Completed, so an unpaid agent order
(default offline method, open transaction) was reported completed with
payment:null - the retro's dangerous 'free checkout'. Read the order's real
transaction state (OrderPaymentState) and return complete_in_progress until it
is paid; combined with the now-surfaced extra.x402, the agent settles via the
pay route and a replayed complete returns completed. Loads
transactions.stateMachineState so replay reflects the true state.
The anonymous ShopwareDataMapperInterface mock missed the new
$checkoutId parameter (#3) added to toOrderView(), which phpstan
flagged as an invalid override.
Payment handlers are now advertised whenever the sales channel is
active (independent of tokenization). The smoke, conformance and e2e
profile assertions still expected an empty payment_handlers map; update
them to expect the delegated (non-tokenizing) com.shopware.invoice
handler.
The offline/invoice checkout flow places the order unpaid, so §3 now
reports complete_in_progress instead of completed. Update the smoke and
functional checkout assertions to match while still verifying the order
is created.
@BrocksiNet

Copy link
Copy Markdown
Contributor

Reviewed this against the 2026-04-08 schemas and the checkout specification, because it overlaps work we have open in #159/#160. Part A is correct and nothing of ours covers it. Part B I think contradicts the spec — details below, and I would rather be argued out of it than have it merged on my say-so.

No merge conflict with either of our PRs (git merge-tree is clean both ways), so this is purely about the protocol question.

Part A — advertise delegated payment handlers: correct, please keep

Your root cause holds, and the spec side backs it:

  • ucp.json#/$defs/platform_schema declares required: ["services", "payment_handlers"].
  • payment_handler.json contains no notion of tokenization at all — not in base, not in platform_schema, not in available_instruments.

So gating advertisement on the payment-tokenization capability descriptor is entirely a plugin invention, and as you found, a delegated handler can never satisfy it. com.shopware.invoice has been unadvertisable since it was registered. Decoupling it from the tokenization capability is right.

This also closes a gap we had explicitly parked as out-of-scope in #159: an agent cannot discover how to pay, so it guesses. Good find.

Part B — complete_in_progress for unpaid orders: this reads as a spec violation

The status values, verbatim from https://ucp.dev/specification/checkout/:

  • complete_in_progress: Business is processing the Complete Checkout request.
  • completed: Order placed successfully.

completed means order placed — not payment captured. An order that exists with an open invoice transaction has been placed successfully, so completed is the accurate report and complete_in_progress is not: that state asserts "I am still processing your Complete Checkout request", which stops being true the moment the order exists.

Two further problems follow from that:

  1. It is a non-terminal state. The lifecycle says businesses MUST provide continue_url for requires_escalation and SHOULD for the other non-terminal states including complete_in_progress, and SHOULD omit it for terminal states. Parking a placed order there invites the platform to poll or hand off, waiting for a transition the completion path will never produce.

  2. The spec already says where post-placement state belongs:

    After this call, other details will be updated through subsequent events as the order, and its associated items, moves through the supply chain.

    That is order.updated — which main already ships via OrderStateSubscriber. You described the two mechanisms as complementary; on the spec's wording I read the webhook as the correct one and the synchronous status change as the incorrect one.

Your underlying concern is real and I do not want it lost. "The agent bought without paying and it looked successful" is the most dangerous failure mode, exactly as you say. I just think UCP answers it somewhere else:

  • If the business will place the order and settle later (invoice, x402 pending settlement): completed is correct, and paid-ness travels via order state and order.updated. An agent that treats completed as "paid" is misreading the protocol, not being misled by it.
  • If the business will not place the order before payment: then do not place it — report requires_escalation with a continue_url and let the buyer settle. That is the state whose whole purpose is "cannot proceed via API".

For the x402 flow specifically, your own live test is the interesting evidence: a fresh order reported complete_in_progress, then flipped to completed after on-chain settlement. If the order row already exists at the first point, the spec-shaped report is completed + an order.updated when the transaction flips. If it does not, requires_escalation + the pay route in continue_url looks like the intended shape.

There may also be a fair argument that UCP simply lacks a "placed but unpaid" surface and that this is worth raising upstream — your own note gestures at that ("consider whether the SDK should offer a first-class payment required / pending surface"). I would rather we ask upstream than pick a local meaning for an existing state.

Suggestion

Split it: land Part A on its own — it is unambiguous, self-contained, and something we depend on. Then take Part B as its own change with the status question settled, whether that is "use order events", "use requires_escalation", or "upstream needs a new state".

Happy to be wrong on Part B if you read the lifecycle differently — if so, quote me the part I am misreading and I will drop the objection. Also worth noting: your follow-up about the replay path needing a Shopware context token a pure-UCP agent does not hold is the same root cause we hit on order.get after a guest checkout. Two independent sightings, so it is a design gap rather than either lane being odd. Recorded on our side and referenced from #159.

@BrocksiNet

Copy link
Copy Markdown
Contributor

Update to my earlier comment: Part B is already solved elsewhere, the spec-shaped way. I argued against it and offered two alternatives; I hadn't yet noticed Lukas Rump (@lukasrump)'s #152 implements one of them.

Part B → #152

From my earlier comment, the alternative I suggested:

If a business genuinely will not place an order before payment, the spec-shaped answer is to not place it and report requires_escalation with a continue_url.

From #152's description:

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.

Same answer, independently reached, and it handles the concern behind Part B better than Part B does:

#130 Part B #152
unpaid order placed, reported complete_in_progress not placed
status used non-terminal state for a finished order requires_escalation, which exists for this
spec fit completed means "Order placed successfully"; complete_in_progress means "Business is processing the Complete Checkout request" the prescribed fallback for a capability/negotiation failure
agent's next move wait for a transition the completion path never produces follow continue_url
opt-in no — changes every unpaid completion yes, per-channel, default off

So the phantom-unpaid-order problem you found is real and is getting fixed — just without giving an existing status a local meaning. Your finding stands; only the mechanism moves.

Also relevant: #152 depends on CheckoutUpdateRequest.payment carrying the committed handler, and that was silently broken until ucp-php-sdk#112 merged today — a spec-shaped {"instruments":[{"handler_id":"…","selected":true}]} mapped to an instrument with an empty handler id. So #152's negotiation only worked for clients sending the flat off-spec payment shape until a few hours ago. Worth knowing if you test the two together.

Part A → please keep, and consider splitting it out

Unchanged from my earlier comment: advertising delegated payment handlers is correct and nothing of ours covers it. ucp.json#/$defs/platform_schema requires payment_handlers, and payment_handler.json has no notion of tokenization at all — so gating advertisement on the tokenization capability was a plugin invention that no delegated handler could ever satisfy. com.shopware.invoice has been unadvertisable since it was registered.

Splitting Part A into its own PR would let it land immediately. It's self-contained (CapabilityFilteringProfileContributor + its test), it's a prerequisite for any agent discovering how to pay, and it would stop #151 — stacked on this branch — from waiting on a contested change.

Housekeeping

I've resolved #151's conflict with main and pushed a merge commit there; it's green at 546 tests. It only needs Part A from this branch, so if Part A splits out, #151 can rebase onto main and stop carrying Part B.

Still happy to be wrong on the status question — if you read the lifecycle differently, quote the part I'm misreading and I'll drop the objection.

Björn Meyer (BrocksiNet) added a commit that referenced this pull request Aug 6, 2026
…outs pass SDK response validation (#165)

* fix(ucp): always emit absolute order.permalink_url so completed checkouts pass SDK response validation

The UCP checkout/order response schema requires order.permalink_url to be a
non-null absolute URI (format: uri). ShopwareDataMapper passed the nullable
continue URL as the permalink, so on sales channels without a continueUrlTemplate
the field was omitted and the shop's OWN checkout response failed the SDK
response validator with an opaque '$ must match exactly one allowed schema'
error on GET and complete — blocking every UCP checkout that reaches an order.

Add OrderPermalinkBuilder ({baseUri}/ucp/v1/orders/{id}) and thread an explicit
order permalink through CheckoutCompleter and ShopwareCheckoutAdapter into
ShopwareDataMapper::toCompletedCheckout. Points at the UCP order endpoint so the
link stays machine-resolvable for headless/agent sales channels.

Stacked on #130 (shares the toCompletedCheckout signature). Rebase onto main
after #130 merges.

* fix(ucp): make order.permalink_url the one URL every buyer can open

Answers the review question on the permalink's target, and unifies the three
different answers the plugin gave for one field.

`/ucp/v1/orders/{id}`, which the previous commit emitted at completion, cannot
be opened. Measured as a browser sends it:

    422 {"messages":[{"code":"invalid_request",
         "content":"$.headers.ucp-agent is required"}]}

It is an API endpoint, and a guest could not authenticate it even with the
header: completion rotates the Shopware context token and the response never
hands the successor back. `order.get` was broken a second way, and that one
predates this branch — `ShopwareOrderAdapter` built
`/account/order/{orderId}`, but that route resolves a **deep-link code**, so an
order id matches nothing. Both spellings render the same guest form, which is
why it looked fine. And `OrderStateSubscriber` used the configured continue URL,
a third answer.

All three now build Shopware's own order page addressed by deep-link code, which
is the one URL that works for every buyer. Checked against core (trunk) rather
than assumed:

  * `AccountOrderPageLoader::load()` refuses only when there is neither a
    customer NOR a `deepLinkCode`, then filters on the code with no branching on
    who is logged in;
  * every core order-state mail links exactly this way —
    rawUrl('frontend.account.order.single.page', {'deepLinkCode': …}, domain) —
    to guests and registered customers alike, because the sender cannot know
    which the recipient is.

Confirmed on a lane by submitting the guest form (email + postcode) at both
spellings for the same guest order:

    /account/order/{deepLinkCode}  ->  order page, order number shown
    /account/order/{orderId}       ->  back to the credentials form, no order

A continue URL is deliberately not preferred over it: it templates
`{checkoutId}`, so the lane's default resolves to /checkout/confirm and shows a
spent checkout after completion rather than the order. The builder falls back to
the order list when `deep_link_code` is null, since `permalink_url` is required
and a URL built from an id cannot resolve.

Headless channels are the case this trades away: they have no storefront order
page. Their answer should be a configured URL rather than an unopenable API
endpoint, which is a separate discussion — the endpoint served neither case.

---------

Co-authored-by: Robin Schulte <r.schulte@shopware.com>
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