diff --git a/src/Resources/schema/quote.openapi.json b/src/Resources/schema/quote.openapi.json index 202b7b97..eedbeb81 100644 --- a/src/Resources/schema/quote.openapi.json +++ b/src/Resources/schema/quote.openapi.json @@ -3,7 +3,7 @@ "info": { "title": "com.shopware.quote", "version": "2026-04-08", - "description": "Buyer-facing B2B Request-for-Quote capability for Shopware shops. Protocol-neutral: any UCP buyer agent can use it.\n\n## Authentication and authorization\n\nQuote operations act on behalf of a customer. Obtain an OAuth 2.0 access token through the shop's `dev.ucp.common.identity_linking` capability, then send it as `Authorization: Bearer `. The access token subject is the trust boundary: the customer's consent decides whose quotes are touched, and nothing in the request body can select a customer. An unscoped Shopware context token is not accepted. Requests also carry the standard `UCP-Agent` header and a request signature when the shop's signature policy requires it.\n\nWhen `/.well-known/oauth-authorization-server` lists `com.shopware.quote:manage` in `scopes_supported`, the shop enforces scopes: every quote operation requires `com.shopware.quote:manage`, and `acceptQuote` additionally requires `dev.ucp.shopping.order:manage`, because accepting places an order. Request both when linking the account. A token missing a required scope gets a 403 naming the missing scopes; link the account again, requesting them. A shop that does not list the quote scope cannot issue it, so it does not check scopes. Authorization is also by quote ownership, on every shop: the token's subject identifies the customer, and every operation is scoped to that customer's own quotes. A request whose token carries no linked customer fails with a validation error rather than succeeding unscoped.\n\nThe capability is advertised in discovery whenever the shop has a UCP surface and the `dev.ucp.common.identity_linking` capability is enabled for the sales channel; a shop that restricts its capabilities and omits identity linking publishes no quote descriptor at all, because a capability nobody can obtain a token for is a dead end. Being advertised is not being routable: discovery does not probe the commercial backend, so a shop without a licensed Quote Management installation advertises the capability and fails every request as unsupported. Treat a 501 as the real availability answer. The customer must additionally have the Quote Management feature enabled (`customer_specific_features`). Unknown and foreign quote ids both return not found, without confirming existence.\n\n## State machine\n\n`open` and `in_review` are the merchant's turn. `replied` is the buyer's turn: accept, counter, or decline. `change_requested` returns the turn to the merchant. Accepted, declined, expired, withdrawn, and cancelled quotes are terminal for the buyer; a merchant may reopen or extend a quote as documented in `x-state-machine`.\n\n## Expiration\n\nOffers carry `expiration_date` (ISO 8601, always present in responses, and possibly null before the merchant replies). Agents must read it and act before it.\n\n## Price semantics\n\nAll amounts are in the quote's `currency`. `totals.tax_status` states whether `totals.gross` or `totals.net` is authoritative. Line item prices and requested prices are **per unit**.\n\nNot every shop can record a per-line ask: the field and the route behind `requested_unit_price` are absent on released commercial builds. Where they are, sending `line_items` with a `requested_unit_price` fails with a validation error naming the limitation, and `requested_unit_price` is always null on reads. Since a counter line item requires a `requested_unit_price`, on those shops a counter-offer must be sent as `comment` alone, with no `line_items` — the merchant reads the ask from the comment. Probe once with the counter you intended and fall back to a comment on the 422; nothing in discovery reports this.\n\n## Idempotency\n\nEvery mutating request accepts `Idempotency-Key`. Shops configured to require it reject a mutating request without one. A repeated key with the same request fingerprint replays the stored response with `Idempotency-Replay: 1`; a key reused for a different request, or for a response too large to have been stored, is a 409.\n\n## Async negotiation\n\nThe merchant responds out-of-band. Agents poll `GET /ucp/quotes/{id}` for state changes; the recommended interval is 300 seconds. Webhooks are not part of this capability version.", + "description": "Buyer-facing B2B Request-for-Quote capability for Shopware shops. Protocol-neutral: any UCP buyer agent can use it.\n\n## Authentication and authorization\n\nQuote operations act on behalf of a customer. Obtain an OAuth 2.0 access token through the shop's `dev.ucp.common.identity_linking` capability, then send it as `Authorization: Bearer `. The access token subject is the trust boundary: the customer's consent decides whose quotes are touched, and nothing in the request body can select a customer. An unscoped Shopware context token is not accepted. Requests also carry the standard `UCP-Agent` header and a request signature when the shop's signature policy requires it.\n\nWhen `/.well-known/oauth-authorization-server` lists `com.shopware.quote:manage` in `scopes_supported`, the shop enforces scopes: every quote operation requires `com.shopware.quote:manage`, and `acceptQuote` additionally requires `dev.ucp.shopping.order:manage`, because accepting places an order. Request both when linking the account. A token missing a required scope gets a 403 naming the missing scopes; link the account again, requesting them. A shop that does not list the quote scope cannot issue it, so it does not check scopes. Authorization is also by quote ownership, on every shop: the token's subject identifies the customer, and every operation is scoped to that customer's own quotes. A request whose token carries no linked customer fails with a validation error rather than succeeding unscoped.\n\nThe capability is advertised in discovery whenever the shop has a UCP surface and the `dev.ucp.common.identity_linking` capability is enabled for the sales channel; a shop that restricts its capabilities and omits identity linking publishes no quote descriptor at all, because a capability nobody can obtain a token for is a dead end. Being advertised is not being usable: discovery does not check the commercial Quote Management backend. A shop without it serves no quote routes, so every request is a 404; a shop that has it but no license fails every request as unsupported (501). Treat either as the real availability answer. A 404 on the `/ucp/quotes` collection can only mean this; on a quote id it can also mean the quote is not the customer's. To request, counter, accept or decline, the customer must additionally have the Quote Management feature enabled (`customer_specific_features`); reads are not checked. Unknown and foreign quote ids both return not found, without confirming existence.\n\n## State machine\n\n`open` and `in_review` are the merchant's turn. `replied` is the buyer's turn: accept, counter, or decline. `change_requested` returns the turn to the merchant. Accepted, declined, expired, withdrawn, and cancelled quotes are terminal for the buyer; a merchant may reopen or extend a quote as documented in `x-state-machine`.\n\n## Expiration\n\nOffers carry `expiration_date` (ISO 8601, always present in responses, and possibly null before the merchant replies). Agents must read it and act before it.\n\n## Price semantics\n\nAll amounts are in the quote's `currency`. `totals.tax_status` states whether `totals.gross` or `totals.net` is authoritative. Line item prices and requested prices are **per unit**.\n\nNot every shop can record a per-line ask: the field and the route behind `requested_unit_price` are absent on released commercial builds. Where they are absent, sending `line_items` with a `requested_unit_price` fails with a validation error naming the limitation, and `requested_unit_price` is always null on reads. Since a counter line item requires a `requested_unit_price`, on those shops a counter-offer must be sent as `comment` alone, with no `line_items` — the merchant reads the ask from the comment. Probe once with the counter you intended and fall back to a comment on the 422; nothing in discovery reports this.\n\n## Idempotency\n\nEvery mutating request accepts `Idempotency-Key`. Shops configured to require it reject a mutating request without one. A repeated key with the same request fingerprint replays the stored response with `Idempotency-Replay: 1`; a key that is still being processed, or reused for a different request, or for a response too large to have been stored, is a 409.\n\n## Async negotiation\n\nThe merchant responds out-of-band. Agents poll `GET /ucp/quotes/{id}` for state changes; the recommended interval is 300 seconds. Webhooks are not part of this capability version.", "x-capability": "com.shopware.quote" }, "x-polling-interval-seconds": 300, @@ -194,6 +194,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "501": { "$ref": "#/components/responses/Unsupported" } @@ -213,7 +216,7 @@ "maximum": 50, "default": 25 }, - "description": "Page size; values above 50 are clamped." + "description": "Page size; values above 50 are clamped. A non-integer value is a 400." }, { "name": "page", @@ -223,7 +226,8 @@ "type": "integer", "minimum": 1, "default": 1 - } + }, + "description": "A non-integer value is a 400." } ], "responses": { @@ -246,6 +250,9 @@ "422": { "$ref": "#/components/responses/UnprocessableEntity" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "501": { "$ref": "#/components/responses/Unsupported" } @@ -379,7 +386,7 @@ } }, "400": { - "description": "Not acceptable in current state or expired (code CHECKOUT__QUOTE_CANNOT_PLACE_ORDER)", + "description": "Not acceptable in current state or expired. The message `code` is `invalid_request`; `content` carries the reason from the commercial quote-to-order flow.", "content": { "application/json": { "schema": { @@ -497,7 +504,7 @@ "schema": { "type": "string" }, - "description": "Replay guard for a mutating request. Required by shops whose signature policy demands it; a replayed response carries `Idempotency-Replay: 1`." + "description": "Replay guard for a mutating request. Required by shops configured to demand it; a replayed response carries `Idempotency-Replay: 1`." } }, "responses": { @@ -512,7 +519,7 @@ } }, "UnprocessableEntity": { - "description": "Invalid payload, per-line price asks sent to a shop that cannot record them, the access token does not resolve to a customer, or Quote Management is not enabled for the customer (`customer_specific_features` must contain `{\"QUOTE_MANAGEMENT\": true}`)", + "description": "Invalid payload, per-line price asks sent to a shop that cannot record them, the access token does not resolve to a customer, or, on a request, counter, accept or decline, Quote Management is not enabled for the customer (`customer_specific_features` must contain `{\"QUOTE_MANAGEMENT\": true}`)", "content": { "application/json": { "schema": { @@ -522,7 +529,7 @@ } }, "NotFound": { - "description": "Quote unknown or owned by another customer, without confirming which", + "description": "Quote unknown or owned by another customer, without confirming which. On any path, also a shop without the commercial Quote Management backend, which serves no quote routes.", "content": { "application/json": { "schema": { @@ -532,7 +539,7 @@ } }, "Unsupported": { - "description": "The quote capability is disabled, not licensed, or the commercial Quote Management backend is unavailable", + "description": "The commercial Quote Management backend is installed but not licensed, or a service the capability needs is unavailable", "content": { "application/json": { "schema": { @@ -542,7 +549,7 @@ } }, "Conflict": { - "description": "Idempotency key reused for a different request, or for a response that is no longer replayable (code `idempotency_conflict`)", + "description": "Idempotency key still being processed, reused for a different request, or for a response that is no longer replayable (code `idempotency_conflict`)", "content": { "application/json": { "schema": { @@ -552,7 +559,7 @@ } }, "InsufficientScope": { - "description": "insufficient_scope: the access token is valid but lacks a scope this operation requires. Only on shops that list com.shopware.quote:manage in scopes_supported. Link the account again, requesting the scopes the message names.", + "description": "The access token is valid but lacks a scope this operation requires. The message `code` is `invalid_request`; its `content` starts with `insufficient_scope:` and names the missing scopes. Only on shops that list com.shopware.quote:manage in scopes_supported. Link the account again, requesting those scopes.", "content": { "application/json": { "schema": { @@ -787,7 +794,7 @@ }, "order": { "type": "object", - "description": "Present only after acceptance", + "description": "Present only in the acceptQuote response (and its idempotent replay); later reads of an accepted quote do not carry it.", "required": [ "id" ], @@ -805,11 +812,11 @@ }, "a2cn_session_id": { "type": "string", - "description": "Present only when the quote was created with an A2CN session stamped onto it. The buyer's negotiation session id for POST /a2cn/sessions/{sessionId}/messages; derivable from the quote id, so its absence is not an error." + "description": "Present only in the requestQuote response, when an A2CN session was stamped onto the new quote; later reads do not carry it. The buyer's negotiation session id for POST /a2cn/sessions/{sessionId}/messages; derivable from the quote id, so its absence is not an error." }, "ucp": { "type": "object", - "description": "Protocol envelope added to every successful response; `capabilities` appears once an agent has negotiated.", + "description": "Protocol envelope added to every successful response.", "properties": { "version": { "type": "string" @@ -817,9 +824,6 @@ "status": { "type": "string", "const": "success" - }, - "capabilities": { - "type": "object" } } } @@ -861,9 +865,10 @@ "type": "string", "description": "Machine-readable error code", "examples": [ - "validation_error", - "resource_not_found", - "unsupported_capability" + "invalid_request", + "not_found", + "idempotency_conflict", + "capability_unsupported" ] }, "content": { diff --git a/src/Resources/schema/quote.spec.html b/src/Resources/schema/quote.spec.html index 45892ada..2e3b2a38 100644 --- a/src/Resources/schema/quote.spec.html +++ b/src/Resources/schema/quote.spec.html @@ -47,28 +47,28 @@

Discovery

The capability appears in /.well-known/ucp when the shop runs a UCP surface and the merchant has enabled dev.ucp.common.identity_linking for that sales channel — without it no token can be issued for quoting, so the descriptor is - suppressed rather than advertised as a dead end. Its descriptor carries the endpoint and - the schema URL: + suppressed rather than advertised as a dead end. Its descriptor carries the capability + version and the URLs of this page and of the schema:

"com.shopware.quote": [{
   "version": "2026-04-08",
   "spec":    "https://shop.example/.well-known/ucp/specs/quote.html",
-  "schema":  "https://shop.example/.well-known/ucp/schemas/quote.openapi.json",
-  "config": {
-    "endpoint": "https://shop.example/ucp/quotes",
-    "schema":   "https://shop.example/.well-known/ucp/schemas/quote.openapi.json"
-  }
+  "schema":  "https://shop.example/.well-known/ucp/schemas/quote.openapi.json"
 }]

If the entry is absent, the shop cannot quote: fall back to ordinary checkout, or tell - the user that quoting is unavailable. Do not hard-code the endpoint; read it from the - descriptor. + the user that quoting is unavailable. The descriptor names no endpoint. The operation + paths are the paths of the schema; resolve them against the shop's base + URL, which is the schema URL up to /.well-known/.

- Present in discovery is not the same as usable. Discovery does not probe the commercial - B2B Quote Management backend, so a shop without a licensed installation still advertises - the capability and answers every call with 501. Treat the first 501 as the real - availability answer and fall back exactly as you would for a missing entry. + Present in discovery is not the same as usable. Discovery does not check the commercial + B2B Quote Management backend. A shop without it still advertises the capability but + serves no quote routes, so every call is a 404. A shop that has the backend but no + license for it answers every call with 501. Treat the first such answer as the real + availability answer and fall back exactly as you would for a missing entry. A 404 on + POST /ucp/quotes or GET /ucp/quotes can only mean this; a 404 + on a quote id can also mean the quote is not yours.

Authorization

@@ -169,15 +169,17 @@

Signing GET requests

Requests also carry the usual UCP-Agent header, and an Idempotency-Key on writes — required where the shop is configured to demand it, and in every case a replay guard: the same key with the same request replays the - stored response (Idempotency-Replay: 1), a key reused for a different one - is a 409. A Shopware + stored response (Idempotency-Replay: 1), and a key that is still being + processed, or reused for a different request, or for a response too large to have been + stored, is a 409. A Shopware sw-context-token is deliberately not accepted: it carries no OAuth scopes and could reuse the customer's existing storefront cart.

Beyond authorization, the customer must have the commercial Quote Management feature - enabled on their account. If not, calls fail with a validation error naming the missing - feature. That is a merchant-side setting, not something an agent can resolve. + enabled on their account. If not, requesting, countering, accepting and declining fail + with a validation error naming the missing feature; reads are not checked. That is a + merchant-side setting, not something an agent can resolve.

Operations

@@ -200,7 +202,8 @@

State machine

repliedbuyeraccept, counter, decline change_requestedmerchantpoll reopenmerchantpoll — the merchant reopened a declined quote; it returns to replied - accepted / declined / expired / withdrawn / cancellednoneterminal for the buyer + expiredmerchantterminal for the buyer; the merchant may extend it back to replied + accepted / declined / withdrawn / cancellednoneterminal for the buyer

Only replied is buyer-actionable. Acting from any other state fails, so @@ -251,16 +254,17 @@

Asynchronous negotiation

Errors

- + + - - + + - + - +
SituationResultStatus
Capability disabled, not licensed, or the commercial Quote Management backend is unavailableunsupported capability — note the descriptor may still be in discovery501
Commercial Quote Management backend not installed: the shop serves no quote routesnot found, on every path; the descriptor may still be in discovery404
Commercial Quote Management backend installed but not licensed, or missing a service the capability needsunsupported capability; the descriptor may still be in discovery501
Access token missing, invalid, expired, or revokedinvalid token401
Access token lacks com.shopware.quote:manage, or dev.ucp.shopping.order:manage on accept, on a shop that lists the quote scopeinsufficient_scope, naming the missing scopes; link the account again403
Authorization request whose signature did not verify, or whose client_id is not the signed profile URIerror naming UCP-Agent401
Access token lacks com.shopware.quote:manage, or dev.ucp.shopping.order:manage on accept, on a shop that lists the quote scopemessage starting insufficient_scope: and naming the missing scopes; link the account again403
Authorization request whose signature did not verify, or whose client_id is not the signed profile URIerror saying the signature did not verify, or that client_id must be the signed profile URI401
authorization_endpoint called by an agent without a signed-in storefront customeridentity_required; use the account-linking flow400
Credential resolves to no customervalidation error naming the header422
Quote Management not enabled for the customer (customer_specific_features)validation error naming the feature422
Quote Management not enabled for the customer (customer_specific_features), on a request, counter, accept or declinevalidation error naming the feature422
Malformed request body or line itemsvalidation error422
Per-line price ask sent to a shop that cannot record onevalidation error telling you to send the ask as a comment422
Idempotency-Key missing where required, or reused for a different requestvalidation error / idempotency_conflict422 / 409
Idempotency-Key missing where required, or still being processed, or reused for a different requestvalidation error / idempotency_conflict422 / 409
Quote id unknown or owned by another customernot found, without confirming existence404
Accepting from a non-actionable or expired stateerror from the commercial quote-to-order flow400
@@ -273,8 +277,12 @@

Errors

Versioning

- The capability version tracks the UCP protocol version in the descriptor. Additive - fields may appear without a version change; read defensively and ignore unknown fields. + The descriptor's version is the revision of this contract, not the UCP + protocol version the shop serves (that one is ucp.version in discovery). + It changes only when the contract changes incompatibly. UCP negotiation matches a + capability by exact version, so if your profile lists com.shopware.quote, + list it at the version the descriptor publishes. Additive fields may appear without a + version change; read defensively and ignore unknown fields.

diff --git a/src/Ucp/Quote/QuoteCapability.php b/src/Ucp/Quote/QuoteCapability.php index d4510a7e..5dbc7111 100644 --- a/src/Ucp/Quote/QuoteCapability.php +++ b/src/Ucp/Quote/QuoteCapability.php @@ -20,12 +20,14 @@ * collects them into its CapabilityRegistry. No decoration of the Agentic * Commerce plugin, and no changes to it. * - * Guard-then-delegate: the backend is optional, so without SwagCommercial the - * gateway is absent and every operation fails as unsupported (501) rather than - * as a container error. The descriptor is still published — the profile - * contributor asks only whether identity linking is enabled, never whether the - * commercial backend is there — because the contract documents are served - * either way, and the published schema says so: a 501, not a missing + * Guard-then-delegate: the backend is optional, so a null or unavailable + * gateway fails every operation as unsupported (501) rather than as a + * container error. Over HTTP that 501 is what an installed but unlicensed + * SwagCommercial answers; without its quote bundle the routes are never + * imported (AgentFacingRoutes), so every call is a 404. The descriptor is + * published in both cases — the profile contributor asks only whether + * identity linking is enabled, never whether the commercial backend is there — + * and the published contract says so: the 404 or 501, not a missing * descriptor, is what tells an agent this shop cannot quote. */ final class QuoteCapability implements CapabilityInterface diff --git a/src/Ucp/Quote/QuoteCapabilityDescriptor.php b/src/Ucp/Quote/QuoteCapabilityDescriptor.php index e9e14f7f..aedfba64 100644 --- a/src/Ucp/Quote/QuoteCapabilityDescriptor.php +++ b/src/Ucp/Quote/QuoteCapabilityDescriptor.php @@ -28,6 +28,11 @@ final class QuoteCapabilityDescriptor { public const NAME = 'com.shopware.quote'; + /** + * The revision of this capability's contract, not the UCP protocol version + * the SDK serves. Negotiation matches capabilities by exact version, so + * change it only for an incompatible contract change. + */ public const VERSION = '2026-04-08'; public const SPEC_PATH = '/.well-known/ucp/specs/quote.html';