Skip to content

fix: make checkout-complete callable and keep discount responses schema-valid - #155

Open
Björn Meyer (BrocksiNet) wants to merge 3 commits into
fix/mcp-tool-error-visibility-and-catalog-lookup-idsfrom
fix/ucp-checkout-complete-payment-and-discount-totals
Open

fix: make checkout-complete callable and keep discount responses schema-valid#155
Björn Meyer (BrocksiNet) wants to merge 3 commits into
fix/mcp-tool-error-visibility-and-catalog-lookup-idsfrom
fix/ucp-checkout-complete-payment-and-discount-totals

Conversation

@BrocksiNet

Copy link
Copy Markdown
Contributor

Stacked on #154. That PR made tool failures visible in band; these are the first two things the visibility exposed, and both are real defects rather than agent mistakes.

Why is this change necessary?

checkout-complete could never commit. Traced end to end:

step result
checkout-update with a buyer accepted, reaches ready_for_complete
checkout-complete with dryRun=false $.payment is required
its MCP schema id, dryRun only — nowhere to put payment

The requirement is correct and comes from the spec, not from a bug. checkout.json annotates the field:

"payment": { "$ref": "payment.json",
  "ucp_request": { "create": "optional", "update": "optional", "complete": "required" } }

so the generated checkout.complete.request.json derives required: ["payment"] faithfully. The defect is on our side: UcpCheckoutCompleteTool::__invoke() declared only id and dryRun and sent [] as the payload, where every other mutating tool forwards the agent's. The money tool could not place an order, and no agent input could fix it.

discount-apply failed its own response schema, server-side, after succeeding. Applying a code adds a Shopware promotion line item with a negative unit price. ShopwareDataMapper::mapShopwareLineItems() forwarded every line regardless of type, and the SDK's LineItem::toArray() emits a per-line {"type": "subtotal"} entry — which types/total.json constrains to minimum: 0:

{ "if":   { "properties": { "type": { "enum": ["subtotal", "fulfillment", "tax", "fee"] } }, "required": ["type"] },
  "then": { "properties": { "amount": { "minimum": 0 } } } }

ShoppingOperationExecutor::response() validates before returning, so the agent got a server error on a valid request whose write had already landed. discount-apply is only where it is noticed first: cart.get, cart.update, checkout.get and checkout.update run the same mapping and broke identically whenever a promotion sat in the cart.

Three payload shapes were impossible to infer, each failing in a way that reads like the agent's fault. cart.update requires the cart id inside the payload as well as on the tool argument, because the SDK validates the raw payload instead of merging the resource id first as it does for cart.get/cart.cancel — and HttpPayloadMapper::toCartUpdateRequest() then discards the payload copy, so it is pure redundancy. checkout.create accepts cart_id for cart-to-checkout conversion plus discounts.codes, fulfillment and buyer_consent, none of which appear in its generated request schema. And line_items is a full replacement on both update tools, so an agent told to add a shipping address that sends only fulfillment empties the cart.

What does this change do, exactly?

checkout-complete takes a payload like the other mutating tools, and defaults payment to an empty instrument list when the agent does not mention it. Nothing reads the instrument yet — CheckoutAdapterInterface::completeCheckout() takes only an id and a context, and CheckoutCompleter never references payment, so completion charges the sales channel default (invoice/offline) method. Making the agent invent an instrument would be a gate with no payoff. An explicit payment is passed through untouched, so it starts meaning something the moment the SDK threads it into the adapter.

The default is {"instruments": []} rather than {} deliberately: an empty PHP array passes only because GeneratedSchemaValidator::matchesExpectedType() treats [] as an object, and the payload should not lean on that leniency.

Defaulting lives in UcpCheckoutCompletionPayment for the same reason UcpCheckoutCompletionPreview was split out in #154 — the tool depends on the final ShoppingOperationExecutor and cannot be constructed with a mock, so inline logic would leave the money path untestable.

Promotions are reported the way the spec models them. They are excluded from line_items and emitted as a negative items_discount total, one of the two types total.json permits a negative amount. Shopware's positionPrice is already net of the promotion, so the discount is added back out of the subtotal and reported separately: subtotal + items_discount returns to positionPrice, and subtotal stays non-negative as the schema requires. The entry is omitted entirely when nothing was discounted, since zero would violate exclusiveMaximum: 0.

The predicate keys off a negative unit price rather than the promotion type, so credits and custom negative lines are covered too. The embedded page needed no change — its ucp_embedded_totals block already renders any type generically, so the discount moves from an item row to an "Items discount" total row.

The three payload shapes are now in the tool descriptions, including that line_items stays required when converting a cart, so cart_id alone does not silently fail.

Verification

Lane 66. 530 unit tests pass (was 508), 1656 assertions, 8 pre-existing skips. php-cs-fixer reports 0 of 269 files fixable. PHPStan reports 21 errors, all pre-existing and in files this PR does not touch — zero overlap with the seven changed files.

The payment gate, against the real GeneratedSchemaValidator and the real schemas:

payload result
[] — what the tool sent before ["$.payment is required"]
{"payment":{"instruments":[]}} — the new default passes
explicit com.shopware.invoice instrument passes

That first row is the reported failure reproduced exactly.

UcpResponseSchemaTest runs the production path — the payload's own fields plus the ucp envelope, exactly as ShoppingOperationExecutor::response() assembles it — through the real validator, across discount.apply, cart.get, cart.update, cart.create, checkout.get, checkout.update and checkout.create, with a promotion in the cart. It resolves the schema directory by reflection the same way UcpSdkExtension does, so it holds whether the SDK is installed from a path repository or from vendor/.

I checked the new tests actually catch the old bug rather than merely passing: reverting only ShopwareDataMapper makes UcpResponseSchemaTest throw ValidationException on discount.apply.response and the mapper tests fail on the promotion still being in line_items.

Both unguessable-payload claims were confirmed against the validator rather than assumed — cart.update without a payload id returns ["$.id is required"], and checkout.create with cart_id but no line_items returns ["$.line_items is required"].

Not verified end to end: a live checkout-complete --dryRun=false placing a real order on a lane, which is blocked by the same profile-fetch handshake documented in #154.

Known limits and upstream follow-ups

Four gaps have their root cause in the SDK and are recorded in docs/full-ucp-parity-plan.md rather than worked around here:

  • completeCheckout() cannot receive the payment the spec requires. CheckoutAdapterInterface::completeCheckout(string $id, RequestContext $context) — the executor validates payment, then drops it. Fixing this changes a public interface every merchant adapter implements, so it needs a deprecation path rather than a drive-by patch.
  • Checkout has no payment field, though checkout.json defines one as a response property. This is why checkout-update accepts payment and checkout-get shows no trace of it.
  • Cart has no discounts field and no extra escape hatch (Checkout has extra). So the spec's richer discounts.applied[] breakdown — discount.json$defs.applied_discount, with per-target allocations — cannot be expressed on cart.get, cart.update or discount.apply at all. This PR reports the amount; the itemisation waits on that field.
  • cart.update's duplicate id and checkout.create's missing schema fields are both upstream: merging the resource id before validating in cartUpdate() would remove the duplication and match the SDK's own ShoppingOperationToolSchemas::CART_UPDATE_PAYLOAD, which requires only line_items and currently contradicts its runtime validator.

Also worth noting: payment_handlers is always empty in discovery. CapabilityFilteringProfileContributor gates it on DESCRIPTOR_PAYMENT_TOKENIZATION, which UcpExtensionAvailability strips unless a handler reports supportsTokenization() === true, and ShopwareInvoicePaymentHandler returns false. That is correct today, and this PR does not change it — worth stating so the empty list is not read as a regression from the payment work.

The tool sent an empty payload to checkout.complete, but UCP marks payment as
required for that operation: checkout.json annotates it

    ucp_request: {create: "optional", update: "optional", complete: "required"}

and the generated checkout.complete.request.json derives `required: ["payment"]`
from it. Every commit therefore failed schema validation with
`$.payment is required`, and the tool declared only `id` and `dryRun`, so an
agent had nowhere to put one. No UCP order could be placed over MCP at all.

Add a payload parameter, matching the other mutating tools, and default payment
to an empty instrument list when the agent does not mention it. Nothing reads
the instrument yet -- CheckoutAdapterInterface::completeCheckout() takes only an
id and a context, so completion charges the sales channel default
(invoice/offline) method -- so requiring the agent to invent one would be a gate
with no payoff. An explicit payment is passed through untouched, so it starts
meaning something as soon as the SDK threads it into the adapter.

The defaulting lives in its own class because the tool depends on the final
ShoppingOperationExecutor and cannot be constructed with a mock, the same reason
UcpCheckoutCompletionPreview was split out.
Applying a discount code adds a Shopware promotion line item with a negative
unit price. The mapper forwarded every line item regardless of type, and the
SDK's LineItem::toArray() emits a per-line `{"type": "subtotal"}` entry, which
types/total.json constrains to `minimum: 0`. The response then failed its own
schema inside ShoppingOperationExecutor::response(), so the agent saw a server
error even though the request was valid and the discount had been applied.

discount.apply is where an agent notices, but any response carrying a promotion
was affected: cart.get, cart.update, checkout.get and checkout.update all run
the same mapping.

Report these lines the way the spec models them instead: exclude them from
line_items and emit a negative `items_discount` total, which is one of the two
types total.json permits a negative amount. Shopware's positionPrice is already
net of the promotion, so the discount is added back out of the subtotal and
reported separately -- subtotal + items_discount returns to positionPrice, and
subtotal stays non-negative as the schema requires. The entry is omitted when
nothing was discounted, since zero would violate `exclusiveMaximum: 0`.

The predicate keys off a negative unit price rather than the promotion type, so
credits and custom negative lines are handled too. The embedded page needs no
change: its totals block already renders any type generically.

UcpResponseSchemaTest runs the real GeneratedSchemaValidator against the real
schemas, so the mapping and the spec cannot drift apart again unnoticed.
Three request shapes are impossible to infer from the tool schemas, and each one
fails in a way that reads like the agent's mistake:

- cart.update requires the cart id inside the payload as well as on the tool
  argument. The SDK validates the raw payload instead of merging the resource id
  first as it does for cart.get and cart.cancel, so omitting it returns
  `$.id is required` even though the id was supplied.
- checkout.create accepts cart_id for cart-to-checkout conversion, plus
  discounts.codes, fulfillment and buyer_consent. The SDK maps all four, but the
  generated request schema lists none of them. line_items stays required even
  when converting, so cart_id alone returns `$.line_items is required`.
- line_items is a full replacement on both update tools. An agent told to add a
  shipping address that sends only fulfillment empties the cart.

Record the remaining gaps in the parity plan too, so the entry that claims the
MCP matrix is fully routed no longer reads as complete when payment is validated
but discarded and discounts.applied[] is not emitted at all.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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.

1 participant