Skip to content

feat(shopping): add Fee Extension for checkout and cart (Option B)#245

Open
maximenajim wants to merge 1 commit intomainfrom
feat/fee-extension
Open

feat(shopping): add Fee Extension for checkout and cart (Option B)#245
maximenajim wants to merge 1 commit intomainfrom
feat/fee-extension

Conversation

@maximenajim
Copy link

Summary

Implements the Fee Extension (dev.ucp.shopping.fee) per Issue #219 (Option B), enabling businesses to surface itemized fees on checkout sessions and carts.

Key design decisions

  • Shared allocation type: Extracts types/allocation.json as a reusable type referenced by both discount and fee extensions — eliminates cross-extension coupling (fee no longer depends on discount's $defs)
  • fee_type required: Fees without a type are ambiguous for platform rendering/categorization
  • exclusiveMinimum: 0: Zero-amount fees are not permitted — waived fees are omitted from the array entirely
  • additionalProperties: false on fee type for strict validation
  • readOnly: true on fees.applied for generic tooling/codegen compatibility
  • $ref to amount.json on all monetary fields to preserve the canonical amount type chain
  • RFC 9535 JSONPath dialect explicitly named in allocation path descriptions
  • Conditional language in total.json descriptions ("When the Fee Extension is present…")

Files changed (8)

New (4):

  • source/schemas/shopping/types/allocation.json — Shared allocation type
  • source/schemas/shopping/types/fee.json — Individual fee schema
  • source/schemas/shopping/fee.json — Fee Extension schema (checkout + cart)
  • docs/specification/fee.md — Full specification page

Modified (4):

  • source/schemas/shopping/discount.json — Allocation now $refs shared type
  • source/schemas/shopping/types/total.json — Conditional fee language in descriptions
  • mkdocs.yml — Nav + llmstxt entries
  • .cspell/custom-words.txt — New terms

Spec highlights

  • Mismatch handling: platforms MUST NOT complete checkout if totals[type=fee] diverges from sum(fees.applied[].amount)
  • Receiver rejection: business receivers MUST reject (not ignore) client-supplied fee fields
  • Sanitization: title/description/display_text are plain text — renderers MUST escape
  • Waived fees: omitted from array (not zero-amount entries)

Closes #219
Related: #220

@google-cla
Copy link

google-cla bot commented Mar 9, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Implement structured fee support via dev.ucp.shopping.fee extension, enabling businesses to surface itemized fees (service, handling, recycling, etc.) with allocation breakdowns, taxability, and waivability metadata. Extracts shared allocation type to decouple fee and discount extensions. Closes #219.
@maximenajim maximenajim added the TC review Ready for TC review label Mar 9, 2026
Comment on lines +67 to +70
!!! note "Partial adoption"
A business MAY support fees on checkout only, cart only, or both. Each
`extends` entry is independent. Platforms should check which base
capabilities the fee extension extends before expecting `fees` in responses.
Copy link

Choose a reason for hiding this comment

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

I'm curious if this needs to be in docs/specification/overview.md, as this should be true for all extensions, correct?

Comment on lines +97 to +105
| Total Type | Description |
| ---------------- | ------------------------------------------------------------- |
| `subtotal` | Sum of line item prices before any adjustments |
| `items_discount` | Discounts allocated to line items |
| `discount` | Order-level discounts (shipping, flat amount) |
| `fulfillment` | Shipping, delivery, or pickup costs |
| `tax` | Tax amount |
| `fee` | **Single aggregated fee total** — sum of all `fees.applied[]` |
| `total` | Grand total: `subtotal - discount + fulfillment + tax + fee` |
Copy link

Choose a reason for hiding this comment

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

Should this table be here under docs/specification/fee.md or should fee.md just call out the new total type? I ask because it could make the docs harder to maintain if every extension that affects totals recreates the totals table (e.g. Discount also could have this table).

| `discount` | Order-level discounts (shipping, flat amount) |
| `fulfillment` | Shipping, delivery, or pickup costs |
| `tax` | Tax amount |
| `fee` | **Single aggregated fee total** — sum of all `fees.applied[]` |
Copy link

Choose a reason for hiding this comment

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

This language is helpful, and we should consider being this crisp with the other total types.

Businesses MUST ensure this invariant holds. If a platform detects a mismatch
between the aggregated fee total and the sum of itemized fees, the platform
SHOULD treat the response as invalid and MUST NOT complete the checkout without
surfacing the discrepancy to the user.
Copy link

Choose a reason for hiding this comment

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

Even if the discrepancy is surfaced, is it appropriate for the platform to proceed? Should the Platform use the continue_url is such a case? If yes, should the continue_url be a required field? Currently, it doesn't seem like we have a good way for platforms to handle these cases.

SHOULD treat the response as invalid and MUST NOT complete the checkout without
surfacing the discrepancy to the user.

!!! note "When the Fee Extension is absent"
Copy link

Choose a reason for hiding this comment

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

This still feels a bit ambiguous. Does this note deserve the same normative language as the Invariant above? That is, "totals[type=fee] MUST represent the aggregated fee total"; or alternatively, something about there MAY be n number of totals[type=fee] objects in totals (the question that sparked issue #219 ).

},
"amount": {
"$ref": "amount.json",
"description": "Amount allocated to this target in ISO 4217 minor units."
Copy link

Choose a reason for hiding this comment

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

We should adopt this across the spec. In other places we are simply saying "minor units (cents)" which is inadequate for all currencies.

"properties": {
"id": {
"type": "string",
"description": "Unique fee identifier. Unlike applied discounts, fees always require an id to enable stable referencing across updates."
Copy link

Choose a reason for hiding this comment

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

Are fee ids expected to be consistent across checkout sessions? Or just across requests within a checkout session or cart?

},
"description": {
"type": "string",
"description": "Optional explanation of why the fee is charged."
Copy link

Choose a reason for hiding this comment

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

Also human-readable, I assume. As with conversations around disclosure, I wonder how Platforms should navigate Businesses potentially wanting richer text, or even images.

Comment on lines +129 to +136
| `service` | General service fee for order processing | Platform service fee |
| `handling` | Physical handling and packaging of goods | Oversized item handling fee |
| `recycling` | Disposal or recycling of materials | Electronics recycling fee |
| `processing` | Payment or order processing surcharge | Credit card processing fee |
| `regulatory` | Government-mandated fee or compliance charge | Mattress recycling surcharge |
| `convenience` | Fee for using a particular ordering channel | Online ordering convenience fee |
| `restocking` | Fee for processing returns or exchanges | Return restocking fee |
| `environmental` | Environmental impact or sustainability surcharge | Carbon offset fee |
Copy link

Choose a reason for hiding this comment

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

What is the value of this spec defining types, especially so many of them? Does a Platform need to know the various types?


3. **Positive amounts only:** All fee amounts use `exclusiveMinimum: 0`
zero-amount fees are not permitted. If a fee does not apply, it MUST be
omitted from the `applied` array entirely. This includes waived fees: when a
Copy link

Choose a reason for hiding this comment

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

Is there perhaps a need to communicate to the Platform / User that a fee was waived? If we do not allow zero-amount fees, how should this be communicated in the response?

operations, meaning platforms never send fee data in requests — fees are
determined entirely by the business and returned in responses.

Business receivers MUST reject any `fees` fields provided by platforms in
Copy link

Choose a reason for hiding this comment

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

Shall we add an error code to source/schemas/shopping/types/error_code.json for this?

"amount": 500,
"fee_type": "recycling",
"taxable": true,
"description": "State-mandated electronics recycling fee."
Copy link

Choose a reason for hiding this comment

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

This made me think of #222 . Is a plain-text description adequate, or are there fees that may need to be more expressive; perhaps we handle those with disclosures?

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.

Clarify itemized fee support in totals[]

2 participants