diff --git a/docs/specification/return.md b/docs/specification/return.md new file mode 100644 index 000000000..b1e57ce01 --- /dev/null +++ b/docs/specification/return.md @@ -0,0 +1,261 @@ + + +# Return Policy Extension + +## Overview + +The Return Policy Extension defines the `dev.ucp.shopping.policy.return` policy +type on the core [`policies[]`](overview.md#policies) primitive. It adds +pre-purchase, machine-readable return terms to policies that carry this type, so +platforms and agents can answer questions like "How long do I have to return +this?", "Do I get my money back or store credit?", "Where and at what cost do I +send it back?", and "Can I return this at all?" without leaving to read a policy +page. + +**Key features:** + +- A return window as a policy statement (`window`), with an explicit anchor +- What the buyer receives on an accepted return (`supported_resolutions`) +- Permitted return channels and their logistics cost (`methods`) +- A restocking fee that deducts from the refund independent of channel + (`restocking_fee`) +- A hard `final_sale` flag for non-returnable items + +**Dependencies:** + +- The core `policies[]` primitive (see [Policies](overview.md#policies)). +- One or more of the parent capabilities this type extends: Catalog Search, + Catalog Lookup, Cart, Checkout, or Order. + +This extension only structures the type-specific body. The `policies[]` +container, `applies_to` targeting, same-type precedence, and buyer-facing +disclosure through `messages[]` are defined once by the primitive and are +**unchanged** here. + +## Discovery + +Businesses advertise return policy support in their profile. The type extends any +surface that carries `policies[]`: + + +```json +{ + "ucp": { + "version": "{{ ucp_version }}", + "capabilities": { + "dev.ucp.shopping.policy.return": [ + { + "version": "{{ ucp_version }}", + "extends": [ + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup", + "dev.ucp.shopping.cart", + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.order" + ], + "spec": "https://ucp.dev/{{ ucp_version }}/specification/return", + "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/policy_return.json" + } + ] + } + } +} +``` + +A business MAY advertise support on a subset of these surfaces. A platform that +has negotiated this type validates the return body and MAY reason over it (for +example, computing a return deadline). A platform that has not negotiated it +still renders the policy from the base `type` and `description`, because the +return body is purely additive. + +## Schema + +When this type is active, a `policies[]` entry whose `type` is +`dev.ucp.shopping.policy.return` MAY carry the following fields in addition to the +base `type`, `description`, `applies_to`, and `url`. All fields are optional; a +return policy with no structured fields is still presentable from its +`description`. + +### Return Policy + +{{ extension_schema_fields('policy_return.json#/$defs/return_body', 'return') }} + +### Return Method + +{{ extension_schema_fields('policy_return.json#/$defs/return_method', 'return') }} + +## Return terms + +### Window and anchor + +`window.days` is the length of the return window as a **policy statement**, not a +live countdown. A platform computes a concrete deadline by adding `days` to the +event named by `window.anchor`. + +Businesses differ on whether the clock starts at delivery, purchase, shipment, or +fulfillment, so when `window` is present a business MUST provide both `days` and +`anchor`. There is no default anchor, and a platform MUST NOT assume one. Omit +`window` entirely for an unlimited window, or when the window is not stated +structurally, and convey it in `description`. + +The resolved, absolute cutoff is an Order-time fact: once the anchor event has +occurred, a business republishes the policy on the Order as the source of truth +(see [Order-time resolution](#order-time-resolution)). Pre-purchase, the window +stays a duration plus an anchor. + +### Non-returnable items + +To signal that the covered items cannot be returned, a business MUST set +`final_sale` to `true` and SHOULD omit the other return-body fields, since return +terms do not apply. A business MUST NOT use an empty `supported_resolutions` array +to signal non-returnability; `final_sale` is the single, unambiguous signal a +platform acts on. + +When a business requires the buyer to be shown that an item is final sale, it +emits a `messages[]` warning with `presentation: "disclosure"` and `code` equal +to `dev.ucp.shopping.policy.return`, targeting the item. The disclosure pairs +with the governing return policy at that node, as defined in +[Presenting policies](overview.md#presenting-policies). + +### Two kinds of cost + +Returns carry two independent costs, modeled separately: + +- **Logistics cost** is the price of using a channel (return shipping or + handling). It lives on each `methods[].fee` because it varies by channel: an + in-store drop-off may be `free` while a mailed return is a `fixed_fee`. +- **Restocking fee** is a deduction from the refund charged regardless of how the + item comes back. It lives once at the policy level as `restocking_fee`. + +Keeping them separate lets a business express, for example, "free returns by mail +with a 15% restocking fee" - which a single per-method fee cannot represent. A +percentage restocking fee uses `restocking_fee.percentage_bps`; a flat or +precomputed amount uses `restocking_fee.amount`. + +When a return method's `fee.type` is `fixed_fee`, `amount` MUST be present, so the +charge is never left uninterpretable. + +## Targeting and precedence + +Targeting and precedence are provided by the `policies[]` primitive and are not +redefined here. In short: a policy with no `applies_to` is the response-wide +default; a policy that targets specific items overrides it where they overlap, +with the narrowest same-type target winning. See +[Targeting](overview.md#targeting) and [Precedence](overview.md#precedence). + +For return policies this means a business states a single default return policy +once, then adds targeted overrides only for the exceptions (a final-sale item, a +category with a different window), rather than repeating a policy on every line. + +## Responsibilities + +Return policies are business-stated facts. They are response-only data +(`ucp_request: omit` on cart, checkout, and order) that a platform never submits; +there is no buyer selection and no request-side machinery. They carry no +buyer-asserted claims and no PII, so they cross no new trust boundary beyond the +base response. + +A business SHOULD populate the structured fields it can author accurately and MAY +state anything it cannot yet structure (condition requirements, category +nuances) in the policy `description` or link to it through `url`. A platform +SHOULD surface `url` alongside the structured terms so the buyer can review the +full policy. + +A policy's `description` and its structured fields are two representations of the +same policy. The `description` MAY add terms not modeled here, but a business MUST +NOT let it contradict a structured field. A platform that models this type MUST +treat the structured fields as authoritative (for computing the return deadline, +net refund, and returnability); the `description` is the human-readable summary it +renders, and the fallback for a platform that does not model the type. + +## Order-time resolution + +Pre-purchase, the return window is a duration plus an anchor. The concrete +deadline a buyer actually has can only be computed once the anchor event (for +example, delivery) has occurred. That resolution is an Order-time concern: on the +Order, a business republishes the same return policy carrying the resolved cutoff, +so the platform reads it rather than tracking fulfillment events and recomputing. +Modeling the resolved deadline field is deferred to that Order-side work. + +## Examples + +### Checkout with a default policy and a final-sale override + +A response-wide return policy states the standard terms once. An item-scoped +policy marks one engraved line as final sale; because it targets that line +directly, it governs it under same-type precedence, while every other line keeps +the default. A paired disclosure compels the final-sale notice to be shown. + + +```json +[ + { + "type": "dev.ucp.shopping.policy.return", + "description": { + "plain": "30-day returns from delivery. Refund to original payment or exchange. Free in-store; $5 return shipping by mail. 15% restocking fee on opened items." + }, + "window": { "days": 30, "anchor": "delivered" }, + "supported_resolutions": ["original_payment_method", "exchange"], + "methods": [ + { "type": "in_store", "fee": { "type": "free", "display_text": "Free in-store return" } }, + { "type": "by_mail", "fee": { "type": "fixed_fee", "amount": 500, "display_text": "Return shipping" } } + ], + "restocking_fee": { "percentage_bps": 1500, "display_text": "15% restocking fee on opened items" }, + "url": "https://example.com/returns" + }, + { + "type": "dev.ucp.shopping.policy.return", + "description": { "plain": "This engraved item is final sale and cannot be returned." }, + "applies_to": ["$.line_items[?@.id=='engraved_watch']"], + "final_sale": true, + "url": "https://example.com/returns#final-sale" + } +] +``` + +The paired disclosure that compels display of the final-sale term: + + +```json +[ + { + "type": "warning", + "code": "dev.ucp.shopping.policy.return", + "path": "$.line_items[?@.id=='engraved_watch']", + "presentation": "disclosure", + "content": "This engraved item is final sale and cannot be returned." + } +] +``` + +### Catalog with a store-credit-only return policy + +On a catalog surface, a return policy targets one product and signals that +returns are store credit only, a material purchase-decision factor: + + +```json +[ + { + "type": "dev.ucp.shopping.policy.return", + "description": { "plain": "Returns accepted within 30 days for store credit only." }, + "applies_to": ["$.products[0]"], + "window": { "days": 30, "anchor": "purchased" }, + "supported_resolutions": ["store_credit"] + } +] +``` diff --git a/mkdocs.yml b/mkdocs.yml index 3c512e000..7344aa6f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,6 +54,7 @@ nav: - Discounts Extension: specification/discount.md - Fulfillment Extension: specification/fulfillment.md - Loyalty Extension: specification/loyalty.md + - Return Policy Extension: specification/return.md - Split Payments Extension: specification/split-payments.md - Cart Capability: - Overview: specification/cart.md @@ -328,6 +329,12 @@ plugins: memberships, tiers, benefits, and reward earning forecasts while managing provisional eligibility claims and enforcing data-minimization privacy rules. + - specification/return.md: >- + Return Policy Extension, defining the dev.ucp.shopping.policy.return + type on the core policies primitive with pre-purchase return terms: + window and anchor, supported resolutions, return channels and + logistics fees, a policy-level restocking fee, and a final-sale flag + across catalog, cart, checkout, and order. Cart Capability: - specification/cart.md: >- Pre-purchase Cart Capability, detailing item collection, state diff --git a/source/schemas/shopping/policy_return.json b/source/schemas/shopping/policy_return.json new file mode 100644 index 000000000..78b01ac06 --- /dev/null +++ b/source/schemas/shopping/policy_return.json @@ -0,0 +1,260 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/policy_return.json", + "name": "dev.ucp.shopping.policy.return", + "title": "Return Policy Extension", + "description": "Defines the `dev.ucp.shopping.policy.return` policy type on the core `policies[]` primitive, adding return terms to catalog, cart, checkout, and order.", + "$defs": { + "return_body": { + "title": "Return Policy", + "description": "Return terms for a `dev.ucp.shopping.policy.return` policy. All fields are optional.", + "type": "object", + "properties": { + "final_sale": { + "type": "boolean", + "description": "When `true`, the covered items cannot be returned. When `false` or omitted, the items are returnable subject to the other fields." + }, + "window": { + "title": "Return Window", + "description": "Return window as a policy statement. Omit for an unlimited or unstated window.", + "type": "object", + "required": ["days", "anchor"], + "properties": { + "days": { + "type": "integer", + "minimum": 0, + "description": "Length of the return window in whole days from `anchor`. `0` means non-returnable." + }, + "anchor": { + "type": "string", + "description": "Event `days` is measured from. Well-known values: `delivered`, `purchased`, `shipped`, `fulfilled`." + } + } + }, + "supported_resolutions": { + "type": "array", + "description": "Accepted return outcomes, as an unordered set. Well-known values: `original_payment_method`, `store_credit`, `gift_card`, `exchange`, `replacement`.", + "items": { + "type": "string" + } + }, + "methods": { + "type": "array", + "description": "Permitted return channels and the logistics cost of each.", + "items": { + "$ref": "#/$defs/return_method" + } + }, + "restocking_fee": { + "title": "Restocking Fee", + "description": "A refund deduction applied regardless of return channel. Provide `percentage_bps`, `amount`, or both.", + "type": "object", + "properties": { + "percentage_bps": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Restocking fee in basis points of the item price (e.g., `1500` = 15%)." + }, + "amount": { + "$ref": "../common/types/amount.json", + "description": "Restocking fee as a flat charge in ISO 4217 minor units." + }, + "display_text": { + "type": "string", + "description": "Human-readable fee label (e.g., '15% restocking fee')." + } + } + } + } + }, + "return_method": { + "title": "Return Method", + "description": "A return channel and its logistics cost.", + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Return channel. Well-known values: `in_store`, `by_mail`, `kiosk`." + }, + "fee": { + "title": "Return Method Fee", + "description": "Logistics cost of using this channel.", + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Cost structure. Well-known values: `free`, `fixed_fee` (see `amount`), `customer_responsibility`." + }, + "amount": { + "$ref": "../common/types/amount.json", + "description": "Flat channel fee in ISO 4217 minor units, when `type` is `fixed_fee`." + }, + "display_text": { + "type": "string", + "description": "Human-readable fee label (e.g., 'Return shipping')." + } + } + } + } + }, + "policies_item": { + "description": "A `policies[]` entry; the return body applies when `type` is `dev.ucp.shopping.policy.return`.", + "allOf": [ + { + "$ref": "types/policy.json" + }, + { + "if": { + "properties": { + "type": { + "const": "dev.ucp.shopping.policy.return" + } + }, + "required": ["type"] + }, + "then": { + "$ref": "#/$defs/return_body" + } + } + ] + }, + "search_response": { + "allOf": [ + { + "$ref": "catalog_search.json#/$defs/search_response" + }, + { + "type": "object", + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/$defs/policies_item" + } + } + } + } + ] + }, + "lookup_response": { + "allOf": [ + { + "$ref": "catalog_lookup.json#/$defs/lookup_response" + }, + { + "type": "object", + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/$defs/policies_item" + } + } + } + } + ] + }, + "get_product_response": { + "allOf": [ + { + "$ref": "catalog_lookup.json#/$defs/get_product_response" + }, + { + "type": "object", + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/$defs/policies_item" + } + } + } + } + ] + }, + "dev.ucp.shopping.cart": { + "title": "Cart with Return Policy", + "description": "Cart extended with return policy terms on `policies[]`.", + "allOf": [ + { + "$ref": "cart.json" + }, + { + "type": "object", + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/$defs/policies_item" + }, + "ucp_request": "omit" + } + } + } + ] + }, + "dev.ucp.shopping.checkout": { + "title": "Checkout with Return Policy", + "description": "Checkout extended with return policy terms on `policies[]`.", + "allOf": [ + { + "$ref": "checkout.json" + }, + { + "type": "object", + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/$defs/policies_item" + }, + "ucp_request": "omit" + } + } + } + ] + }, + "dev.ucp.shopping.order": { + "title": "Order with Return Policy", + "description": "Order extended with return policy terms on `policies[]`.", + "allOf": [ + { + "$ref": "order.json" + }, + { + "type": "object", + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/$defs/policies_item" + } + } + } + } + ] + }, + "dev.ucp.shopping.catalog.search": { + "description": "Catalog search composition with return policy (extends dev.ucp.shopping.catalog.search).", + "$defs": { + "search_response": { + "$ref": "#/$defs/search_response" + } + } + }, + "dev.ucp.shopping.catalog.lookup": { + "description": "Catalog lookup composition with return policy (extends dev.ucp.shopping.catalog.lookup).", + "$defs": { + "lookup_response": { + "$ref": "#/$defs/lookup_response" + }, + "get_product_response": { + "$ref": "#/$defs/get_product_response" + } + } + } + } +}