Conversation
This commit formalizes totals as a "receipt-printer" contract:
The business is the authoritative source for presented totals —
their content and order — because the correct presentation is
subject to regional, product, and regulatory requirements that
the business is obligated to satisfy.
Platforms MUST render all top-level entries in the order provided.
Platforms MUST NOT interpret, filter, reorder, aggregate, or apply
display logic of their own.
Key design decisions:
- Open type: closed enum replaced with open string. Well-known values
(subtotal, discount, tax, fee, fulfillment, total, items_discount)
have implicit effect defaults. Businesses may introduce custom types
with an explicit effect field; opens up extensibility for
backwards-compatible additions (e.g. gift cards, store credit, etc).
- effect field (add/subtract): self-describing sign convention. Well-known
types carry implicit defaults; custom types must include it. Platforms
resolve sign from effect without hardcoded type knowledge. The total
entry has no effect — it is the pre-computed result, not an adjustment.
- Repeating types: all types except subtotal and total may appear multiple
times. Enables multi-jurisdiction tax (separate federal/state lines),
itemized fees (service + recycling + handling), and multiple discounts.
- Sub-lines (lines): optional nested breakdown for progressive enhancement.
The parent entry is always rendered; sub-lines are supplementary detail
platforms SHOULD render when provided. The business controls what must be
displayed (top-level) versus what may be optionally surfaced (sub-lines).
- Cardinality: exactly one subtotal and one total, schema-enforced via
JSON Schema 2020-12 contains/minContains/maxContains.
Schema changes:
- total.json renamed to total_line.json — base entry type with open type
string. Used by line_item, order_line_item, fulfillment_option.
- totals.json (new) — constrained array composing total_line.json with
required display_text, effect, lines, and cardinality rules. Used by
checkout, cart, order.
Schema composition in totals.json:
The array has two layers of constraints, each using allOf:
Item shape (items.allOf): composes the base total_line.json entry with
checkout-level requirements — display_text becomes required, and the
effect and lines fields are added. This tightening only applies at
checkout/cart/order level; line_item and fulfillment_option reference
total_line.json directly (no tightening, display_text stays optional).
Array cardinality (top-level allOf): uses contains/minContains/maxContains
to enforce exactly one subtotal and exactly one total entry. All other
types are unconstrained — they may appear zero or more times.
This separation means the base entry type (total_line.json) stays simple
and unchanged for sub-level consumers, while checkout/cart/order get the
full receipt-printer contract through composition.
Backward compatibility:
- Additive: effect field (optional, defaults for well-known types), lines
array (optional), totals.json constrained array type (new file).
- Tightening: display_text required at checkout/cart/order level (every
existing implementation includes it). Cardinality constraints on subtotal
and total (makes implicit contract explicit).
- Relaxation: type enum opened to string — all existing values remain
valid, schema now additionally accepts custom types.
|
The direction makes sense to me. Keep in mind #254 encourages positive/negative integers and not the |
Replace the effect field (add/subtract) with signed integers on totals entries. Discounts and credits are negative; charges are positive. The sign IS the direction — no separate field needed.
Add if/then constraints on the totals array items schema to enforce
sign at validation time:
discount, items_discount → must be negative (exclusiveMaximum: 0)
subtotal, fulfillment,
tax, fee → must be non-negative (minimum: 0)
total → unconstrained
unknown types → unconstrained (sign is self-describing)
A discount with a positive amount or a fee with a negative amount is
now a schema validation error — caught before rendering, not at
runtime via sum-equals-total.
Revert total.json → total_line.json rename to avoid breaking the public $id contract. The canonical URI https://ucp.dev/schemas/shopping/types/total.json is unchanged. total.json remains the base entry type. The new totals.json (constrained array) references it via $ref. All consumer schemas (line_item, order_line_item, fulfillment_option) and doc macros restored to original references.
Move signed_amount.json ref from total.json (base type) into totals.json (checkout/cart/order array composition). The base type reverts to unsigned amount.json. Line item, order line item, and fulfillment option totals inherit the base type — their amounts stay non-negative. Signed amounts, sign enforcement (if/then), and display_text required are all co-located on totals.json where the receipt-printer contract lives.
Well-known totals types (subtotal, tax, fee, etc.) have default display labels documented in the spec. Businesses SHOULD provide display_text to override, but omitting it on well-known types is valid — platforms fall back to the default label. Unknown types MUST include display_text (schema-enforced via if/then). The platform cannot infer a label for a type it has never seen. This preserves backward compatibility with existing examples that omit display_text on well-known types while enforcing labels on the custom types (fees, account credits) that motivated the totals contract work.
|
@maximenajim great feedback, updated: #261 (commits) |
ACSchil
left a comment
There was a problem hiding this comment.
But displaying accurate, itemized totals to the buyer is not optional functionality. We should not force a dependency on an extension for something the business is obligated to present
💯 This is the right take!
Great improvement that formalizes the expectations around totals, and solves #219
|
@igrigorik thanks for putting this together. This PR covers ~80% of what #245 was trying to solve, and does it at the right layer (base contract vs. extension). I'd like to propose a path that lets #261 absorb the remaining #245 concerns with minimal additions, then close #245. What #245 has that #261 is missing
@lemonmade raised exactly this gap (#261 (comment)) — line_items has parent_id for structure, but totals entries have no identity at all.
|
…unt override Align with the composition pattern in PR #261 (totals contract): allOf total.json base + signed_amount.json override, rather than inlining the schema.
Tighten verification contract for agentic flows. If sum of entries does not match the total, platforms must not auto-complete — reject or escalate to buyer review via continue_url.
Update discount.md examples to use negative amounts for discount and items_discount totals entries, consistent with the signed totals contract.
|
@maximenajim hmm..
Practically, doesn't
What's the rendering expectation for a description? Are these optional strings that should (must?) be shown by the platform rendering the fee line? Do they need to rendered in proximity and permanently visible / shown as tooltips? I can guess these, but curious what contract and expectations you have in mind.
Why is this a concern for the platform whose job is to render the provided line? It does not do, and should not do, any tax accounting. The entire contract is that platform renders provided lines and optionally sums the lines to verify -- nothing more. Is there a requirement to visually distinguish taxable vs non-taxable lines and if so can it be captured in the label? |
Clarify that applied[].amount describes the magnitude of the discount (positive); the corresponding totals entry represents its signed effect on the receipt (negative).
|
@igrigorik Good points across the board. A few reactions: On id — I think you're right that {type, display_text} might be sufficient as a de facto identifier for rendering purposes. With repeated types explicitly allowed, and display_text now optional for well-known types with fallback labels, {type, display_text} is presentation data, not a protocol-stable identifier. It is not guaranteed unique, and binding machine logic to buyer-facing copy is brittle. UCP already uses opaque identifiers for structural identity elsewhere — line items require id and may carry parent_id. I’d scope id narrowly here too: optional, opaque, session-local, and with zero rendering semantics. I'd be fine deferring id — if a concrete need surfaces (e.g., an extension that needs to cross-reference specific totals entries), it can be added as an optional field later without breaking anything. On description — The question about rendering contract is the right one to ask. Without a clear MUST/SHOULD/MAY for how platforms surface it, we'd be adding a field that different platforms treat inconsistently — some show it, some ignore it, some put it in a tooltip. If there's a concrete regulatory requirement where the label alone isn't sufficient (and I think there are — e.g., California's mandatory fee disclosure language), then description should come with an explicit rendering contract (SHOULD render in proximity to the line, visible without interaction). But that's a well-scoped follow-up, not a blocker for this PR. On taxable — Agree this doesn't belong on the presentation layer. The receipt-printer contract is intentionally "render what you're given." Whether a fee is taxable is upstream business logic — the business has already computed the tax amount and included it in the totals. If a platform needs taxability metadata for its own verification or tax-exempt flows, that's a companion extension concern, not a totals field. Overall, this PR is in great shape. The core contract is solid and the remaining proposals from #245 can land as targeted follow-ups if/when concrete use cases demand them. 👍 |
Related context: #219 and #245
On first pass, fees-as-extension seemed "right" to me, but reviewing discussion on the PR led me into a dead end. If fees are modeled as an extension, a business in a jurisdiction that mandates itemized fee disclosure (e.g., state mandated electronics recycling fees), would have to escalate via
continue_urlwhen the platform doesn't advertise support for the Fee Extension — the platform simply can't render what the business is obligated to show. The same problem applies to taxes: some countries/regions require display of separate tax lines at checkout. This is a repeating pattern and problem.This is a non-starter. You can conduct commerce without negotiating discounts — absence of ability to negotiate a discount does not "block" checkout from proceeding. But displaying accurate, itemized totals to the buyer is not optional functionality. We should not force a dependency on an extension for something the business is obligated to present, and I think our existing contract is basically there... modulo a few edits and upgrades.
Claim: fees and taxes must live on
totals[];totals[]must support repeating types. (and it does, but underspecified)This draft is formalizing the totals contract: the business dictates what must be rendered — content and order — because the correct presentation is subject to regional, product, and regulatory requirements that the business is obligated to satisfy. The platform renders what it's given. The contract is simple: iterate, display
display_text+amountfor each entry. The platform is a receipt printer, it does not and must not enforce own display logic -- this makes it universal, because business dictates the presentation and can meet its obligations.What this contract enables
type: "fee"entries, or collapsed withlinesbreakdownaccount_credit,tip,surcharge— opentypestring with expliciteffectfieldlinesarray for progressive enhancement — business controls what MUST be rendered (top-level) vs what MAY be optionally surfaced (sub-lines)subtotaland onetotal(schema-enforced)effect: "add"/"subtract"— well-known types have implicit defaults, custom types must include itExamples
Split tax, itemized at top-level:
Collapsed fees with optional breakdown:
Account credit — custom type with explicit
effect:Schema enforcement for cardinality
The array has two layers of constraints, each using allOf:
total_line.jsonentry with checkout-level requirements —display_textbecomes required, and theeffectandlinesfields are added. This tightening only applies at checkout/cart/order level.contains/minContains/maxContainsto enforce exactly one subtotal and exactly one total entry. All other types are unconstrained — they may appear zero or more times.Backward compatibility
effectfield (optional, well-known types have defaults),linesarray (optional).display_textrequired at checkout/cart/order level.typeenum opened to string — all existing values remain valid, schema additionally accepts custom types.