diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index edf126445..989fa438d 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -9,6 +9,7 @@ Anytown Backordered Braintree CFRG +CEFACT CIMD Carrefour Centricity @@ -99,6 +100,7 @@ llmstxt mastercard meunier midsole +millilitre misparsed mkdocs mtok @@ -158,3 +160,4 @@ punycode userinfo examplecorp zapatillas +recoverably diff --git a/docs/specification/cart.md b/docs/specification/cart.md index 13adb23d8..982f3c07c 100644 --- a/docs/specification/cart.md +++ b/docs/specification/cart.md @@ -89,6 +89,28 @@ SHOULD be linked for the duration of the checkout. on a cleared cart ID return `not_found`; the platform can start a new session with `create_cart`. +## Quantity and sale basis + +Cart line items apply the shared +[quantities and units](overview.md#quantities-and-units) contract. Each +`line_items[].quantity` is an integer step count in the item's authoritative +sale basis. On a Business response, an absent +`line_items[].item.quantity_unit` encodes the default `each` basis, so +`quantity` counts whole items. + +On a Platform request, omission of `line_items[].item.quantity_unit` makes no +assertion. The Business interprets `quantity` using the item's authoritative +sale basis, so a request for a measure-denominated item can omit the descriptor +without asserting `each`. The Platform **MAY** include `item.quantity_unit` to +assert the sale-basis identity. + +Cart follows [Checkout — Quantity and sale basis](checkout.md#quantity-and-sale-basis) +for sale-basis discovery, assertion matching, mismatch conversion or +rejection, response echo, ordering-increment handling, and line pricing. +Cart totals remain estimates (see [Total](#total)). A measure-denominated line +counts as one line item in cart summaries; its `quantity` is an amount, not an +item count. + ## Actions The cart surfaces outstanding Action instances in its response-only `actions` diff --git a/docs/specification/catalog/index.md b/docs/specification/catalog/index.md index 2964757ac..6e7f9e388 100644 --- a/docs/specification/catalog/index.md +++ b/docs/specification/catalog/index.md @@ -41,6 +41,9 @@ This enables product discovery before checkout, supporting use cases like: Large"), price, and availability. * **Price**: Price values include both amount (in minor currency units) and currency code, enabling multi-currency catalogs. +* **Sale basis**: How quantity is denominated—as whole items (`each`, the + default) or in a unit of measure such as weight, length, area, volume, or + time. See [Quantities and units](../overview.md#quantities-and-units). ### Relationship to Checkout @@ -53,6 +56,114 @@ terms for the given request but are not transactional commitments — checkout is authoritative. Responses can be session-specific and **SHOULD NOT** be reused across sessions without re-validation. +## Sale basis and quantity units + +`variants[].quantity_unit` advertises a variant's sale basis before a +transaction. The descriptor follows the shared +[quantities and units](../overview.md#quantities-and-units) contract. Its +absence advertises the default `each` basis; the Business advertises a +non-`each` basis by including the descriptor. + +The catalog is where the Platform learns the sale basis before transacting: +`unit` and `scale` define how quantities are denominated, and an optional +[`increment`](../overview.md#ordering-increment) advertises the ordering +granularity the Business sells in, letting the Platform build quantity +steppers and validate input before submission. A Business selling bananas by +the pound in quarter-pound multiples advertises +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }`. + +The sale basis is not limited to physical measure: metered offerings — +parking by the minute, labor by the hour — use the same descriptor (`MIN`, +`HUR`) with the same contract. + +`variants[].id` identifies the purchasable variant; `quantity_unit` defines the +denomination and granularity used to order it. +Bananas whose `quantity_unit` is +`{ "unit": "LBR", "scale": 2, "display_text": "lb" }` are sold in +hundredth-of-a-pound steps, so a `quantity` of `150` represents 1.50 lb. + +### Distinction from unit price + +`quantity_unit` and a variant's [`unit_price`](#variant) answer different +questions and are set independently: + +* `quantity_unit` is the **sale basis** — the unit `quantity` is counted in and + `price` is quoted in. +* `unit_price` is a **display comparator** — a derived "price per standard + measure" (for example, per 100 mL) for shelf-style comparison. Its `measure` + is the packaging or content quantity of the variant and its `reference` is the + comparison denominator. + +The unit fields in `quantity_unit`, `unit_price.measure`, and +`unit_price.reference` follow the shared +[quantities and units](../overview.md#quantities-and-units) contract. The two +unit-price fields use the shared measure type, so their integer `value` fields +are step counts. The Business **MAY** provide `quantity_unit`, `unit_price`, +both, or neither on a variant. + +To keep the display comparator defined, the Business **MUST** use a positive +integer for both `unit_price.measure.value` and `unit_price.reference.value`. +The Business **MUST** set `unit_price.currency` equal to `price.currency`. +Within `unit_price`, the Business **MUST** use identical values for +`measure.unit` and `reference.unit`. The Business **MAY** use different `scale` +values for those measures. The Business **MUST NOT** perform cross-unit or +currency conversion as part of the unit-price calculation. Each measure +represents its integer `value × 10^-scale` in the common unit. The Business +**MUST** compute the comparator from `price.amount` and those scaled values: + +```text +(price.amount / (measure.value × 10^-measure.scale)) × (reference.value × 10^-reference.scale) +``` + +The Business **MUST** round the result once to the currency's minor units +according to its pricing rules and return it as `unit_price.amount`. The +returned `unit_price.amount` is authoritative. The Platform **MUST NOT** +recompute it or substitute its own result. + +The same-unit and same-currency rules are semantic invariants. JSON Schema +validates the corresponding fields independently and does not enforce either +equality. + +For example, a 50 m cable spool sold by `each` can omit `quantity_unit` while +carrying a `unit_price` per metre. Its `measure` can be +`{ "value": 5000, "unit": "MTR", "scale": 2, "display_text": "m" }` and its +`reference` can be +`{ "value": 1, "unit": "MTR", "display_text": "m" }`. Both measures use +`MTR`; they represent 50 m and 1 m respectively without cross-unit conversion. + +### Example: a good sold by weight + +A `get_product` response for bananas sold by the pound. The variant advertises +`quantity_unit` +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }` and a +`price` of `79` — $0.79 per whole pound, sold in 0.25-lb multiples: + + +```json +{ + "ucp": { "version": "{{ ucp_version }}" }, + "product": { + "id": "prod_bananas", + "title": "Bananas", + "description": { "plain": "Fresh bananas sold by the pound." }, + "price_range": { + "min": { "amount": 79, "currency": "USD" }, + "max": { "amount": 79, "currency": "USD" } + }, + "variants": [ + { + "id": "var_bananas", + "title": "Bananas", + "description": { "plain": "Fresh bananas sold by the pound." }, + "price": { "amount": 79, "currency": "USD" }, + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }, + "availability": { "available": true } + } + ] + } +} +``` + ## Shared Entities ### Context diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index 4222e17bf..a473b3d63 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -69,6 +69,263 @@ Fulfillment is optional in the checkout object. This is done to enable a platform to perform checkout for digital goods without needing to furnish fulfillment details more relevant for physical goods. +### Quantity and sale basis + +Checkout applies the shared +[quantities and units](overview.md#quantities-and-units) contract, including the +default (`C62`, `0`) identity. The Business determines each item's authoritative +sale basis for the transaction. The Business's response is authoritative, and +each `line_items[].quantity` is an integer step count in that basis. + +**Discovering the sale basis.** The sale basis is item data: the Platform +**SHOULD** discover an item's `quantity_unit` — including `scale` and any +`increment` — from the +[catalog](catalog/index.md#sale-basis-and-quantity-units) before transacting. +A Platform without catalog knowledge can omit the descriptor: the Business +applies its authoritative basis and confirms it on the response line, and the +Platform inspects the echoed descriptor and, if that interpretation is not +what it intended, resubmits the quantity denominated in the now-known basis. +Asserting `quantity_unit` on a request (below) is how the Platform checks that +a basis it previously discovered still holds; a Platform that does not know +the basis omits the descriptor rather than asserting a guess. + +**Requesting a quantity.** When a Platform request omits +`line_items[].item.quantity_unit`, the Platform makes no assertion about the +sale-basis identity. The Business interprets `quantity` using the item's +authoritative sale basis. A request for a measure-denominated item can therefore +omit the descriptor without asserting the default identity. + +The Platform **MAY** include `quantity_unit` on a request line to assert the +basis it believes it is ordering in. When the Platform includes it, the Business +**MUST** compare the asserted and authoritative +[machine identities](overview.md#quantities-and-units). An explicit assertion +with `unit: "C62"` and effective `scale` `0` matches the default identity +represented by an absent descriptor; a `display_text` difference is not a +mismatch. + +If an asserted `quantity_unit` does not match the item's authoritative sale +basis, the requested quantity is denominated in a basis the Business does not +sell in. Silent conversion is forbidden in both directions: neither party may +reinterpret a quantity the other denominated without surfacing the change. The +Business resolves the mismatch in one of two ways: + +* **Convert, visibly.** When the Business can convert the asserted + basis to its authoritative basis (for example, pounds to kilograms), it + **MAY** apply the request as a visible line revision: the line carries the + authoritative descriptor and the converted quantity — rounded once to the + authoritative `scale` according to its rules, with any ordering-increment + policy applied after conversion — and the response includes a warning + `messages[]` entry at the line. UCP defines no conversion factors or + dimensions; whether to convert is the Business's own determination. +* **Reject, recoverably.** When it cannot or chooses not to convert (for + example, a quantity of fasteners denominated in litres), the Business + **MUST** reject the request's effect on that line with a recoverable + business outcome naming the authoritative basis. On update, the line + remains at its previous authoritative state. On create, the line is not created and the + message identifies the item in `content`; when no line can be created, the + Business **MAY** return an error response instead. + +In both cases the response reflects authoritative state, never an echo of +rejected input, and every line present carries its authoritative sale basis +under the response-echo rule. The Platform recovers by reading the echoed +descriptor — or re-reading the catalog — and resubmitting; the message +`content` is explanatory text for humans, never the recovery input. + +For example, an industrial supplier sells loose fasteners by the kilogram, +but stale data leads a Platform to believe they are sold by the pound. It +submits an update asserting that basis: + + +```json +{ + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb" } + }, + "quantity": 275 + } + ] +} +``` + +A Business that converts applies the update as a visible revision. +The line is denominated in the authoritative basis — the requested 2.75 lb +converts to 1.25 kg, rounded once to `scale` 2 — and a warning marks the +revision: + + +```json +{ + "ucp": { "version": "{{ ucp_version }}", "status": "success", "payment_handlers": {} }, + "id": "chk_fasteners_1", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "title": "Stainless Steel Fasteners", + "price": 1299, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" } + }, + "quantity": 125, + "totals": [ + { "type": "subtotal", "amount": 1624 }, + { "type": "total", "amount": 1624 } + ] + } + ], + "messages": [ + { + "type": "warning", + "code": "quantity_unit_converted", + "path": "$.line_items[0].quantity", + "content": "This item is sold by the kilogram. The requested 2.75 lb was converted to 1.25 kg." + } + ], + "totals": [ + { "type": "subtotal", "amount": 1624 }, + { "type": "total", "amount": 1624 } + ], + "links": [] +} +``` + +A Business that does not convert rejects the update instead. The line remains +unchanged — `quantity` stays `150` (1.50 kg), not a reinterpretation of the +requested `275` — and the error names the authoritative basis: + + +```json +{ + "ucp": { "version": "{{ ucp_version }}", "status": "success", "payment_handlers": {} }, + "id": "chk_fasteners_1", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "title": "Stainless Steel Fasteners", + "price": 1299, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" } + }, + "quantity": 150, + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ] + } + ], + "messages": [ + { + "type": "error", + "code": "quantity_unit_mismatch", + "severity": "recoverable", + "path": "$.line_items[0].item.quantity_unit", + "content": "This item is sold by the kilogram, not by the pound. Resubmit the quantity as a count of 0.01-kg steps." + } + ], + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ], + "links": [] +} +``` + +**Ordering increment.** The sale basis **MAY** declare an +[`increment`](overview.md#ordering-increment) — the ordering granularity, in +steps, the Business sells in. The declaration lets the Platform build quantity +steppers and validate input before submission. Platform-authored quantities +**SHOULD** be integer multiples of the line's effective increment. The +increment is merchandising policy, not a representational bound, and schema +validation does not enforce it: on receiving an off-increment quantity, the +Business **MAY** accept it, revise the line to an increment multiple, or reject +it with a recoverable business outcome. A revision **MUST** be returned as a +revised line `quantity` with an explanatory `messages[]` entry; the Business +**MUST NOT** silently reinterpret the requested quantity. The Business **MAY** +also revise a quantity for its own reasons (for example, limited stock); such +revisions **SHOULD** stay on the increment grid so subsequent Platform stepper +edits from the revised value remain on-grid. + +For example, bananas sold by the pound in quarter-pound multiples +(`increment` `25` at `scale` `2`). A request for `quantity` `137` (1.37 lb) is +off-increment; a Business that snaps it returns the line revised to `125` with +a warning: + + +```json +[ + { + "type": "warning", + "code": "quantity_increment_revised", + "path": "$.line_items[0].quantity", + "content": "This item is sold in 0.25 lb increments. Your requested 1.37 lb was adjusted to 1.25 lb." + } +] +``` + +**Echoing the sale basis.** The Business **MAY** omit `quantity_unit` from a +response line whose effective sale-basis identity is the default identity. The +Business **MUST** include `quantity_unit` on the item in every Cart, Checkout, +and Order line response whenever the effective identity differs from the +default identity. Omission would otherwise make a measure-denominated line read +as a count of whole items. + +**Pricing a line.** `item.price` is the amount per one whole +`quantity_unit.unit` (for example, per kg or per hour); when `quantity_unit` is +absent, it is the price per `each`. Other characteristics of a sale unit may +affect the quoted `item.price`, but do not change its sale-basis denominator. +The Business **MUST** compute the line total as +`price × quantity × 10^-scale` and round once at the line. The presented +`totals[]` remain authoritative (see [Totals](checkout.md#totals)). The Platform +**MUST NOT** recompute a line total from the fractional quantity and substitute +its own rounding. + +For example, a Business sells bananas by the pound with `quantity_unit` +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }` and a +`price` of `79` ($0.79/lb). A Buyer orders 1.50 lb, so the Platform sends +`quantity` `150` — 150 hundredth-of-a-pound steps. The line total is +`79 × 150 × 10^-2 = 118.5`, rounded once per its pricing rules to `119` +($1.19): + + +```json +{ + "ucp": { "version": "{{ ucp_version }}", "status": "success", "payment_handlers": {} }, + "id": "chk_bananas_1", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_bananas", + "item": { + "id": "var_bananas", + "title": "Bananas", + "price": 79, + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 } + }, + "quantity": 150, + "totals": [ + { "type": "subtotal", "amount": 119 }, + { "type": "total", "amount": 119 } + ] + } + ], + "totals": [ + { "type": "subtotal", "amount": 119 }, + { "type": "total", "amount": 119 } + ], + "links": [] +} +``` + ### Checkout Status Lifecycle The checkout `status` field indicates the current phase of the session and diff --git a/docs/specification/glossary.md b/docs/specification/glossary.md index 716e41f7f..32f74e075 100644 --- a/docs/specification/glossary.md +++ b/docs/specification/glossary.md @@ -43,9 +43,12 @@ acronym in each specification Markdown file spells out the full term (e.g., | Term | Acronym | Definition | | :--------------------------- | :------ | :---------------------------------------------------------------------------------------------------------------------------------------------------- | | **Business** | - | The entity selling goods or services. In UCP, they act as the **Merchant of Record (MoR)**, retaining financial liability and ownership of the order. | +| **Increment** | - | Optional [ordering granularity](overview.md#ordering-increment) of a sale basis, a count of steps; advisory for Platform-authored quantities. | | **Merchant of Record** | MoR | The legal entity responsible for the sale, including financial liability and order ownership. | | **Payment Service Provider** | PSP | The financial infrastructure provider that processes payments, authorizations, and settlements on behalf of the business. | | **Platform** | - | The consumer-facing surface (AI agent, app, website) acting on behalf of the user to discover businesses and facilitate commerce. | +| **Quantity Unit** | - | A [sale-basis descriptor](overview.md#quantities-and-units) with machine identity (`unit`, effective `scale`); excludes `display_text`/`increment`. | +| **Step** | - | One [integer increment](overview.md#quantities-and-units) in the authoritative sale basis: `10^-scale` of `unit`. | ## Payments diff --git a/docs/specification/order.md b/docs/specification/order.md index ac5650ee4..92b6b9b79 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -56,7 +56,20 @@ Orders have three main components: Line items reflect what was purchased at checkout and their current state: * Item details (product, price, quantity ordered) -* Quantity counts and fulfillment status +* Quantity counts and fulfillment status — `original`, `total`, and `fulfilled` + are integer step counts of the item's inherited `quantity_unit` under the + shared [quantities and units](overview.md#quantities-and-units) contract; an + absent `quantity_unit` means the quantities count whole items (`each`) at + `scale` 0 + +All order lifecycle arithmetic over quantities operates on sale-basis step +counts inherited from the item; other item characteristics do not enter that +arithmetic. Business-recorded quantities — fulfillment events, adjustments, and +revised totals — are bounded only by `scale`: a declared ordering +[`increment`](overview.md#ordering-increment) binds Platform requests at cart +and checkout and does not constrain what the Business records (a 0.25-lb +ordering increment does not prevent recording an actual picked weight of +1.90 lb). ### Fulfillment @@ -109,8 +122,8 @@ fulfillment: `price_adjustment`, `dispute`, `cancellation`) * Can be any post-order change * Optionally link to line items (or order-level for things like shipping refunds) -* Quantities and amounts are signed—negative for reductions (returns, refunds), - positive for additions (exchanges) +* Quantities are signed step counts and amounts are signed—negative for + reductions (returns, refunds), positive for additions (exchanges) * Include totals breakdown when relevant * Can happen at any time regardless of fulfillment status @@ -137,8 +150,16 @@ Line items reflect what was purchased at checkout and their current state. } ``` +When the item is measure-denominated these are step counts — for an +`item.quantity_unit` of +`{ "unit": "LBR", "scale": 2, "display_text": "lb" }`, `fulfilled: 50` means +0.50 lb fulfilled. + **Status Derivation:** +`total` and `fulfilled` are step counts in the same inherited +`item.quantity_unit`; the derivation operates on those counts: + ```text if (total == 0) → "removed" else if (fulfilled == total) → "fulfilled" @@ -278,6 +299,151 @@ Examples: `refund`, `return`, `credit`, `price_adjustment`, `dispute`, } ``` +## Example: goods sold by measure + +An order for bananas sold by the pound (`item.quantity_unit` +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }`). +Quantities are step counts: the Buyer ordered 1.50 lb (`total: 150`), of which +0.50 lb has shipped (`fulfilled: 50`), so the line is `partial`. A later +return of 0.25 lb is recorded as an adjustment of `-25` steps. Amounts are +priced at $0.79/lb and rounded once — the line total is `79 × 1.50 = 118.5`, +rounded to `119`, and the return credits `79 × 0.25 = 19.75`, rounded to +`20`: + + +```json +{ + "ucp": { + "version": "{{ ucp_version }}", + "capabilities": { "dev.ucp.shopping.order": [{"version": "{{ ucp_version }}"}] } + }, + "id": "order_bananas_1", + "checkout_id": "chk_bananas_1", + "permalink_url": "https://business.example.com/orders/bananas1", + "currency": "USD", + "line_items": [ + { + "id": "li_bananas", + "item": { + "id": "var_bananas", + "title": "Bananas", + "price": 79, + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 } + }, + "quantity": { "original": 150, "total": 150, "fulfilled": 50 }, + "totals": [ + { "type": "subtotal", "amount": 119 }, + { "type": "total", "amount": 119 } + ], + "status": "partial" + } + ], + "fulfillment": { + "events": [ + { + "id": "evt_1", + "occurred_at": "2026-01-08T10:30:00Z", + "type": "shipped", + "line_items": [{ "id": "li_bananas", "quantity": 50 }], + "tracking_number": "123456789", + "tracking_url": "https://carrier.example/track/123456789", + "description": "0.50 lb shipped" + } + ] + }, + "adjustments": [ + { + "id": "adj_1", + "type": "return", + "occurred_at": "2026-01-10T14:30:00Z", + "status": "completed", + "line_items": [{ "id": "li_bananas", "quantity": -25 }], + "totals": [{ "type": "total", "amount": -20 }], + "description": "Returned 0.25 lb" + } + ], + "totals": [ + { "type": "subtotal", "amount": 119 }, + { "type": "total", "amount": 119 } + ] +} +``` + +## Example: catch-weight reconciliation + +For weighed goods, the picked weight routinely differs from the ordered +weight. The difference is a commercial fact, not a numeric error: the Business +records the actual pick and reconciles the difference with an +[adjustment](#adjustments) that moves money together with quantity. No +tolerance comparison is involved; the status derivation operates on exact step +counts throughout. + +A Buyer orders 2.00 lb of the same bananas at $0.79/lb, sold in quarter-pound +increments (`quantity` `200`, line total `79 × 2.00 = 158`). The picker weighs +out 1.90 lb — an off-increment fact, recorded as-is, because the +[`increment`](overview.md#ordering-increment) binds Platform ordering, not +Business records. The fulfillment event records the actual `190` steps, and a +`price_adjustment` of `-10` steps reconciles `total` to the actual pick with +its price delta (`79 × 0.10 = 7.9`, rounded once to `8`). `fulfilled == total` +then holds exactly (`190 == 190`) and the line derives `fulfilled`: + + +```json +{ + "ucp": { + "version": "{{ ucp_version }}", + "capabilities": { "dev.ucp.shopping.order": [{"version": "{{ ucp_version }}"}] } + }, + "id": "order_bananas_2", + "checkout_id": "chk_bananas_2", + "permalink_url": "https://business.example.com/orders/bananas2", + "currency": "USD", + "line_items": [ + { + "id": "li_bananas", + "item": { + "id": "var_bananas", + "title": "Bananas", + "price": 79, + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 } + }, + "quantity": { "original": 200, "total": 190, "fulfilled": 190 }, + "totals": [ + { "type": "subtotal", "amount": 158 }, + { "type": "total", "amount": 158 } + ], + "status": "fulfilled" + } + ], + "fulfillment": { + "events": [ + { + "id": "evt_1", + "occurred_at": "2026-01-12T09:15:00Z", + "type": "shipped", + "line_items": [{ "id": "li_bananas", "quantity": 190 }], + "description": "Picked 1.90 lb" + } + ] + }, + "adjustments": [ + { + "id": "adj_1", + "type": "price_adjustment", + "occurred_at": "2026-01-12T09:15:00Z", + "status": "completed", + "line_items": [{ "id": "li_bananas", "quantity": -10 }], + "totals": [{ "type": "total", "amount": -8 }], + "description": "Adjusted to actual picked weight of 1.90 lb" + } + ], + "totals": [ + { "type": "subtotal", "amount": 158 }, + { "type": "total", "amount": 158 } + ] +} +``` + ## Scopes The Order capability defines the following well-known scopes for diff --git a/docs/specification/overview.md b/docs/specification/overview.md index 0793cea17..1b0e4b670 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -31,6 +31,115 @@ Schema notes: unless otherwise specified - Amounts format: Minor units (cents) +## Quantities and units + +UCP uses a shared quantity representation wherever a schema contains an integer +`quantity`, a `quantity_unit`, or the shared measure type. + +A `quantity` is an integer count of **steps**. A unit descriptor consists of: + +- `unit` — a required, stable machine identifier. +- `scale` — an optional nonnegative integer. Its effective value is the provided + value or `0` when omitted. +- `display_text` — a required printable label for the unit. + +One step is `10^-scale` of `unit`. The shared measure type adds a required +integer `value`, which is also a count of those steps. Because these counts are +integers, `scale` fixes the representation's granularity. A unit descriptor's +machine identity is the (`unit`, effective `scale`) pair; `display_text` is not +part of that identity. This identity applies only to the unit descriptor; it +does not identify the purchasable item or exhaustively describe one sale unit. + +The default sale basis is `each`, with machine identity (`C62`, `0`). `C62` is +the United Nations Centre for Trade Facilitation and Electronic Business +(UN/CEFACT) Recommendation 20 (Rec20) Common Code for one/each. The Business +**MAY** omit `quantity_unit` from an authoritative Business representation to +encode this default. When a Business or Platform includes a +descriptor whose `unit` is `C62`, it **MUST** use an effective `scale` of `0`; +`scale` can only be omitted or explicitly set to `0`. + +UCP does not put floating-point numbers on the wire. Quantity arithmetic +feeds money — `price × quantity × 10^-scale` prices a line, `fulfilled` +accumulates across fulfillment events, and status derives from +`fulfilled == total` — so quantities get money's representation: an integer +count plus a declared interpretation, exactly as an `amount` relates to its +`currency`. Integer counts keep every total and comparison exact in every +language, and UCP therefore defines no rounding tolerances and no epsilon +comparisons anywhere in the quantity lifecycle. A fulfilled quantity that +legitimately differs from the ordered quantity — a 1.90 lb pick against a +2.00 lb order — is a commercial fact reconciled through +[adjustments](order.md#adjustments) that move money together with quantity, +not a numeric error absorbed by comparison fuzz. + +Reading a quantity requires no arithmetic and no unit knowledge: shift the +decimal point `scale` places and append `display_text`. `150` with +`{ "scale": 2, "display_text": "kg" }` renders as `1.50 kg`, by the same code +path for a Rec20 code and for a custom unit. Unlike a currency exponent, +`scale` is per-item data rather than a static table — which is why +authoritative responses always carry their own descriptor on every non-`each` +line. + +### Unit vocabulary + +The Business **SHOULD** use the exact Rec20 Common Code unless no code +accurately identifies the unit. When no Rec20 code accurately identifies the +unit, the Business **MAY** use a custom unit identifier. If it does, the Business +**MUST** use that identifier consistently for the same unit. The Platform +**MUST** treat an unrecognized `unit` value as opaque. The following table is +non-exhaustive: + +| Code | Unit | +| :---- | :----------- | +| `C62` | one / `each` | +| `KGM` | kilogram | +| `GRM` | gram | +| `LBR` | pound | +| `MLT` | millilitre | +| `LTR` | litre | +| `MTR` | metre | +| `INH` | inch | +| `YRD` | yard | +| `FTK` | square foot | +| `MTK` | square metre | +| `HUR` | hour | +| `MIN` | minute | + +Rec20 includes X-prefixed package units derived from UN/CEFACT Recommendation +21 (Rec21). UCP deliberately excludes those values from `quantity_unit`. The +Business **MUST** make package form part of the purchasable variant's identity +and count packages as `each`. The Business **MUST NOT** use an X-prefixed +Rec21-derived package code as `quantity_unit`. + +### Display text + +When sending a unit descriptor, a Business or Platform **MUST** include +`display_text`. The Platform **MUST** use that value when it does not recognize +`unit`. For a recognized Rec20 code, the Platform **MAY** substitute its own +localized label. The Business and Platform **MUST NOT** use `display_text` when +matching machine identities or as an input to quantity conversion. + +### Ordering increment + +A sale-basis descriptor (`quantity_unit`) **MAY** declare an `increment`: an +optional positive integer, denominated in steps, whose effective value is the +provided value or `1` when omitted. Only the sale basis carries an increment; +the bare unit descriptor and the shared measure type do not. It declares the +ordering granularity the Business sells in — for example, an item sold by the +pound with `scale` `2` and `increment` `25` is sold in 0.25 lb multiples. + +`scale` and `increment` play different roles: `scale` bounds what any quantity +can express; `increment` shapes what the Platform asks for. The increment is +advisory merchandising policy, not a representational bound — Platform-authored +quantities **SHOULD** be integer multiples of the line's effective increment, +while Business-authored quantities (checkout revisions, fulfillment events, +adjustments) are bounded only by `scale`. `increment` is not part of the +unit-descriptor machine identity and **MUST NOT** participate in mismatch +comparison. + +Request assertions, mismatch handling, response echo, off-increment request +handling, pricing, and lifecycle behavior are defined by the capability that +uses the shared representation. + ## Actions An Action is an outstanding unit of extension-defined work for a Platform to diff --git a/source/schemas/common/types/measure.json b/source/schemas/common/types/measure.json new file mode 100644 index 000000000..ff305403d --- /dev/null +++ b/source/schemas/common/types/measure.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/measure.json", + "title": "Measure", + "description": "A measure composed of an integer value and a unit descriptor. Its value is the integer count of `10^-scale` units of `unit`.", + "allOf": [ + { + "$ref": "unit.json" + }, + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "integer", + "description": "Integer count of `10^-scale` units of `unit`." + } + } + } + ] +} diff --git a/source/schemas/common/types/quantity_unit.json b/source/schemas/common/types/quantity_unit.json new file mode 100644 index 000000000..17e3848b9 --- /dev/null +++ b/source/schemas/common/types/quantity_unit.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/quantity_unit.json", + "title": "Quantity Unit", + "description": "Sale-basis descriptor for quantities: the shared unit descriptor plus the Business's ordering policy. Its unit-descriptor machine identity remains (`unit`, effective `scale`); `display_text` and `increment` are excluded from identity and mismatch comparison.", + "allOf": [ + { + "$ref": "unit.json" + }, + { + "type": "object", + "properties": { + "increment": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Ordering granularity, denominated in steps: the Business sells this item in integer multiples of `increment` steps. Its effective value is the provided value or 1. Advisory merchandising policy, not a representational bound: Platform-authored quantities SHOULD be integer multiples of the effective increment; the Business MAY accept, revise, or reject an off-increment request with a recoverable business outcome and MUST NOT silently reinterpret it. Business-authored quantities (checkout revisions, fulfillment events, adjustments) are bounded only by `scale`." + } + } + } + ] +} diff --git a/source/schemas/common/types/unit.json b/source/schemas/common/types/unit.json new file mode 100644 index 000000000..b8ccea563 --- /dev/null +++ b/source/schemas/common/types/unit.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/unit.json", + "title": "Unit", + "description": "A reusable unit descriptor for quantities and measures. Its unit-descriptor machine identity is (`unit`, effective `scale`), where effective `scale` is the provided `scale` or 0; `display_text` is excluded.", + "type": "object", + "required": ["unit", "display_text"], + "properties": { + "unit": { + "type": "string", + "description": "Stable machine identifier. The Business SHOULD use the exact UN/CEFACT Rec20 Common Code when one accurately identifies the unit. Otherwise, the Business MAY use a custom unit identifier and MUST use it consistently for the same unit. The Platform MUST treat an unrecognized identifier as opaque." + }, + "scale": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "One step equals `10^-scale` of `unit`. When `unit` is `C62`, `scale`, if present, MUST be 0." + }, + "display_text": { + "type": "string", + "description": "Required printable unit label provided by the Business. The Platform MUST use it when it does not recognize `unit`; for a recognized UN/CEFACT Rec 20 Common Code, the Platform MAY substitute its own localized label. It does not participate in unit identity or mismatch comparison." + } + }, + "allOf": [ + { + "if": { + "properties": { + "unit": { "const": "C62" } + }, + "required": ["unit"] + }, + "then": { + "properties": { + "scale": { "const": 0 } + } + } + } + ] +} diff --git a/source/schemas/shopping/types/adjustment.json b/source/schemas/shopping/types/adjustment.json index c4fa91fbd..5f3fe039d 100644 --- a/source/schemas/shopping/types/adjustment.json +++ b/source/schemas/shopping/types/adjustment.json @@ -45,7 +45,7 @@ }, "quantity": { "type": "integer", - "description": "Signed quantity affected by this adjustment. Negative values represent reductions (e.g. returns); positive values represent additions (e.g. exchanges)." + "description": "Signed integer count of steps of the referenced line item's `quantity_unit` (`10^-scale` × `unit`); when `quantity_unit` is absent, it counts whole items (`each`). Negative values represent reductions (e.g. returns); positive values represent additions (e.g. exchanges)." } } }, diff --git a/source/schemas/shopping/types/expectation.json b/source/schemas/shopping/types/expectation.json index ccd01c8e9..8c7993eaf 100644 --- a/source/schemas/shopping/types/expectation.json +++ b/source/schemas/shopping/types/expectation.json @@ -28,7 +28,7 @@ "quantity": { "type": "integer", "minimum": 1, - "description": "Quantity of this item in this expectation." + "description": "Integer count of steps of the referenced line item's `quantity_unit` (`10^-scale` × `unit`); when `quantity_unit` is absent, it counts whole items (`each`)." } } }, diff --git a/source/schemas/shopping/types/fulfillment_event.json b/source/schemas/shopping/types/fulfillment_event.json index 5d0ba7e7f..84ddb6e18 100644 --- a/source/schemas/shopping/types/fulfillment_event.json +++ b/source/schemas/shopping/types/fulfillment_event.json @@ -37,7 +37,7 @@ "quantity": { "type": "integer", "minimum": 1, - "description": "Quantity fulfilled in this event." + "description": "Integer count of steps of the referenced line item's `quantity_unit` (`10^-scale` × `unit`); when `quantity_unit` is absent, it counts whole items (`each`)." } } }, diff --git a/source/schemas/shopping/types/item.json b/source/schemas/shopping/types/item.json index e52258b9e..2c51a4ea9 100644 --- a/source/schemas/shopping/types/item.json +++ b/source/schemas/shopping/types/item.json @@ -20,9 +20,14 @@ }, "price": { "$ref": "../../common/types/amount.json", - "description": "Unit price in ISO 4217 minor units.", + "description": "Unit price in ISO 4217 minor units. Price is the amount per one whole `quantity_unit.unit` (for example, per lb or per hour); when `quantity_unit` is absent, it is per `each`.", "ucp_request": "omit" }, + "quantity_unit": { + "$ref": "../../common/types/quantity_unit.json", + "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts the unit-descriptor machine identity. The Business MUST compare that machine identity (`unit`, effective `scale`), ignore `display_text` and `increment`, and resolve a mismatch by conversion surfaced as a visible line revision with a warning, or by rejection with a recoverable business outcome; silent reinterpretation is forbidden. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", + "ucp_request": "optional" + }, "image_url": { "type": "string", "description": "Product image URI.", diff --git a/source/schemas/shopping/types/line_item.json b/source/schemas/shopping/types/line_item.json index 0eba9ed05..09a7a3052 100644 --- a/source/schemas/shopping/types/line_item.json +++ b/source/schemas/shopping/types/line_item.json @@ -23,7 +23,7 @@ }, "quantity": { "type": "integer", - "description": "Quantity of the item being purchased.", + "description": "Always an integer step count. On Platform requests, steps use the item's Business-authoritative sale basis; omitting `item.quantity_unit` makes no assertion and does not imply `each`. On Business responses, `item.quantity_unit` describes the basis; if absent, it encodes the `each` machine identity (`C62`, 0) and `quantity` counts whole items.", "minimum": 1 }, "totals": { diff --git a/source/schemas/shopping/types/order_line_item.json b/source/schemas/shopping/types/order_line_item.json index 5f639dcff..0d1e36561 100644 --- a/source/schemas/shopping/types/order_line_item.json +++ b/source/schemas/shopping/types/order_line_item.json @@ -17,7 +17,7 @@ }, "item": { "$ref": "item.json", - "description": "Product data (id, title, price, image_url)." + "description": "Purchased item data, including identity, price, and sale basis." }, "quantity": { "type": "object", @@ -26,20 +26,20 @@ "original": { "type": "integer", "minimum": 0, - "description": "Quantity from the original checkout." + "description": "Quantity from the original checkout, expressed as an integer step count." }, "total": { "type": "integer", "minimum": 0, - "description": "Current total active quantity. May differ from original due to post-order modifications (e.g., returns or cancellations)." + "description": "Current active quantity after returns, cancellations, or other order changes, expressed as an integer step count." }, "fulfilled": { "type": "integer", "minimum": 0, - "description": "Quantity fulfilled so far." + "description": "Quantity fulfilled so far, expressed as an integer step count." } }, - "description": "Quantity tracking for the line item." + "description": "Tracks the line item's original, current active, and fulfilled quantities. All three values use the same inherited `item.quantity_unit`. When `item.quantity_unit` is absent on an authoritative order response, each step is one whole item (`each`) under the shared default." }, "totals": { "type": "array", diff --git a/source/schemas/shopping/types/variant.json b/source/schemas/shopping/types/variant.json index 9355b5f1a..03996c289 100644 --- a/source/schemas/shopping/types/variant.json +++ b/source/schemas/shopping/types/variant.json @@ -63,7 +63,11 @@ }, "price": { "$ref": "../../common/types/price.json", - "description": "Current selling price." + "description": "Current selling price. Price is the amount per one whole `quantity_unit.unit` (for example, per lb or per hour); when `quantity_unit` is absent, it is per `each`. Line total is `price × quantity × 10^-scale`, computed and rounded once by the Business; `totals` remain authoritative." + }, + "quantity_unit": { + "$ref": "../../common/types/quantity_unit.json", + "description": "Sale basis this variant's `quantity` is denominated in. The default sale basis is `each`, whose machine identity is (`C62`, 0); `C62` is the UN/CEFACT Rec20 code for one/each. An absent catalog descriptor encodes that default. An `increment` advertises the ordering granularity in steps (for example, `scale` 2 with `increment` 25 sells in 0.25-unit multiples)." }, "list_price": { "$ref": "../../common/types/price.json", @@ -71,12 +75,12 @@ }, "unit_price": { "type": "object", - "description": "Price per standard unit of measurement. MAY be omitted when unit pricing does not apply.", + "description": "Price per standard unit of measurement. MAY be omitted when unit pricing does not apply. `unit_price.currency` MUST equal `price.currency`; the comparator MUST NOT perform currency conversion. `measure.unit` and `reference.unit` MUST be identical; cross-unit conversion is not permitted. Their scales MAY differ; each value represents `value × 10^-scale`.", "required": ["amount", "currency", "measure", "reference"], "properties": { "amount": { "$ref": "../../common/types/amount.json", - "description": "Unit price in ISO 4217 minor units. Business MUST return precomputed unit price value: (variant.price / measure.value) * reference.value." + "description": "Unit price in ISO 4217 minor units. After satisfying the same-unit invariant, the Business MUST compute the comparator as `(price.amount / (measure.value × 10^-measure.scale)) × (reference.value × 10^-reference.scale)` and round it once to ISO 4217 minor units according to its pricing rules. The returned `unit_price.amount` is authoritative; the Platform MUST NOT recompute or substitute its own result." }, "currency": { "type": "string", @@ -84,22 +88,36 @@ "description": "ISO 4217 currency code." }, "measure": { - "type": "object", - "description": "Product quantity in packaging (e.g., 750ml bottle).", - "required": ["value", "unit"], - "properties": { - "value": { "type": "number", "description": "Package quantity." }, - "unit": { "type": "string", "description": "Unit of measurement." } - } + "description": "Product quantity in packaging/content (for example, a 750 mL bottle), distinct from `quantity_unit`, which defines the sale basis. Its integer `value` MUST be at least 1.", + "allOf": [ + { + "$ref": "../../common/types/measure.json" + }, + { + "type": "object", + "properties": { + "value": { + "minimum": 1 + } + } + } + ] }, "reference": { - "type": "object", - "description": "Denominator for unit price display (e.g., per 100ml, per 1kg).", - "required": ["value", "unit"], - "properties": { - "value": { "type": "integer", "description": "Reference quantity." }, - "unit": { "type": "string", "description": "Unit of measurement." } - } + "description": "Denominator for unit price display (for example, per 100 mL or per 1 kg). Its integer `value` MUST be at least 1.", + "allOf": [ + { + "$ref": "../../common/types/measure.json" + }, + { + "type": "object", + "properties": { + "value": { + "minimum": 1 + } + } + } + ] } } },