fix: never report unpaid orders as completed; advertise delegated payment handlers - #130
fix: never report unpaid orders as completed; advertise delegated payment handlers#130Robin Schulte (relativvv) wants to merge 6 commits into
Conversation
…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.
|
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 ( Part A — advertise delegated payment handlers: correct, please keepYour root cause holds, and the spec side backs it:
So gating advertisement on the 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 —
|
|
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 → #152From my earlier comment, the alternative I suggested:
From #152's description:
Same answer, independently reached, and it handles the concern behind Part B better than Part B does:
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 Part A → please keep, and consider splitting it outUnchanged from my earlier comment: advertising delegated payment handlers is correct and nothing of ours covers it. Splitting Part A into its own PR would let it land immediately. It's self-contained ( HousekeepingI've resolved #151's conflict with 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. |
…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>
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 → orderpaid).Problem
payment_handlersalways empty — paying was undiscoverable./.well-known/ucp(and checkout responses) advertisedpayment_handlers: {}, so an agent could not discover how to pay and had to guesshandler_id: "x402".completed.checkout.completereturnedstatus: completedwithpayment: nullfor 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
CapabilityFilteringProfileContributor::contribute()set the profile'spaymentHandlersto[]unless thepayment-tokenizationcapability descriptor was enabled — and that descriptor is only enabled when some registered handler returnssupportsTokenization() === 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.CheckoutCompleter::complete()(andShopwareDataMapper::toCompletedCheckout()) hardcodedCheckoutStatus::Completedregardless 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 statepaid? Safe default: unknown/unloaded ⇒ not paid.src/Ucp/Checkout/CheckoutCompleter.php— computepaid ? Completed : CompleteInProgressand pass it to the mapper and the session store, on both the fresh-order and replay paths.src/Ucp/Gateway/ShopwareDataMapperInterface.php+ShopwareDataMapper.php—toCompletedCheckout()takes an optionalCheckoutStatus(defaultCompleted, so existing callers are unaffected) instead of always assumingCompleted.src/Ucp/Gateway/ShopwareOrderGateway.php— additionally loadtransactions.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 -lclean on all changed/added files (PHP 8.3).complete_in_progress; paid order ⇒completed;OrderPaymentStatepaid/unpaid/unknown.complete_in_progress; after on-chain USDC settlement via the surfacedextra.x402pay route, the transaction flips topaid.Notes for reviewers
main, which has independently evolved the order/payment area — it now shipssrc/Ucp/Order/OrderStateSubscriber. That subscriber publishesorder.updatedwebhooks on state-machine transitions; it does not change the synchronouscheckout.completeresponse status. I verifiedCheckoutCompleter/ShopwareDataMapperstill hardcodedCheckoutStatus::Completedonmain, so §3 is not yet fixed upstream and this change is still required. The two mechanisms are complementary (webhook notification vs. correct synchronous status).ShopwareOrderGateway::orderCriteria():mainadded astateMachineStateassociation; I kept it and addedtransactions.stateMachineStatealongside (both are needed). TheShopwareDataMapperInterface::toOrderView()$checkoutIdparam added upstream was preserved by the 3-way merge.complete_in_progress.completereplay 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 tocompleted. Tracked separately.