Skip to content

feat: formalize totals contract#261

Merged
amithanda merged 12 commits intomainfrom
feat/totals-contract
Mar 17, 2026
Merged

feat: formalize totals contract#261
amithanda merged 12 commits intomainfrom
feat/totals-contract

Conversation

@igrigorik
Copy link
Contributor

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_url when 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 + amount for 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

  • Itemized fees: multiple type: "fee" entries, or collapsed with lines breakdown
  • Multi-jurisdiction tax: separate Federal Tax / State Tax entries
  • Custom types: account_credit, tip, surcharge — open type string with explicit effect field
  • Sub-line breakdown: lines array for progressive enhancement — business controls what MUST be rendered (top-level) vs what MAY be optionally surfaced (sub-lines)
  • Cardinality enforcement: exactly one subtotal and one total (schema-enforced)
  • Self-describing sign convention: effect: "add" / "subtract" — well-known types have implicit defaults, custom types must include it

Examples

Split tax, itemized at top-level:

"totals": [
  { "type": "subtotal",    "display_text": "Subtotal",      "amount": 5750 },
  { "type": "fulfillment", "display_text": "Shipping",      "amount": 899 },
  { "type": "tax",         "display_text": "Federal Tax",   "amount": 332 },    // <--
  { "type": "tax",         "display_text": "State Tax",     "amount": 465 },    // <--
  { "type": "total",       "display_text": "Total",         "amount": 7446 }
]

Collapsed fees with optional breakdown:

"totals": [
  { "type": "subtotal", "display_text": "Subtotal", "amount": 4999 },
  {
    "type": "fee", "display_text": "Fees", "amount": 549,
    "lines": [              // <--
      { "display_text": "Service Fee", "amount": 399 },
      { "display_text": "Recycling Fee", "amount": 150 }
    ]
  },
  { "type": "tax",   "display_text": "Tax",   "amount": 444 },
  { "type": "total", "display_text": "Total", "amount": 5992 }
]

Account credit — custom type with explicit effect:

"totals": [
  { "type": "subtotal",     "display_text": "Subtotal",     "amount": 10000 },
  { "type": "tax",          "display_text": "Tax",          "amount": 800 },
  { "type": "account_credit", "display_text": "Account Credit", "amount": 2500, "effect": "subtract" }, // <--
  { "type": "total",        "display_text": "Amount Due",   "amount": 8300 }
]

Schema enforcement for cardinality

The array has two layers of constraints, each using allOf:

  1. 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.
  2. 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.

Backward compatibility

  • Additive: effect field (optional, well-known types have defaults), lines array (optional).
  • Tightening:
    • display_text required at checkout/cart/order level.
    • Cardinality constraints on subtotal and total makes implicit contract explicit in schema.
  • Relaxed: type enum opened to string — all existing values remain valid, schema additionally accepts custom types.

  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.
@richmolj
Copy link
Contributor

The direction makes sense to me. Keep in mind #254 encourages positive/negative integers and not the effect strategy here. I prefer the former, but either way let's make sure we are consistent.

  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.
@igrigorik igrigorik added the TC review Ready for TC review label Mar 16, 2026
@igrigorik
Copy link
Contributor Author

@richmolj hmm, that does make things a lot simpler: 6fd2c53, aba5943.

@igrigorik igrigorik mentioned this pull request Mar 16, 2026
10 tasks
  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.
@igrigorik
Copy link
Contributor Author

@maximenajim great feedback, updated: #261 (commits)

@igrigorik igrigorik marked this pull request as ready for review March 16, 2026 20:34
@igrigorik igrigorik requested review from a team as code owners March 16, 2026 20:34
@igrigorik igrigorik requested review from mmohades and yanheChen March 16, 2026 20:34
Copy link
Contributor

@ACSchil ACSchil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@maximenajim
Copy link

@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

  1. id (string, optional) - Stable identifier for a totals entry within a session. Without it, there's no reliable way to:
  • Track which fee/tax/charge changed between checkout updates (reconciliation)
  • Join totals entries to detail in companion extensions
  • Build accessible UIs that maintain focus/identity across re-renders

@lemonmade raised exactly this gap (#261 (comment)) — line_items has parent_id for structure, but totals entries have no identity at all.
{ "type": "fee", "id": "fee-recycling-1", "display_text": "Recycling Fee", "amount": 150 }

  1. description (string, optional)
    A plain-text explanation of why a charge exists — distinct from display_text (the label). Critical for fees and regulatory charges where the buyer needs to understand the reason, not just the name.
{
  "type": "fee",
  "display_text": "Recycling Fee",
  "description": "Required by state law for electronic waste disposal",
  "amount": 150
}
  1. taxable (boolean, optional, default false)
    Whether this entry is subject to tax. Relevant for fees (recycling fees are often taxable, service fees often aren't) and fulfillment (shipping is taxable in some jurisdictions). Tax engines and verification flows need this.
    { "type": "fee", "category": "service", "display_text": "Service Fee", "amount": 399, "taxable": true }

richmolj added a commit that referenced this pull request Mar 16, 2026
…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.
@igrigorik
Copy link
Contributor Author

@maximenajim hmm..

id (string, optional) - Stable identifier for a totals entry within a session.

Practically, doesn't {type, display_text} satisfy all of these use case already? The display_text IS the buyer-facing identifier — it's stable within a session, human-readable, and targetable via JSONPath: $.totals[?@.display_text=='Recycling Fee']. Do we really need ID as another identifier?

description (string, optional)
A plain-text explanation of why a charge exists — distinct from display_text (the label). Critical for fees and regulatory charges where the buyer needs to understand the reason, not just the name.

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.

taxable (boolean, optional, default false)
Whether this entry is subject to tax. Relevant for fees (recycling fees are often taxable, service fees often aren't) and fulfillment (shipping is taxable in some jurisdictions). Tax engines and verification flows need this.
{ "type": "fee", "category": "service", "display_text": "Service Fee", "amount": 399, "taxable": true }

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).
@amithanda amithanda merged commit f2062ef into main Mar 17, 2026
6 checks passed
@amithanda amithanda deleted the feat/totals-contract branch March 17, 2026 12:45
@maximenajim
Copy link

@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. 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants