diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index 1865c84f..2e96f95b 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -89,17 +89,22 @@ pymdownx reactiva renderable repudiable +restocking schemas sdjwt shopify streamable superfences +surcharge +surcharges talla tracción upsell upsells variante vulnz +waivability +waivable worktree yaml zapatillas diff --git a/docs/specification/fee.md b/docs/specification/fee.md new file mode 100644 index 00000000..49cb2342 --- /dev/null +++ b/docs/specification/fee.md @@ -0,0 +1,624 @@ + + +# Fee Extension + +## Overview + +The Fee Extension allows businesses to surface itemized fees on checkout +sessions and carts, giving platforms and agents full visibility into surcharges +such as service fees, handling charges, regulatory fees, and other additional +costs beyond the item subtotal. + +**Key features:** + +- Itemized fees with human-readable titles and descriptions +- Typed fees with an open `fee_type` string for extensibility +- Allocation breakdowns showing how fees are distributed across line items +- Taxability and waivability metadata per fee +- Supported on both Checkout and Cart + +**Dependencies:** + +- Checkout Capability and/or Cart Capability + +## Discovery + +Businesses advertise fee support in their profile. The `extends` field uses the +multi-parent array form to declare which base capabilities the fee extension +augments: + +```json +{ + "ucp": { + "version": "2026-01-11", + "capabilities": { + "dev.ucp.shopping.fee": [ + { + "version": "2026-01-11", + "extends": ["dev.ucp.shopping.checkout", "dev.ucp.shopping.cart"], + "spec": "https://ucp.dev/specification/fee", + "schema": "https://ucp.dev/schemas/shopping/fee.json" + } + ] + } + } +} +``` + +!!! note "Partial adoption" + A business MAY support fees on checkout only, cart only, or both. When + extending only one parent, use a single-element array or a plain string. + Platforms should check which base capabilities the fee extension extends + before expecting `fees` in responses. + +!!! tip "Fee disclosure timing" + To avoid late-stage price surprises and ensure a stable state machine + across the transaction lifecycle: + + 1. **Early disclosure:** A business SHOULD include fees at the cart level + whenever the fee criteria (e.g., item types or subtotal thresholds) are + known. Fees SHOULD NOT be deferred to checkout unless they are strictly + dependent on data only available at that stage (e.g., payment method + surcharges). + + 2. **Disclosure continuity:** A business SHOULD include fees at checkout if + they were provided at the cart level to ensure continuity of disclosure + granularity and avoid a "lossy" state transition. + + This ensures the merchant-controlled portion of the cost is deterministic + as early as possible and remains consistent throughout the transaction + lifecycle. + +## Schema + +When this capability is active, checkout and/or cart responses are extended with +a `fees` object. + +### Fees Object + +{{ extension_schema_fields('fee.json#/$defs/fees_object', 'fee') }} + +### Fee + +{{ schema_fields('types/fee', 'fee') }} + +### Allocation + +{{ schema_fields('types/allocation', 'fee') }} + +## Fee Semantics + +### Relationship to Totals + +Fees surface in two places: the `fees.applied` array provides the itemized +breakdown, while `totals[]` contains a single aggregated entry that rolls all +fees into the order total calculation. + +| 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` | + +**Invariant:** `totals[type=fee].amount` equals `sum(fees.applied[].amount)`. +Businesses MUST ensure this invariant holds. If a platform detects a mismatch +between the aggregated fee total and the sum of itemized fees: + +- If `totals[type=fee].amount` **exceeds** `sum(fees.applied[].amount)`, the + platform MUST treat this as an error — the user would otherwise pay + unspecified fees through an intermediary, complicating dispute resolution. + Platforms MUST NOT complete the checkout in this case and SHOULD use + `continue_url` to hand off to the business UI for resolution. + +- If `totals[type=fee].amount` is **less than** `sum(fees.applied[].amount)`, + the platform SHOULD surface the discrepancy to the user but MAY proceed + with caution, as the user is not being overcharged. + +!!! note "When the Fee Extension is absent" + When the Fee Extension is present, there MUST be at most one `totals[]` + entry with `type: "fee"`, and its `amount` MUST equal + `sum(fees.applied[].amount)`. When the Fee Extension is not advertised, + the interpretation of any `totals[type=fee]` entry is business-defined — + platforms SHOULD render it using `display_text` but MUST NOT assume + itemized fee data is available. + +### Fee Types + +The `fee_type` field is an open string. Businesses MAY use any value, including +custom types not listed below. Platforms SHOULD handle unknown values gracefully +by displaying the fee's `title` to the user. + +**Well-known values:** + +| Fee Type | Description | Example | +| --------------- | ------------------------------------------------ | ---------------------------------- | +| `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 | +| `environmental` | Environmental impact or sustainability surcharge | Carbon offset fee | + +### Why `id` Is Required + +Unlike applied discounts — where `code` serves as a natural key and automatic +discounts may not need a stable identifier — fees are entirely +business-determined and read-only. Platforms need a reliable way to reference +individual fees across checkout updates (e.g., to track which fees changed +between updates or to display consistent UI elements). Therefore, `id` is +required on every fee. + +Fee IDs are scoped to a single checkout or cart session. The same fee retains +its `id` across requests within a session (create → update → complete), but the +`id` is not guaranteed to be consistent across separate sessions. Businesses +control ID generation. + +## Multiple Fees + +A checkout or cart may include multiple fees. The following invariants hold: + +1. **Single aggregated total:** There SHOULD be exactly one `totals[]` entry + with `type: "fee"` whose `amount` equals the sum of all `fees.applied[]` + amounts. + +2. **Allocation sums:** When a fee includes `allocations`, the sum of + `allocations[].amount` MUST equal the fee's `amount`. + +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 + fee marked `waivable: true` is actually waived, the business omits it rather + than including a zero-amount entry. + +4. **Communicating waived fees:** To inform the platform and user that a fee was + waived, businesses MAY include a `messages[]` entry with `type: "info"`: + + ```json + { + "messages": [ + { + "type": "info", + "code": "fee_waived", + "content": "Service Fee waived for loyalty members." + } + ] + } + ``` + +## Operations + +Fees are fully read-only. The `fees` object uses `ucp_request: "omit"` for all +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 +requests. Because fees directly affect money movement, silently ignoring +client-supplied fee data is insufficient — an explicit error response prevents +parameter-smuggling attacks where a platform attempts to influence fee amounts. +Businesses SHOULD use the `readonly_field_not_allowed` error code when rejecting +such requests. + +**Checkout operations:** + +| Operation | `fees` in Request | `fees` in Response | +| ---------- | ----------------- | ------------------ | +| `create` | Omit | Present | +| `update` | Omit | Present | +| `complete` | Omit | Present | + +**Cart operations:** + +| Operation | `fees` in Request | `fees` in Response | +| --------- | ----------------- | ------------------ | +| `create` | Omit | Present | +| `update` | Omit | Present | + +!!! note "Cart vs. Checkout" + Cart has no `complete` operation (carts are converted to checkouts before + completion), so only `create` and `update` are specified as "omit". + +## Rendering Guidance + +Platforms SHOULD display each fee as a separate line item in the order summary, +using the fee's `title` for display. + +!!! warning "Sanitization" + Fee `title`, `description`, and totals `display_text` fields are plain text. + Renderers MUST escape or sanitize these values before inserting them into + HTML, logs, or other output contexts to prevent markup injection or XSS. + +Example text rendering: + +```text +Order Summary +───────────────────────────── + Subtotal $120.00 + Shipping $8.99 + Service Fee $3.99 + Recycling Fee $1.50 + Tax $9.60 + ───────────────────────── + Total $144.08 +``` + +When a fee includes a `description`, platforms MAY display it as supplementary +text (e.g., a tooltip or fine print) to help users understand why the fee is +charged. The `description` field is plain text — for richer content such as +regulatory disclosures, images, or formatted copy, businesses should use the +Disclosures capability (see [#222](https://github.com/Universal-Commerce-Protocol/ucp/issues/222)) +when available. + +If a fee has `waivable: true`, platforms MAY indicate this to the user (e.g., +"This fee is waived for members"). + +## Calculation Formula + +The grand total follows the standard UCP calculation: + +```text +total = subtotal - discount + fulfillment + tax + fee +``` + +Where `fee` is the single aggregated fee total amount from `totals[type=fee]`. + +## Multi-Level Fee Support + +Fees can apply at different levels of an order, expressed through the +`allocations` array. + +### Checkout-Level Fees + +Fees without allocations apply to the order as a whole: + +```json +{ + "id": "fee_svc_1", + "title": "Service Fee", + "amount": 399, + "fee_type": "service" +} +``` + +### Line-Item-Level Fees + +Fees allocated to specific line items use JSONPath in `allocations`: + +```json +{ + "id": "fee_recycle_1", + "title": "Recycling Fee", + "amount": 300, + "fee_type": "recycling", + "allocations": [ + { "path": "$.line_items[0]", "amount": 150 }, + { "path": "$.line_items[1]", "amount": 150 } + ] +} +``` + +### Fulfillment-Option-Level Fees + +Fees allocated to fulfillment options: + +```json +{ + "id": "fee_handling_1", + "title": "Oversized Handling Fee", + "amount": 1500, + "fee_type": "handling", + "allocations": [ + { "path": "$.fulfillment.options[0]", "amount": 1500 } + ] +} +``` + +## Interaction with Discounts + +The Fee Extension and Discount Extension are independent extensions that can +coexist on the same checkout or cart. Key rules: + +- **Independent calculation:** Fees and discounts are calculated independently. + A discount does not reduce fees, and a fee does not increase the discount + base, unless the business explicitly structures it that way. + +- **Separate totals entries:** Fees appear in `totals[type=fee]` and discounts + in `totals[type=discount]` (or `totals[type=items_discount]`). They are never + combined into a single entry. + +- **Waivable fees:** The `waivable` flag indicates a fee *can* be waived under + certain conditions (e.g., membership tier, promotional period). The waivability + is informational — when a fee is actually waived, the business simply omits it + from the `fees.applied` array. The flag helps platforms communicate potential + savings to users. + +- **Grand total formula remains the same:** + `total = subtotal - discount + fulfillment + tax + fee` + +## Examples + +### Simple Service Fee + +A single service fee with no allocations. + +**Request:** + +```json +{ + "line_items": [ + { + "item": { + "id": "prod_1", + "quantity": 1 + } + } + ] +} +``` + +**Response:** + +```json +{ + "id": "chk_abc123", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "prod_1", + "quantity": 1, + "title": "Wireless Headphones", + "price": 7999 + } + } + ], + "fees": { + "applied": [ + { + "id": "fee_svc_1", + "title": "Service Fee", + "amount": 399, + "fee_type": "service" + } + ] + }, + "totals": [ + { "type": "subtotal", "display_text": "Subtotal", "amount": 7999 }, + { "type": "fee", "display_text": "Service Fee", "amount": 399 }, + { "type": "tax", "display_text": "Tax", "amount": 672 }, + { "type": "total", "display_text": "Total", "amount": 9070 } + ] +} +``` + +### Multiple Fees + +Service fee and recycling fee, with a single aggregated `totals[type=fee]` +entry. + +**Request:** + +```json +{ + "line_items": [ + { + "item": { + "id": "prod_tv", + "quantity": 1 + } + } + ] +} +``` + +**Response:** + +```json +{ + "id": "chk_def456", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "prod_tv", + "quantity": 1, + "title": "55\" 4K Television", + "price": 49999 + } + } + ], + "fees": { + "applied": [ + { + "id": "fee_svc_1", + "title": "Service Fee", + "amount": 999, + "fee_type": "service" + }, + { + "id": "fee_recycle_1", + "title": "Electronics Recycling Fee", + "amount": 500, + "fee_type": "recycling", + "taxable": true, + "description": "State-mandated electronics recycling fee." + } + ] + }, + "totals": [ + { "type": "subtotal", "display_text": "Subtotal", "amount": 49999 }, + { "type": "fulfillment", "display_text": "Shipping", "amount": 0 }, + { "type": "fee", "display_text": "Fees", "amount": 1499 }, + { "type": "tax", "display_text": "Tax", "amount": 4080 }, + { "type": "total", "display_text": "Total", "amount": 55578 } + ] +} +``` + +### Fee with Allocations + +A recycling fee split across two line items. + +**Request:** + +```json +{ + "line_items": [ + { + "item": { + "id": "prod_battery_1", + "quantity": 2 + } + }, + { + "item": { + "id": "prod_battery_2", + "quantity": 1 + } + } + ] +} +``` + +**Response:** + +```json +{ + "id": "chk_ghi789", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "prod_battery_1", + "quantity": 2, + "title": "AA Battery Pack (8ct)", + "price": 899 + } + }, + { + "id": "li_2", + "item": { + "id": "prod_battery_2", + "quantity": 1, + "title": "9V Battery Pack (4ct)", + "price": 1299 + } + } + ], + "fees": { + "applied": [ + { + "id": "fee_recycle_1", + "title": "Battery Recycling Fee", + "amount": 150, + "fee_type": "recycling", + "taxable": true, + "allocations": [ + { "path": "$.line_items[0]", "amount": 100 }, + { "path": "$.line_items[1]", "amount": 50 } + ] + } + ] + }, + "totals": [ + { "type": "subtotal", "display_text": "Subtotal", "amount": 3097 }, + { "type": "fee", "display_text": "Recycling Fee", "amount": 150 }, + { "type": "tax", "display_text": "Tax", "amount": 260 }, + { "type": "total", "display_text": "Total", "amount": 3507 } + ] +} +``` + +### Fees in Cart + +A convenience fee applied to a cart response. + +**Request:** + +```json +{ + "line_items": [ + { + "item": { + "id": "prod_pizza", + "quantity": 2 + } + }, + { + "item": { + "id": "prod_soda", + "quantity": 3 + } + } + ] +} +``` + +**Response:** + +```json +{ + "id": "cart_jkl012", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "prod_pizza", + "quantity": 2, + "title": "Large Pepperoni Pizza", + "price": 1499 + } + }, + { + "id": "li_2", + "item": { + "id": "prod_soda", + "quantity": 3, + "title": "Cola (2L)", + "price": 299 + } + } + ], + "currency": "USD", + "fees": { + "applied": [ + { + "id": "fee_conv_1", + "title": "Online Ordering Fee", + "amount": 299, + "fee_type": "convenience", + "waivable": true, + "description": "Waived for loyalty members." + } + ] + }, + "totals": [ + { "type": "subtotal", "display_text": "Subtotal", "amount": 3895 }, + { "type": "fee", "display_text": "Online Ordering Fee", "amount": 299 }, + { "type": "total", "display_text": "Estimated Total", "amount": 4194 } + ] +} +``` + +!!! note "Read-only" + The request does not include `fees` in any of the above examples. This + demonstrates the read-only nature of the fee extension — fees are determined + entirely by the business and returned in responses. diff --git a/mkdocs.yml b/mkdocs.yml index b387bbee..ccadbd6e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - AP2 Mandates Extension: specification/ap2-mandates.md - Buyer Consent Extension: specification/buyer-consent.md - Discounts Extension: specification/discount.md + - Fee Extension: specification/fee.md - Fulfillment Extension: specification/fulfillment.md - Cart Capability: - Overview: specification/cart.md @@ -221,6 +222,7 @@ plugins: - specification/ap2-mandates.md - specification/buyer-consent.md - specification/discount.md + - specification/fee.md - specification/fulfillment.md - specification/cart.md - specification/cart-rest.md diff --git a/source/schemas/shopping/discount.json b/source/schemas/shopping/discount.json index f74869f6..d0e30e85 100644 --- a/source/schemas/shopping/discount.json +++ b/source/schemas/shopping/discount.json @@ -6,22 +6,7 @@ "description": "Extends Checkout with discount code support, enabling agents to apply promotional, loyalty, referral, and other discount codes.", "$defs": { "allocation": { - "type": "object", - "description": "Breakdown of how a discount amount was allocated to a specific target.", - "required": [ - "path", - "amount" - ], - "properties": { - "path": { - "type": "string", - "description": "JSONPath to the allocation target (e.g., '$.line_items[0]', '$.totals.shipping')." - }, - "amount": { - "$ref": "types/amount.json", - "description": "Amount allocated to this target in ISO 4217 minor units." - } - } + "$ref": "types/allocation.json" }, "applied_discount": { "type": "object", diff --git a/source/schemas/shopping/fee.json b/source/schemas/shopping/fee.json new file mode 100644 index 00000000..d784a5ce --- /dev/null +++ b/source/schemas/shopping/fee.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/fee.json", + "name": "dev.ucp.shopping.fee", + "title": "Fee Extension", + "description": "Extends Checkout and Cart with itemized fee support, enabling businesses to surface service fees, handling charges, regulatory fees, and other surcharges.", + "$defs": { + "fees_object": { + "type": "object", + "description": "Container for itemized fees applied to the order.", + "properties": { + "applied": { + "type": "array", + "readOnly": true, + "items": { + "$ref": "types/fee.json" + }, + "description": "Fees applied to this checkout or cart. Business-determined; not settable by platforms." + } + } + }, + "dev.ucp.shopping.checkout": { + "title": "Checkout with Fees", + "description": "Checkout extended with itemized fee support.", + "allOf": [ + { + "$ref": "checkout.json" + }, + { + "type": "object", + "properties": { + "fees": { + "$ref": "#/$defs/fees_object", + "ucp_request": { + "create": "omit", + "update": "omit", + "complete": "omit" + } + } + } + } + ] + }, + "dev.ucp.shopping.cart": { + "title": "Cart with Fees", + "description": "Cart extended with itemized fee support.", + "allOf": [ + { + "$ref": "cart.json" + }, + { + "type": "object", + "properties": { + "fees": { + "$ref": "#/$defs/fees_object", + "ucp_request": { + "create": "omit", + "update": "omit" + } + } + } + } + ] + } + } +} diff --git a/source/schemas/shopping/types/allocation.json b/source/schemas/shopping/types/allocation.json new file mode 100644 index 00000000..353c493f --- /dev/null +++ b/source/schemas/shopping/types/allocation.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/allocation.json", + "title": "Allocation", + "description": "Breakdown of how an amount was allocated to a specific target. Used by discounts, fees, and other extensions that distribute amounts across line items or other order components.", + "type": "object", + "required": [ + "path", + "amount" + ], + "properties": { + "path": { + "type": "string", + "description": "JSONPath (RFC 9535) expression identifying the allocation target (e.g., '$.line_items[0]', '$.totals.shipping')." + }, + "amount": { + "$ref": "amount.json", + "description": "Amount allocated to this target in ISO 4217 minor units." + } + } +} diff --git a/source/schemas/shopping/types/error_code.json b/source/schemas/shopping/types/error_code.json index 77ddf32d..dbd88d95 100644 --- a/source/schemas/shopping/types/error_code.json +++ b/source/schemas/shopping/types/error_code.json @@ -8,6 +8,7 @@ "out_of_stock", "item_unavailable", "address_undeliverable", - "payment_failed" + "payment_failed", + "readonly_field_not_allowed" ] } diff --git a/source/schemas/shopping/types/fee.json b/source/schemas/shopping/types/fee.json new file mode 100644 index 00000000..6b95da88 --- /dev/null +++ b/source/schemas/shopping/types/fee.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/fee.json", + "title": "Fee", + "description": "An individual fee applied to a checkout or cart. Represents a surcharge, service fee, or other additional charge beyond the item subtotal.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "amount", + "fee_type" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique fee identifier within the scope of a single checkout or cart session. Stable across requests within a session (create, update, complete) to enable platforms to track fee changes. Not guaranteed to be consistent across separate sessions." + }, + "title": { + "type": "string", + "description": "Human-readable fee name (e.g., 'Service Fee', 'Recycling Fee')." + }, + "description": { + "type": "string", + "description": "Optional plain-text explanation of why the fee is charged. For richer content (e.g., regulatory disclosures), see the Disclosures capability." + }, + "amount": { + "$ref": "amount.json", + "exclusiveMinimum": 0, + "description": "Fee amount in ISO 4217 minor units. Must be a positive integer (zero-amount fees are not permitted)." + }, + "fee_type": { + "type": "string", + "description": "Type of fee (open string). Well-known values: service, handling, recycling, processing, regulatory, convenience, restocking, environmental. Platforms SHOULD handle unknown values gracefully by displaying the fee title to the user." + }, + "taxable": { + "type": "boolean", + "default": false, + "description": "Whether this fee is subject to tax." + }, + "waivable": { + "type": "boolean", + "default": false, + "description": "Whether this fee can be waived (e.g., membership removes the fee)." + }, + "allocations": { + "type": "array", + "items": { + "$ref": "allocation.json" + }, + "description": "Breakdown of how this fee was allocated to specific targets. Sum of allocation amounts equals total fee amount." + } + } +} diff --git a/source/schemas/shopping/types/total.json b/source/schemas/shopping/types/total.json index cd43d009..be9dc211 100644 --- a/source/schemas/shopping/types/total.json +++ b/source/schemas/shopping/types/total.json @@ -19,7 +19,7 @@ "fee", "total" ], - "description": "Type of total categorization.", + "description": "Type of total categorization. When the Fee Extension is present, businesses SHOULD include a single aggregated entry with type 'fee' whose amount equals the sum of all fees in the Fee Extension's applied array.", "ucp_request": "omit" }, "display_text": { @@ -29,7 +29,7 @@ }, "amount": { "$ref": "amount.json", - "description": "If type == total, sums subtotal - discount + fulfillment + tax + fee. Should be >= 0. Amount in ISO 4217 minor units.", + "description": "If type == total, sums subtotal - discount + fulfillment + tax + fee. When the Fee Extension is present, the fee component is the single aggregated fee total amount. Should be >= 0. Amount in ISO 4217 minor units.", "ucp_request": "omit" } }