diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index 195b8c309..6b470dd95 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -21,7 +21,16 @@ The fulfillment extension enables businesses to advertise support for physical goods fulfillment (shipping, pickup, etc). -This extension adds a `fulfillment` field to Checkout containing: +This extension adds a `fulfillment` field to Checkout and/or Catalog: + +* **Checkout** (`dev.ucp.shopping.checkout`) — selection and cost: which + items go where, by which method, at what price and ETA. +* **Catalog** (`dev.ucp.shopping.catalog.search` and + `dev.ucp.shopping.catalog.lookup`) — discovery: a variant advertises the + fulfillment options available for it, based on the provided buyer + context. See [Catalog Discovery](#catalog-discovery). + +On Checkout, the `fulfillment` field contains: * `methods[]` — fulfillment methods applicable to cart items (shipping, pickup, etc.) * `line_item_ids` — which items this method fulfills @@ -280,11 +289,134 @@ If the buyer chooses pickup but the platform doesn't support split fulfillment, the platform **SHOULD** use `continue_url` to hand off to the business's checkout. +## Catalog Discovery + +When the fulfillment extension extends the Catalog capability, each variant +in a catalog response carries a `fulfillment` object listing the fulfillment +methods available for that variant and their availability — so a buyer +browsing the catalog can see how an item can be fulfilled. + +### Methods + +`fulfillment.methods[]` lists the methods available for a variant. Each +method has: + +* `type` — the fulfillment method (e.g. `shipping`, `pickup`); see + [Method Types](#method-types). +* `description` — short, buyer-facing summary of how the variant is + fulfilled via this method (e.g. "Ships in 2–4 business days"). Directly + renderable; see [Rendering](#rendering). +* `availability` — whether the variant is available via this method at the + specified or inferred location. +* `location` — for place-based methods (e.g. `pickup`), the resolved + location id, and the business's stable identifier for that location. A + business that advertises pickup at a `location` MUST accept the same id + as `selected_destination_id` for that method, so a discovered location + can be used in cart and checkout. + +Catalog reports availability for a single location per method — the one +specified via `fulfills_to` or inferred from `context`; discovering and +comparing other locations is handled separately. + +### Shapes + +#### Catalog Fulfillment + +{{ extension_schema_fields('fulfillment.json#/$defs/catalog_fulfillment', 'fulfillment') }} + +#### Catalog Fulfillment Method + +{{ extension_schema_fields('fulfillment.json#/$defs/catalog_fulfillment_method', 'fulfillment') }} + +#### Availability + +{{ schema_fields('types/availability', 'fulfillment') }} + +#### Fulfillment Destination Filter + +{{ schema_fields('types/fulfillment_destination_filter', 'fulfillment') }} + +### Location and method: `context` and `filters` + +* **`context`** (`address_country` / `address_region` / `postal_code`) is + where the *buyer* is — a non-binding hint the business uses to report + `availability`. On a market-scoped catalog it MAY narrow results; + otherwise it annotates rather than removes them. +* **`filters.fulfills_to`** is where the order is *fulfilled to* — a + `location` id, or a coarse address (`address_country` / `address_region` + / `postal_code`). It restricts results to what can be fulfilled there + and seeds method `availability`. It may differ from `context` (e.g. a + gift). +* **`filters.method`** restricts results to specific method types (e.g. + `["pickup"]`). + +Provide location once: `context` for where the buyer is, `fulfills_to` for +an explicit destination. When both are present, `fulfills_to` supersedes +`context`. + +### Example + +A variant exposes two fulfillment methods: shipping to the buyer's ship-to +and pickup today at a named store. Each method carries its own availability, +and `pickup` references the resolved location by id. + + +```json +{ + "ucp": { "version": "{{ ucp_version }}" }, + "products": [ + { + "id": "prod_kettle", + "title": "Electric Kettle", + "description": { "plain": "1.7L electric kettle." }, + "price_range": { + "min": { "amount": 4999, "currency": "USD" }, + "max": { "amount": 4999, "currency": "USD" } + }, + "variants": [ + { + "id": "var_ss", + "title": "Stainless Steel", + "description": { "plain": "Stainless steel finish." }, + "price": { "amount": 4999, "currency": "USD" }, + "availability": { "available": true, "status": "in_stock" }, + "fulfillment": { + "methods": [ + { + "type": "shipping", + "description": { "plain": "Ships to your address" }, + "availability": { "available": true, "status": "in_stock" } + }, + { + "type": "pickup", + "description": { "plain": "Pickup today at Downtown Store" }, + "location": "loc_downtown", + "availability": { "available": true, "status": "in_stock" } + } + ] + } + } + ] + } + ] +} +``` + +Each method is a way the variant can be fulfilled, with its own +`availability`. Each method's `description` is directly renderable, so a +platform can present it without recognizing the `type` (see +[Rendering](#rendering)). + ## Configuration Businesses and platforms declare fulfillment constraints in their profiles. Businesses fetch platform profiles to adapt responses accordingly. +The `extends` array lists the capabilities this extension adds fulfillment +to. Checkout is the authoritative, transactional surface; catalog is for +discovery. A business lists the catalog capabilities in `extends` to expose +fulfillment on catalog, or omits them to scope itself to checkout only. + ### Platform Profile Platforms declare their rendering capabilities using `platform_schema`: @@ -296,7 +428,8 @@ single-group responses. The response shape is always `methods[].groups[]`—the difference is whether `groups.length` can exceed 1 within each method. -Default declaration (single group per method): +Default declaration (single group per method; fulfillment surfaced on +checkout and on catalog discovery): ```json @@ -306,12 +439,19 @@ Default declaration (single group per method): "version": "{{ ucp_version }}", "spec": "https://ucp.dev/{{ ucp_version }}/specification/fulfillment", "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", - "extends": "dev.ucp.shopping.checkout" + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ] } ] } ``` +A party that does not expose catalog discovery MAY narrow `extends` to +`"dev.ucp.shopping.checkout"` (string form) or to a single-element array. + Opt-in declaration (business MAY return multiple groups per method): @@ -322,7 +462,11 @@ Opt-in declaration (business MAY return multiple groups per method): "version": "{{ ucp_version }}", "spec": "https://ucp.dev/{{ ucp_version }}/specification/fulfillment", "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", - "extends": "dev.ucp.shopping.checkout", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ], "config": { "supports_multi_group": true } } ] @@ -344,7 +488,11 @@ Businesses declare what fulfillment configurations they support using "version": "{{ ucp_version }}", "spec": "https://ucp.dev/{{ ucp_version }}/specification/fulfillment", "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", - "extends": "dev.ucp.shopping.checkout", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ], "config": { "allows_multi_destination": { "shipping": true @@ -375,18 +523,23 @@ shipping+pickup. * Platform is responsible for rendering group selection UI (e.g., choose shipping speed per package) -### Adding New Methods +### Method Types + +`fulfillment_method.type` (checkout) and `catalog_fulfillment_method.type` +(catalog) share one vocabulary of fulfillment method types. The field is an +open string: consumers ignore values they do not recognize. -Extensions that extend fulfillment with new method types (e.g., -`local_delivery`) **MUST** add an extension schema that: +**Well-known values:** -1. Adds the method to the `type` enum in `fulfillment_method` -2. Adds corresponding business config options: - * `allows_multi_destination.local_delivery: boolean` - * `allows_method_combinations` items enum (includes `"local_delivery"`) +| Value | Meaning | +| --- | --- | +| `shipping` | Carrier ships to the buyer's address. | +| `pickup` | Buyer picks up at a named location. | -Note: Platform's `supports_multi_group` is method-agnostic (single boolean), -so no extension needed. +**Adding method types.** `type` is an open string: businesses MAY use +additional values, and consumers ignore values they do not recognize. New +well-known values are added via a UCP RFC and a `dev.ucp.shopping.fulfillment` +version bump. ## Examples diff --git a/source/schemas/common/types/locality.json b/source/schemas/common/types/locality.json new file mode 100644 index 000000000..daa47a58e --- /dev/null +++ b/source/schemas/common/types/locality.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/locality.json", + "title": "Locality", + "description": "A coarse geographic location — country, region, and postal code. A lightweight alternative to a full postal address.", + "type": "object", + "additionalProperties": true, + "properties": { + "address_country": { + "type": "string", + "description": "The country, as a 2-letter ISO 3166-1 alpha-2 code (e.g. \"US\"). A 3-letter alpha-3 code or full country name MAY also be used." + }, + "address_region": { + "type": "string", + "description": "The first-level administrative region within the country (e.g. a state or province such as California)." + }, + "postal_code": { + "type": "string", + "description": "The postal code (e.g. \"94043\")." + } + } +} diff --git a/source/schemas/shopping/fulfillment.json b/source/schemas/shopping/fulfillment.json index f2440168c..0de78c891 100644 --- a/source/schemas/shopping/fulfillment.json +++ b/source/schemas/shopping/fulfillment.json @@ -20,6 +20,185 @@ "fulfillment": { "$ref": "types/fulfillment.json" }, + "catalog_fulfillment_method": { + "title": "Catalog Fulfillment Method", + "description": "A fulfillment method on a catalog variant: how the variant can be fulfilled, and its availability.", + "type": "object", + "additionalProperties": true, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Fulfillment method type. Well-known values: `shipping`, `pickup`. Businesses MAY use additional values." + }, + "description": { + "$ref": "../common/types/description.json", + "description": "Short buyer-facing summary (e.g. 'Ships in 2–4 business days')." + }, + "availability": { + "$ref": "types/availability.json", + "description": "Availability of this variant via this method at the specified or inferred location." + }, + "location": { + "type": "string", + "description": "The location resolved for this method, where appropriate (e.g. the pickup store or location), as a location id." + } + } + }, + "catalog_fulfillment": { + "title": "Catalog Fulfillment", + "description": "How a catalog variant can be fulfilled. Mirrors checkout `fulfillment`.", + "type": "object", + "additionalProperties": true, + "properties": { + "methods": { + "type": "array", + "description": "Fulfillment methods for this variant.", + "items": { "$ref": "#/$defs/catalog_fulfillment_method" } + } + } + }, + "fulfillment_variant": { + "description": "A catalog variant with fulfillment.", + "allOf": [ + { "$ref": "types/variant.json" }, + { + "type": "object", + "properties": { + "fulfillment": { "$ref": "#/$defs/catalog_fulfillment" } + } + } + ] + }, + "fulfillment_product": { + "description": "A catalog product whose variants are fulfillment-enriched. Used by search.", + "allOf": [ + { "$ref": "types/product.json" }, + { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { "$ref": "#/$defs/fulfillment_variant" } + } + } + } + ] + }, + "fulfillment_lookup_variant": { + "description": "A lookup variant (carrying input correlation) enriched with fulfillment.", + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/lookup_variant" }, + { + "type": "object", + "properties": { + "fulfillment": { "$ref": "#/$defs/catalog_fulfillment" } + } + } + ] + }, + "fulfillment_lookup_product": { + "description": "A lookup product whose variants are fulfillment-enriched, preserving input correlation. Used by lookup.", + "allOf": [ + { "$ref": "types/product.json" }, + { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { "$ref": "#/$defs/fulfillment_lookup_variant" } + } + } + } + ] + }, + "fulfillment_detail_product": { + "description": "A get_product detail product (carrying selected/options availability signals) whose variants are fulfillment-enriched. Used by get_product.", + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/detail_product" }, + { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { "$ref": "#/$defs/fulfillment_variant" } + } + } + } + ] + }, + "fulfillment_search_filters": { + "description": "Catalog filters extended with a fulfillment destination filter and a method-type filter.", + "allOf": [ + { "$ref": "types/search_filters.json" }, + { + "type": "object", + "properties": { + "fulfills_to": { + "$ref": "types/fulfillment_destination_filter.json", + "description": "Where the order is fulfilled to — may differ from the buyer's `context` location (e.g. a gift). A `location` id, or a coarse address (`address_country`/`address_region`/`postal_code`). Restricts results to what can be fulfilled there and seeds method `availability`. Supersedes `context`." + }, + "method": { + "type": "array", + "items": { "type": "string" }, + "description": "Restrict results to these fulfillment method types (e.g. [\"pickup\"]). Well-known values: `shipping`, `pickup`." + } + } + } + ] + }, + "fulfillment_search_request": { + "allOf": [ + { "$ref": "catalog_search.json#/$defs/search_request" }, + { "type": "object", "properties": { "filters": { "$ref": "#/$defs/fulfillment_search_filters" } } } + ] + }, + "fulfillment_search_response": { + "allOf": [ + { "$ref": "catalog_search.json#/$defs/search_response" }, + { "type": "object", "properties": { "products": { "type": "array", "items": { "$ref": "#/$defs/fulfillment_product" } } } } + ] + }, + "fulfillment_lookup_request": { + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/lookup_request" }, + { "type": "object", "properties": { "filters": { "$ref": "#/$defs/fulfillment_search_filters" } } } + ] + }, + "fulfillment_lookup_response": { + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/lookup_response" }, + { "type": "object", "properties": { "products": { "type": "array", "items": { "$ref": "#/$defs/fulfillment_lookup_product" } } } } + ] + }, + "fulfillment_get_product_request": { + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/get_product_request" }, + { "type": "object", "properties": { "filters": { "$ref": "#/$defs/fulfillment_search_filters" } } } + ] + }, + "fulfillment_get_product_response": { + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/get_product_response" }, + { "type": "object", "properties": { "product": { "$ref": "#/$defs/fulfillment_detail_product" } } } + ] + }, + "dev.ucp.shopping.catalog.search": { + "description": "Catalog search composition with fulfillment (extends dev.ucp.shopping.catalog.search).", + "$defs": { + "search_request": { "$ref": "#/$defs/fulfillment_search_request" }, + "search_response": { "$ref": "#/$defs/fulfillment_search_response" } + } + }, + "dev.ucp.shopping.catalog.lookup": { + "description": "Catalog lookup composition with fulfillment (extends dev.ucp.shopping.catalog.lookup).", + "$defs": { + "lookup_request": { "$ref": "#/$defs/fulfillment_lookup_request" }, + "lookup_response": { "$ref": "#/$defs/fulfillment_lookup_response" }, + "get_product_request": { "$ref": "#/$defs/fulfillment_get_product_request" }, + "get_product_response": { "$ref": "#/$defs/fulfillment_get_product_response" } + } + }, "dev.ucp.shopping.checkout": { "title": "Checkout with Fulfillment", "description": "Checkout extended with hierarchical fulfillment.", diff --git a/source/schemas/shopping/types/availability.json b/source/schemas/shopping/types/availability.json new file mode 100644 index 000000000..2d8159ca0 --- /dev/null +++ b/source/schemas/shopping/types/availability.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/availability.json", + "title": "Availability", + "description": "Availability of an item: whether it can be obtained, and a qualifying status.", + "type": "object", + "properties": { + "available": { + "type": "boolean", + "description": "Whether this can be obtained. See status for fulfillment details." + }, + "status": { + "type": "string", + "description": "Qualifies available with fulfillment state. Well-known values: `in_stock`, `backorder`, `preorder`, `out_of_stock`, `discontinued`." + } + } +} diff --git a/source/schemas/shopping/types/context.json b/source/schemas/shopping/types/context.json index a140c67f2..9d7f51207 100644 --- a/source/schemas/shopping/types/context.json +++ b/source/schemas/shopping/types/context.json @@ -5,38 +5,35 @@ "description": "Provisional buyer signals for relevance and localization—not authoritative data. Businesses SHOULD use these values when verified inputs (e.g., shipping address) are absent, and MAY ignore or down-rank them if inconsistent with higher-confidence signals (authenticated account, risk detection) or regulatory constraints (export controls). Eligibility and policy enforcement MUST occur at checkout time using binding transaction data. Context SHOULD be non-identifying and can be disclosed progressively—coarse signals early, finer resolution as the session progresses. Higher-resolution data (shipping address, billing address) supersedes context.", "type": "object", "additionalProperties": true, - "properties": { - "address_country": { - "type": "string", - "description": "The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example \"US\". For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as \"SGP\" or a full country name such as \"Singapore\" can also be used. Optional hint for market context (currency, availability, pricing)—higher-resolution data (e.g., shipping address) supersedes this value." + "allOf": [ + { + "$ref": "../../common/types/locality.json" }, - "address_region": { - "type": "string", - "description": "The region in which the locality is, and which is in the country. For example, California or another appropriate first-level Administrative division. Optional hint for progressive localization—higher-resolution data (e.g., shipping address) supersedes this value." - }, - "postal_code": { - "type": "string", - "description": "The postal code. For example, 94043. Optional hint for regional refinement—higher-resolution data (e.g., shipping address) supersedes this value." - }, - "intent": { - "type": "string", - "description": "Background context describing buyer's intent (e.g., 'looking for a gift under $50', 'need something durable for outdoor use'). Informs relevance, recommendations, and personalization." - }, - "language": { - "type": "string", - "description": "Preferred language for content. Use IETF BCP 47 language tags (e.g., 'en', 'fr-CA', 'zh-Hans'). For REST, equivalent to Accept-Language header—platforms SHOULD fall back to Accept-Language when this field is absent; when provided, overrides Accept-Language. Businesses MAY return content in a different language if unavailable." - }, - "currency": { - "type": "string", - "description": "Preferred currency (ISO 4217, e.g., 'EUR', 'USD'). Businesses determine presentment currency from context and authoritative signals; this hint MAY inform selection in multi-currency markets. Also serves as the denomination for price filter values — platforms SHOULD include this field when sending price filters. Response prices include explicit currency confirming the resolution." - }, - "eligibility": { - "type": "array", - "description": "Buyer claims about eligible benefits such as loyalty membership, payment instrument perks, and similar. Recognized claims MAY inform the Business response (e.g., member-only product availability, adjusted pricing in catalog, provisional discounts at cart or checkout). Businesses MUST ignore unrecognized values without error. Values MUST use reverse-domain naming (e.g., 'com.example.loyalty_gold', 'org.school.student') and MUST be non-identifying.", - "uniqueItems": true, - "items": { - "$ref": "../../common/types/reverse_domain_name.json" + { + "type": "object", + "additionalProperties": true, + "properties": { + "intent": { + "type": "string", + "description": "Background context describing buyer's intent (e.g., 'looking for a gift under $50', 'need something durable for outdoor use'). Informs relevance, recommendations, and personalization." + }, + "language": { + "type": "string", + "description": "Preferred language for content. Use IETF BCP 47 language tags (e.g., 'en', 'fr-CA', 'zh-Hans'). For REST, equivalent to Accept-Language header—platforms SHOULD fall back to Accept-Language when this field is absent; when provided, overrides Accept-Language. Businesses MAY return content in a different language if unavailable." + }, + "currency": { + "type": "string", + "description": "Preferred currency (ISO 4217, e.g., 'EUR', 'USD'). Businesses determine presentment currency from context and authoritative signals; this hint MAY inform selection in multi-currency markets. Also serves as the denomination for price filter values — platforms SHOULD include this field when sending price filters. Response prices include explicit currency confirming the resolution." + }, + "eligibility": { + "type": "array", + "description": "Buyer claims about eligible benefits such as loyalty membership, payment instrument perks, and similar. Recognized claims MAY inform the Business response (e.g., member-only product availability, adjusted pricing in catalog, provisional discounts at cart or checkout). Businesses MUST ignore unrecognized values without error. Values MUST use reverse-domain naming (e.g., 'com.example.loyalty_gold', 'org.school.student') and MUST be non-identifying.", + "uniqueItems": true, + "items": { + "$ref": "../../common/types/reverse_domain_name.json" + } + } } } - } + ] } diff --git a/source/schemas/shopping/types/fulfillment_destination_filter.json b/source/schemas/shopping/types/fulfillment_destination_filter.json new file mode 100644 index 000000000..730631b64 --- /dev/null +++ b/source/schemas/shopping/types/fulfillment_destination_filter.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_destination_filter.json", + "title": "Fulfillment Destination Filter", + "description": "A specific destination to check fulfillment against: a location id, or a coarse locality. Used as a request filter; a full street address is not required.", + "type": "object", + "additionalProperties": true, + "allOf": [ + { + "$ref": "../../common/types/locality.json" + }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "location": { + "type": "string", + "description": "A specific location id (e.g. a store) to check." + } + } + } + ] +} diff --git a/source/schemas/shopping/types/fulfillment_method.json b/source/schemas/shopping/types/fulfillment_method.json index 92e02e40a..c6d649256 100644 --- a/source/schemas/shopping/types/fulfillment_method.json +++ b/source/schemas/shopping/types/fulfillment_method.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_method.json", "title": "Fulfillment Method", - "description": "A fulfillment method (shipping or pickup) with destinations and groups.", + "description": "A fulfillment method with destinations and groups.", "type": "object", "required": ["id", "type", "line_item_ids"], "additionalProperties": true, @@ -14,8 +14,7 @@ }, "type": { "type": "string", - "enum": ["shipping", "pickup"], - "description": "Fulfillment method type.", + "description": "Fulfillment method type. Well-known values: `shipping`, `pickup`. Businesses MAY use additional values.", "ucp_request": {"create": "required", "update": "optional"} }, "line_item_ids": { diff --git a/source/schemas/shopping/types/variant.json b/source/schemas/shopping/types/variant.json index d0033e7c3..9355b5f1a 100644 --- a/source/schemas/shopping/types/variant.json +++ b/source/schemas/shopping/types/variant.json @@ -104,18 +104,8 @@ } }, "availability": { - "type": "object", - "description": "Variant availability for purchase.", - "properties": { - "available": { - "type": "boolean", - "description": "Whether this variant can be purchased. See status for fulfillment details." - }, - "status": { - "type": "string", - "description": "Qualifies available with fulfillment state. Well-known values: `in_stock`, `backorder`, `preorder`, `out_of_stock`, `discontinued`." - } - } + "$ref": "availability.json", + "description": "Variant availability for purchase." }, "options": { "type": "array",