From 00f0b6d382254cb8b25d54a4962114555d648fe8 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Thu, 6 Aug 2026 12:27:24 -0700 Subject: [PATCH 1/5] negotiate terms as a checkout selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes #603's direction to its destination. Three of #603's four changes stand: delete schedule.json, require a schedule description, aggregate totals per schedule. Only due_at returns, as an absolute RFC 3339 timestamp rather than a recurrence engine. A payment term is negotiated. The Business offers a list, the Buyer picks one, and the Business reprices. selected_term_id makes that choice a first-class field, so the term is what determines the price, the disclosure duties, and which instruments are eligible — eligibility follows from the term rather than the term following from the instrument. instruments[].term_refs is superseded and removed. UCP already has this shape. fulfillment_group is a list of priced options with exactly one selection, and nearly everything below follows from copying it rather than inventing a second shape for the same problem. fulfillment_group is the structural twin: a list of priced options with exactly one selection, which the Buyer changes and the Business prices. Rather than invent a second shape for the same problem, this copies it. - available_terms[] is response-only (ucp_request: omit); selected_term_id is a request-writable scalar sibling. Exactly how options[] pairs with selected_option_id. - Schedules carry a plain total.json array like fulfillment_option, not totals.json, which would mandate a meaningless subtotal per schedule. - due_at is absolute, like earliest_fulfillment_time. No recurrence rules, so month-end, DST, and rounding-residual decisions stay with the party that already makes them. - Selecting a term is a Checkout mutation, and the Business response is authoritative for all derived state. The blast radius is deliberately not enumerated, because fulfillment already establishes that it need not be. - The baseline render contract is title + description + total, mirroring "title + description + total is sufficient to render any option". Recognizing a type only enables optional enhancement; it never changes what must be rendered. The mandatory floor is title, description, schedules[].description, and schedules[].totals, read against the Checkout currency. Above it, `immediate`, exact amounts, and absolute due_at are supplementary. Only one well-known type value exists; anything else means "not captured at completion". A second value would imply a closed vocabulary. Normative acceptance test: a Platform that recognizes no type and reads no other field renders a correct presentation of every term. A subscription passes it with zero subscription vocabulary. That floor covers amounts and timing. It does not discharge a jurisdictional disclosure duty; those travel through policies[] paired with disclosure warnings, and a Platform that cannot honor the rendering contract escalates via continue_url rather than dropping the notice. Display is unconditional: the contract forbids hiding, collapsing, and dismissing. payment.accepted_term carries the accepted term onto the Order, with its schedules and their totals. The other terms are checkout state and are not projected. A Checkout-only disclosure evaporates at completion, which is exactly when a deferred obligation starts mattering: without this, a Buyer who owes $900 at check-in has no protocol record of it. This also gives term-scoped policies a node to survive against. A disclosure that governed the accepted term MUST be returned on the Order targeting $.payment.accepted_term, and disclosures for terms the Buyer declined MUST NOT be. That is stricter than order.md, which leaves the policy snapshot at MAY; the extension tightens its own domain because the obligation is the point of the feature. Deliberately excluded: per-schedule payment status. What was agreed lives here; what was charged lives in #584's payment.instruments[].amount. #584 adds payment to core order.json and this composes with it via allOf. --- docs/specification/payment-terms.md | 840 +++++++++++------- source/schemas/shopping/payment_terms.json | 101 +-- .../types/available_payment_term.json | 30 - source/schemas/shopping/types/entity_ref.json | 29 - .../shopping/types/payment_schedule.json | 39 +- .../schemas/shopping/types/payment_term.json | 27 +- .../schemas/shopping/types/schedule_type.json | 8 - 7 files changed, 606 insertions(+), 468 deletions(-) delete mode 100644 source/schemas/shopping/types/available_payment_term.json delete mode 100644 source/schemas/shopping/types/entity_ref.json delete mode 100644 source/schemas/shopping/types/schedule_type.json diff --git a/docs/specification/payment-terms.md b/docs/specification/payment-terms.md index 4a4dfdbb0..2bcdcd5c9 100644 --- a/docs/specification/payment-terms.md +++ b/docs/specification/payment-terms.md @@ -20,138 +20,221 @@ ## Overview -The Payment Terms extension lets a business describe when payment is due for a -checkout. A buyer-facing **payment term** is composed of one or more **payment -schedules**. Each schedule can be due immediately, due at a future date, or due -on a recurring cadence. +The Payment Terms extension lets a Business offer the Buyer a choice of **when** +payment for a checkout is due. A lodging Business can offer one term that +charges the full stay at booking and another that charges the first night now +and the balance at check-in. A Business selling equipment can offer Net-30 +alongside pay-now. -This is intended for payment timing, not for changing what is being purchased. -For example, a lodging checkout can offer one term where the first night is paid -at booking time and the remaining stay is paid at check-in. +A **payment term** is an addressable, renderable choice composed of one or more +**payment schedules**. Each schedule is one payment: an amount, and a complete +buyer-facing statement of when it is due. -This extension adds to `checkout.payment`: +This extension adds two properties to `checkout.payment`: -* `available_terms[]` — checkout-wide payment terms the buyer can choose from. -* `instruments[].term_refs[]` — selection of one term and optional assignment - of instruments to schedules within that term. +* `available_terms[]` — the terms the Business offers for this checkout. + Response-only. +* `selected_term_id` — the selected term. Always present in a response; + a Platform writes it to change the selection. -It also adds `order.payment.terms[]`, containing exactly one complete snapshot -of the accepted `available_terms[]` entry. Payment handlers can use -`available_instruments[].constraints.term_refs[]` to describe which terms or -schedules an instrument supports. +A Buyer picks one of the options in `available_terms[]`, the same way they pick +one [fulfillment option](fulfillment.md#platform-responsibilities). -## Schema - -### Payment - -When this capability is active, `checkout.payment` is extended with available -terms and term-aware payment instruments. - -{{ extension_schema_fields('payment_terms.json#/$defs/payment', 'payment_terms') }} - -### Entities - -#### Available Payment Term - -{{ schema_fields('types/available_payment_term', 'payment_terms') }} - -#### Payment Schedule - -{{ schema_fields('types/payment_schedule', 'payment_terms') }} +## Presenting payment terms -#### Schedule Type +Presentation is **term-agnostic**: a Platform does not need to model deposits, +installments, or trade credit to present a term meaningfully. -{{ schema_fields('types/schedule_type', 'payment_terms') }} +Given the Checkout's `currency`, a Platform that recognizes no `type` value and +reads no payment-term field other than `title`, `description`, +`schedules[].description`, and `schedules[].totals` **MUST** be able to present +what is owed and when, for every term. A Business **MUST** author terms so this +holds. -#### Entity Reference +That floor covers amounts and timing. It does not by itself discharge a +jurisdictional disclosure duty — a finance charge that must be given a +prescribed prominence, for instance. Those obligations travel through +[Disclosures](#disclosures), and a Platform that cannot honor them escalates +rather than renders. -{{ schema_fields('types/entity_ref', 'payment_terms') }} +Everything above that floor is supplementary. `type` and `due_at` let a Platform +that wants to do more — split the checkout into due-now and due-later, sort +schedules, drive a calendar reminder — without ever being required to. -#### Available Payment Instrument (Payment Terms) - -{{ extension_schema_fields('payment_terms.json#/$defs/available_payment_instrument', 'payment_terms') }} +## Payment schedules -#### Payment Instrument (Payment Terms) +A schedule is **one payment**, not a timetable. A term that charges four times +has four schedules. -{{ extension_schema_fields('payment_terms.json#/$defs/payment_instrument', 'payment_terms') }} +A Business **MUST** make each schedule's `description` a complete buyer-facing +statement of when and how that payment is due, so that a Platform can render it +verbatim. A Platform **MAY** use `type` and `due_at` for a richer presentation +— a calendar view, a countdown, a reminder — but **MUST NOT** present derived +timing that contradicts the `description`. Recognizing a `type` only enables +optional enhancement; the baseline contract is that `description` and `total` +are sufficient. -## Payment schedules +### Timing class -Payment schedules use `type` as an open string vocabulary. Well-known values are: +`type` is an open string vocabulary with exactly one well-known value: | Type | Meaning | | :--- | :------ | -| `immediate` | Payment is due when checkout is completed. | -| `deferred` | Payment is due after checkout completion, as stated in `description`. | - -Businesses MAY use additional values. Platforms MUST tolerate unknown schedule -types and either apply business-specific support or surface clear validation -messages when a selected payment instrument cannot satisfy them. - -Every schedule has a buyer-facing `description` that **MUST** completely state -when and how its payment or payments are due. The `type` field provides only a -broad timing class; platforms do not infer dates or processor instructions from -it. - -Each schedule's `totals[]` is the aggregate amount of every payment represented -by that schedule. A schedule may describe several occurrences in text, but its -`total` covers the complete series. If individual occurrences need different -instrument assignments, the business represents them as separate schedules. -The sum of schedule `total` entries **MUST** equal the checkout `total` exactly -once. - -Schedules settle only the current checkout; they do not create future purchases, -orders, renewals, or fulfillment obligations. They disclose payment timing but -do not themselves define credential storage, future-charge authorization, -processor capture behavior, or mandate requirements. - -## Instrument constraints - -Payment handlers declare term and schedule support with -`available_instruments[].constraints.term_refs`. Each `term_refs[]` entry is an -Entity Reference interpreted in the payment terms context. - -References can target: - -* **Payment term ID** — `{ "id": "pt_deposit_balance" }` applies to all - schedules in that term. -* **Payment schedule ID** — `{ "schedule_id": "balance_at_check_in" }` applies - to matching schedules. -* **Schedule type** — `{ "schedule_type": "immediate" }` applies to all - schedules with that timing class. -* **Compound refs** — `{ "id": "pt_deposit_balance", "schedule_type": - "immediate" }` applies only to schedules that match both selectors. - -For instrument assignment, every reference **MUST** include `id`, and all -assignment references in one checkout **MUST** identify the same available -term. `schedule_id` and `schedule_type` only narrow that selected term. Handler -constraints may omit `id` to express support across terms. Empty references are -invalid in both contexts. - -Businesses SHOULD make payment schedule IDs unique across -`payment.available_terms[]` when platforms are expected to reference schedules -by `schedule_id` alone. If the same schedule ID appears in more than one term, -a `schedule_id`-only reference matches every schedule with that ID; include `id` -to scope the match to a specific payment term. - - -```json -{ - "type": "gift_card", - "constraints": { - "term_refs": [ - { "schedule_type": "immediate" } - ] - } -} -``` - -When the [Split Payments](split-payments.md) extension is also active, -instruments may include an `amount` field to specify a fixed contribution when -multiple instruments share a schedule. A fixed contribution **MUST** resolve to -exactly one schedule. Contributions to each schedule **MUST** equal that -schedule's aggregate total, and every schedule **MUST** be covered by at least -one eligible instrument. +| `immediate` | The payment is captured when the checkout is completed. | + +Any other value means the payment is **not** captured at completion; the +`description` states when it is due. A Platform **MUST** treat an unrecognized +`type` as not captured at completion. Businesses **MAY** use additional values +such as `deferred` or `on_shipment`, but those values carry no protocol meaning +beyond "not `immediate`". + +### Due dates + +`due_at` is an absolute RFC 3339 date-time. A Business **SHOULD** provide it +when it can determine the due date at checkout, and **MUST** omit it when the +date depends on a future event — "due on delivery" has no date yet. `due_at` +never replaces `description`; it restates in machine-readable form what the +description already says. + +The protocol defines no calendar arithmetic. A Business that offers four +biweekly payments emits four schedules with four computed dates, rather than a +recurrence rule a Platform would have to expand. This keeps month-end, daylight +saving, and rounding-residual decisions with the party that already makes them. + +### Amounts + +Each schedule's `totals` states what this one payment costs. A Business **MUST** +include exactly one `type: total` entry with a non-negative amount: the final +amount charged when this payment is taken, inclusive of tax and every other +charge. Unlike checkout totals, no `subtotal` is required, because the purchase +is priced at the checkout rather than once per payment. + +A term's schedule `total` entries define the amount payable under that term. For +the **selected** term, the Business **MUST** ensure that sum equals the checkout +`total`. + +Unselected terms are **indicative**. A term that discounts the purchase for +paying today has a different payable amount than one that defers part of it, so +at most one term can match the checkout total at any moment. Only the selected +term is bound to it. + +## Selecting a payment term + +Selecting a payment term is a **Checkout mutation**. A Platform sets +`selected_term_id`; the Business returns a recomputed Checkout. That response is +**authoritative for all derived state** — including `totals`, line item prices, +discount eligibility, `policies[]`, `messages[]`, and the payment handlers +offered. + +A Platform **MUST NOT** assume that the amounts shown in `available_terms[]` +survive selection unchanged, and **MUST** re-render from the response. + +A Business **MUST** make `available_terms[].id` unique within a Checkout, so +that a selection resolves to exactly one term. + +A term is always selected. The Checkout `total` is the selected term's total, so +a response without a selection would show an amount that matches no stated +terms. A Business **MUST** return `selected_term_id` in every response. Where +the Buyer has made no choice, the Business selects a default. + +When changing the selection, a Platform **MUST** set `selected_term_id` to an +`id` from the latest `available_terms[]`. A Platform **MUST** omit it on create, +because term IDs are scoped to a Checkout and no options exist yet — the create +response establishes both the options and the default. A Business receiving a +selection that no longer resolves — because the options changed — **MUST** +report it in `messages[]` rather than silently substituting a term. + +Selecting a term can invalidate a selection previously accepted elsewhere in the +Checkout — a deferred term may not be available with a same-day fulfillment +option. The Business resolves the conflict, returns the authoritative state, and +**MUST** report what changed in `messages[]`. + +## Payment instruments and eligibility + +This extension does not change how instruments are supplied. The Buyer's +instruments fund the checkout under the selected term, and the Business +allocates them across that term's schedules. + +Checkout-specific handler and instrument eligibility is a **runtime result**. +Profiles advertise broad support; the Business resolves that support against the +Checkout context and any instruments already supplied, then returns the +authoritative `ucp.payment_handlers` in the response. This covers cases a +discovery-time predicate cannot express — a gift card whose balance is below the +deposit, an issuer that will not support a delayed capture for this Business, a +credential that expires before the balance comes due. + +A Business **MUST NOT** publish payment-term identifiers in a discovery-time +handler profile. Term IDs are scoped to one checkout; a cacheable, +Buyer-independent profile cannot reference them. + +Per-schedule funding — directing one instrument at one schedule and a different +instrument at another — is not defined in this version. A Business that supports +it does so outside the protocol. + +## Disclosures + +Some terms carry display obligations. A subscription that renews, an +installment plan with a finance charge, and a deposit that is forfeited on +cancellation are all subject to consumer-protection rules about what must be +shown, and when. + +This extension does not define a private disclosure channel. It uses the two +that already exist: + +1. A [policy](overview.md#policies) carries the durable terms text, targeted + with `applies_to` at the node the terms concern — the payment term when the + terms are about payment timing, the line item when they are about the goods. +2. A `messages[]` warning with `presentation: "disclosure"` and `code` set to + that policy's `type` compels display of the notice. + +These mechanisms carry durable terms and compel their display. The Business +remains responsible for the required content, its applicability, its timing, and +any affirmative Buyer acknowledgment. In particular, presenting a policy is +optional for a Platform, so any content that **must** reach the Buyer belongs in +the warning `content`, not only in the policy `description`. + +Disclosure display is unconditional. Under [Warning +Presentation](checkout.md#warning-presentation) a Platform **MUST** display +every returned disclosure, **MUST** keep it in proximity to the node named by +`path`, and **MUST NOT** hide, collapse, or auto-dismiss it. A Platform that +cannot honor that contract — for example one that collapses a list of terms and +so cannot preserve proximity for each — **MUST** escalate through `continue_url` +rather than silently dropping the notice. + +An obligation disclosed at checkout does not end at checkout. Where a disclosure +governed the term the Buyer accepted, the Business **MUST** return that +disclosure on the Order with its `path` set to `$.payment.accepted_term`, and +**MUST** use the same target in the `applies_to` of any policy paired with it. A +Business **MUST NOT** return disclosures attached to terms the Buyer did not +accept. + +`applies_to` and `path` resolve against the response they appear in, so a target +that named the right node on the Checkout does not necessarily name it on the +Order; an Order's line items, for instance, are current-state data whose +positions need not match. A Business **MUST** ensure that every target it emits +resolves to the intended node on the response that carries it. + +## Out of scope + +**Amounts that are not determinable at checkout.** A schedule states a known +amount due at a stated time. Hotel incidentals, usage overages, and +post-purchase true-ups are not payment schedules; they are authorizations and +order adjustments. + +**Multiple currencies within one term.** Every schedule total is denominated in +the Checkout `currency`, so a term cannot express an obligation payable partly +in another currency. + +**Recurring commerce.** Schedules settle the current checkout. They do not +create future purchases, renewals, or fulfillment obligations. A Business +enrolling a Buyer in a subscription charges the first cycle through a payment +term and discloses the ongoing arrangement through a policy and its paired +disclosure. See [Subscription enrollment](#subscription-enrollment). + +**Payment execution.** This extension discloses when money is due. It does not +define credential storage, future-charge authorization, how a Business executes +capture, or mandate requirements, and a Platform **MUST NOT** infer that a +deferred payment can be inspected, modified, or cancelled through UCP. ## Discovery @@ -179,61 +262,83 @@ Businesses advertise payment terms support in their profile: } ``` -## Operations +## Schema + +### Payment + +When this capability is active, `checkout.payment` is extended with available +terms and the selected term. + +{{ extension_schema_fields('payment_terms.json#/$defs/payment', 'payment_terms') }} + +### Order Payment -### Selecting and assigning payment terms +On the Order, `payment` carries the accepted term. A Business **MUST** ensure +its schedule `total` entries sum to the Order `total`. The available terms are +checkout state and are not projected. -The buyer selects one checkout-wide term through -`payment.instruments[].term_refs[]`. Every referenced `id` **MUST** match one -`payment.available_terms[].id`; referenced schedules **MUST** belong to that -term, and every schedule **MUST** be completely funded by eligible instruments. +{{ extension_schema_fields('payment_terms.json#/$defs/order_payment', 'payment_terms') }} -Before authorization, the business **MUST** return a Checkout response containing -the accepted instruments and their `term_refs`. A change to the selected term or -schedule assignment requires a new Checkout response and renewed authorization. -When AP2 is active, these accepted assignments are part of the Checkout state -bound by the mandate. +### Entities + +#### Payment Term -### Persisting terms on orders +{{ schema_fields('types/payment_term', 'payment_terms') }} -When this extension is active, a completed Order **MUST** include exactly one -`payment.terms[]` entry. That entry **MUST** equal the selected -`available_terms[]` object, including its schedules and totals. +#### Payment Schedule + +{{ schema_fields('types/payment_schedule', 'payment_terms') }} ## Examples -### Lodging deposit and balance at check-in +### Lodging: pay now, or deposit and balance at check-in + +> A $1,200 stay. Pay in full today for $1,150, or pay the first night now and +> the balance at check-in. -> Pay the first night at booking time and the rest of the stay at check-in. +The two terms have **different payable amounts**. Their schedules sum to their +own term's total, and only the selected one is bound to the checkout total. -**Checkout response fragment:** +**Checkout response fragment — the available terms.** The Buyer has not chosen +yet, so the Business defaults to paying now, and says so: ```json { + "selected_term_id": "pt_pay_now", "available_terms": [ { - "id": "pt_first_night_balance", - "name": "First night now, balance at check-in", - "description": "Pay $300 now and $900 when you check in.", + "id": "pt_pay_now", + "title": "Pay now", + "description": { "plain": "Save $50 by paying for your stay today." }, + "schedules": [ + { + "id": "sched_full", + "type": "immediate", + "description": { "plain": "Due today when you book." }, + "totals": [{ "type": "total", "amount": 115000 }] + } + ] + }, + { + "id": "pt_deposit_balance", + "title": "First night now, balance at check-in", + "description": { "plain": "Hold your room with one night's rate." }, "schedules": [ { "id": "sched_first_night", "type": "immediate", - "description": "Due when checkout is completed.", - "totals": [ - { "type": "subtotal", "amount": 30000 }, - { "type": "total", "amount": 30000 } - ] + "description": { "plain": "Due today when you book." }, + "totals": [{ "type": "total", "amount": 30000 }] }, { - "id": "sched_check_in_balance", + "id": "sched_balance", "type": "deferred", - "description": "Due at check-in on September 1, 2026 at 3:00 PM PDT.", - "totals": [ - { "type": "subtotal", "amount": 90000 }, - { "type": "total", "amount": 90000 } - ] + "description": { + "plain": "Due at check-in on September 1, 2026 at 3:00 PM PDT." + }, + "due_at": "2026-09-01T15:00:00-07:00", + "totals": [{ "type": "total", "amount": 90000 }] } ] } @@ -241,236 +346,327 @@ When this extension is active, a completed Order **MUST** include exactly one } ``` -**Complete request payment fragment:** +**Update request — the Buyer selects the deposit term:** - + ```json { - "instruments": [ - { - "id": "pi_card_1", - "handler_id": "handler_1", - "type": "card", - "credential": { - "type": "token", - "token": "tok_visa_xxxx" - }, - "term_refs": [ - { "id": "pt_first_night_balance" } - ] - } - ] + "selected_term_id": "pt_deposit_balance" } ``` -The card is used for both the immediate first-night payment and the deferred -check-in balance. +**Checkout response — recomputed and authoritative:** -### Different instruments for immediate and deferred schedules + +```json +{ + "ucp": { + "version": "{{ ucp_version }}", + "capabilities": { + "dev.ucp.shopping.checkout": [{ "version": "{{ ucp_version }}" }], + "dev.ucp.shopping.payment_terms": [{ "version": "{{ ucp_version }}" }] + }, + "payment_handlers": { + "com.example.card_handler": [ + { + "id": "card_handler", + "version": "{{ ucp_version }}", + "available_instruments": [{ "type": "card" }] + } + ] + } + }, + "id": "checkout_123", + "status": "incomplete", + "currency": "USD", + "line_items": [], + "links": [ + { "type": "terms_of_service", "url": "https://example.com/tos" } + ], + "totals": [ + { "type": "subtotal", "amount": 120000 }, + { "type": "total", "amount": 120000 } + ], + "payment": { + "selected_term_id": "pt_deposit_balance", + "available_terms": [ + { + "id": "pt_pay_now", + "title": "Pay now", + "description": { "plain": "Save $50 by paying for your stay today." }, + "schedules": [ + { + "id": "sched_full", + "type": "immediate", + "description": { "plain": "Due today when you book." }, + "totals": [{ "type": "total", "amount": 115000 }] + } + ] + }, + { + "id": "pt_deposit_balance", + "title": "First night now, balance at check-in", + "description": { "plain": "Hold your room with one night's rate." }, + "schedules": [ + { + "id": "sched_first_night", + "type": "immediate", + "description": { "plain": "Due today when you book." }, + "totals": [{ "type": "total", "amount": 30000 }] + }, + { + "id": "sched_balance", + "type": "deferred", + "description": { + "plain": "Due at check-in on September 1, 2026 at 3:00 PM PDT." + }, + "due_at": "2026-09-01T15:00:00-07:00", + "totals": [{ "type": "total", "amount": 90000 }] + } + ] + } + ] + } +} +``` -> Use a gift card for the deposit, and a card for the check-in balance. +Because the pay-now discount no longer applies, `checkout.totals` reports +$1,200 — the sum of the selected term's two schedules. Had the Buyer selected +`pt_pay_now`, the checkout total would be $1,150. The terms are unchanged; only +the selected term is bound to the checkout total. `ucp.payment_handlers` in this +response is the set resolved for the selected term. -**Complete request payment fragment:** +On completion, the accepted term travels to the Order, so the Buyer can still +see that $900 is due at check-in. The other terms do not travel, and the deposit +disclosure moves with the term it governs: - + ```json { - "instruments": [ + "ucp": { + "version": "{{ ucp_version }}", + "capabilities": { + "dev.ucp.shopping.order": [{"version": "{{ ucp_version }}"}], + "dev.ucp.shopping.payment_terms": [{"version": "{{ ucp_version }}"}] + } + }, + "id": "order_9f2", + "checkout_id": "checkout_7c1", + "permalink_url": "https://hotel.example.com/orders/9f2", + "currency": "USD", + "line_items": [ { - "id": "pi_gift_card_1", - "handler_id": "handler_1", - "type": "gift_card", - "credential": { - "type": "token", - "token": "gc_abc123" + "id": "li_room", + "item": { + "id": "room_deluxe", + "title": "Deluxe King, 3 nights", + "price": 120000 }, - "term_refs": [ + "quantity": { "original": 1, "total": 1, "fulfilled": 0 }, + "totals": [ + { "type": "subtotal", "amount": 120000 }, + { "type": "total", "amount": 120000 } + ], + "status": "processing" + } + ], + "fulfillment": { "expectations": [] }, + "totals": [ + { "type": "subtotal", "amount": 120000 }, + { "type": "total", "amount": 120000 } + ], + "payment": { + "accepted_term": { + "id": "pt_deposit_balance", + "title": "Reserve with a deposit", + "schedules": [ { - "id": "pt_first_night_balance", - "schedule_id": "sched_first_night" - } - ] - }, - { - "id": "pi_card_1", - "handler_id": "handler_1", - "type": "card", - "credential": { - "type": "token", - "token": "tok_visa_xxxx" - }, - "term_refs": [ + "id": "sched_deposit", + "type": "immediate", + "description": { "plain": "$300 deposit, charged today." }, + "totals": [{ "type": "total", "amount": 30000 }] + }, { - "id": "pt_first_night_balance", - "schedule_id": "sched_check_in_balance" + "id": "sched_balance", + "type": "on_arrival", + "description": { + "plain": "$900 balance, charged at check-in on March 14." + }, + "due_at": "2026-03-14T15:00:00-07:00", + "totals": [{ "type": "total", "amount": 90000 }] } ] } + }, + "policies": [ + { + "type": "com.example.policy.deposit_forfeiture", + "applies_to": ["$.payment.accepted_term"], + "description": { + "plain": "The $300 deposit is non-refundable within 48 hours of arrival." + } + } ] } ``` -If both instruments contribute to the same schedule, use the -[Split Payments](split-payments.md) extension to specify fixed contribution -amounts. +The schedules sum to the Order `total`, and the policy that named +`$.payment.available_terms[1]` on the Checkout names `$.payment.accepted_term` +here. No representation of `pt_pay_now` survives. ### Installments -> Pay 25% today, then 25% every two weeks for three more payments. +> Pay 25% today, then 25% every two weeks. + +Four payments and four schedules: one captured at completion, and three with +computed due dates. The Business does the calendar arithmetic; the Platform +reads dates. - + ```json { "id": "pt_pay_in_4", - "name": "Pay in 4", + "title": "Pay in 4", + "description": { "plain": "Four interest-free payments of $25." }, "schedules": [ { - "id": "sched_installment_1", + "id": "sched_1", "type": "immediate", - "description": "One payment of $25 due when checkout is completed.", - "totals": [ - { "type": "subtotal", "amount": 2500 }, - { "type": "total", "amount": 2500 } - ] + "description": { "plain": "Due today." }, + "totals": [{ "type": "total", "amount": 2500 }] }, { - "id": "sched_installments_2_to_4", + "id": "sched_2", "type": "deferred", - "description": "Three payments of $25 due two, four, and six weeks after checkout.", - "totals": [ - { "type": "subtotal", "amount": 7500 }, - { "type": "total", "amount": 7500 } - ] + "description": { "plain": "Due September 15, 2026." }, + "due_at": "2026-09-15T00:00:00Z", + "totals": [{ "type": "total", "amount": 2500 }] + }, + { + "id": "sched_3", + "type": "deferred", + "description": { "plain": "Due September 29, 2026." }, + "due_at": "2026-09-29T00:00:00Z", + "totals": [{ "type": "total", "amount": 2500 }] + }, + { + "id": "sched_4", + "type": "deferred", + "description": { "plain": "Due October 13, 2026." }, + "due_at": "2026-10-13T00:00:00Z", + "totals": [{ "type": "total", "amount": 2500 }] } ] } ``` -The deferred schedule describes three occurrences, while its totals represent -the aggregate $75 obligation assigned to that schedule. +### Subscription enrollment -### Order with accepted payment terms +> $29.99 per month, cancel anytime. -The Order carries the complete accepted term from the originating Checkout. +The checkout charges the first cycle. The ongoing arrangement is **disclosed**, +not scheduled: a later cycle is a future purchase the Buyer can decline by +cancelling, so it is not an amount owed for this checkout. - +**Payment term — one immediate schedule for cycle one:** + + ```json { - "ucp": { - "version": "{{ ucp_version }}", - "capabilities": { - "dev.ucp.shopping.order": [ - { "version": "{{ ucp_version }}" } - ], - "dev.ucp.shopping.payment_terms": [ - { "version": "{{ ucp_version }}" } - ] + "id": "pt_monthly", + "title": "Monthly", + "description": { "plain": "$29.99 today, then monthly until you cancel." }, + "schedules": [ + { + "id": "sched_cycle_1", + "type": "immediate", + "description": { "plain": "Due today. Covers your first month." }, + "totals": [{ "type": "total", "amount": 2999 }] } - }, - "id": "order_123", - "checkout_id": "checkout_123", - "permalink_url": "https://business.example.com/orders/123", - "line_items": [], - "fulfillment": {}, - "currency": "USD", - "totals": [ - { "type": "subtotal", "amount": 10000 }, - { "type": "total", "amount": 10000 } - ], - "payment": { - "instruments": [ - { - "id": "pi_gift_card", - "type": "gift_card", - "amount": 2500, - "display": { - "description": "Gift card", - "last_digits": "9821" - }, - "term_refs": [ - { - "id": "pt_pay_in_4", - "schedule_id": "sched_installment_1" - } - ] - }, - { - "id": "pi_installment_card", - "type": "card", - "billing_address": { - "street_address": "123 Main St", - "address_locality": "Austin", - "address_region": "TX", - "address_country": "US", - "postal_code": "78701" - }, - "display": { - "brand": "visa", - "last_digits": "4242", - "description": "Visa ending in 4242" - }, - "term_refs": [ - { - "id": "pt_pay_in_4", - "schedule_id": "sched_installments_2_to_4" - } - ] - } - ], - "terms": [ - { - "id": "pt_pay_in_4", - "name": "Pay in 4", - "schedules": [ - { - "id": "sched_installment_1", - "type": "immediate", - "description": "One payment of $25 due when checkout is completed.", - "totals": [ - { "type": "subtotal", "amount": 2500 }, - { "type": "total", "amount": 2500 } - ] - }, - { - "id": "sched_installments_2_to_4", - "type": "deferred", - "description": "Three payments of $25 due two, four, and six weeks after checkout.", - "totals": [ - { "type": "subtotal", "amount": 7500 }, - { "type": "total", "amount": 7500 } - ] - } - ] - } - ] - } + ] } ``` +**Policy — the durable terms.** The recurrence concerns the subscribed item +rather than the payment timing, so it targets the line item — a node the Order +also has, which is what makes the snapshot possible: + + +```json +[ + { + "type": "com.example.policy.subscription", + "description": { + "markdown": "Renews at **$29.99/month** on the 14th until cancelled. Cancel anytime at example.com/account." + }, + "applies_to": ["$.line_items[0]"], + "url": "https://example.com/subscription-terms" + } +] +``` + +**Disclosure — compelled display, paired to the policy by `code`:** + + +```json +[ + { + "type": "warning", + "code": "com.example.policy.subscription", + "path": "$.line_items[0]", + "presentation": "disclosure", + "content": "This subscription renews at $29.99/month on the 14th until you cancel. Cancel at https://example.com/account." + } +] +``` + +A free trial works the same way: the schedule's `total` is `0`, and the +disclosure carries the obligation. A checkout whose total is zero while an +ongoing commitment is being authorized is precisely the case where compelled +display matters most. + ## Platform responsibilities -Platforms SHOULD: +Platforms **MUST**: -* Present available payment terms in a clear, buyer-understandable format. -* Show every schedule's aggregate amount and complete timing description. -* Validate term and schedule references plus instrument constraints before submitting payment. -* Tolerate unknown schedule types and surface clear recovery guidance when a - selected instrument cannot satisfy them. +* Present each term's `title`, and each schedule's `description` and `total` + amount, formatted in the Checkout `currency`. +* Re-render from the Business response after selecting a term, rather than + reusing amounts read from `available_terms[]`. +* Treat an unrecognized schedule `type` as not captured at completion, and + present the term regardless. +* Process disclosures attached to terms per + [Warning Presentation](checkout.md#warning-presentation), escalating through + `continue_url` when the rendering contract cannot be honored. -## Business responsibilities +Platforms **MAY** use `type` and `due_at` for enhanced presentation — calendar +views, countdowns, reminders. This is optional; recognizing a `type` never +changes what a Platform is required to render. -Businesses MUST: +Platforms **SHOULD**: -* Provide at least one checkout-wide payment term when the extension is active. -* Ensure aggregate schedule totals cover the checkout total exactly once. -* Validate that all instrument assignments select the same term and completely - fund its schedules. -* Include that complete accepted term on the resulting Order. -* Honor payment instrument constraints declared by payment handlers. +* Present terms so the Buyer can compare them before selecting, unless only one + term is available or the Checkout is being handed off. -Businesses SHOULD: +## Business responsibilities -* Include clear names and descriptions for buyer-facing terms. -* Include at least one immediate pay-now term for simple checkouts when - supported. -* Use stable schedule IDs so platforms can assign instruments precisely. +Businesses **MUST**: + +* Offer at least one payment term when the extension is active, and report the + selected term in `selected_term_id` on every response. +* Ensure every term's schedules state, in `description` alone, when each payment + is due. +* Ensure the selected term's schedule totals sum to the checkout total exactly + once. +* Return the recomputed Checkout after a selection, including any change to + totals, policies, messages, or eligible payment handlers. +* Place any content that must reach the Buyer in the disclosure `content`, not + only in the paired policy `description`. +* Carry the accepted term onto the Order as `payment.accepted_term`, along with + any disclosure that governed it. + +Businesses **SHOULD**: + +* Provide `due_at` whenever the due date is determinable at checkout. +* Keep schedule IDs stable across Checkout responses unless the schedule itself + changes. diff --git a/source/schemas/shopping/payment_terms.json b/source/schemas/shopping/payment_terms.json index 3f8bf2c18..3beebbc4d 100644 --- a/source/schemas/shopping/payment_terms.json +++ b/source/schemas/shopping/payment_terms.json @@ -2,9 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/payment_terms.json", "name": "dev.ucp.shopping.payment_terms", - "version": "2026-01-23", "title": "Payment Terms Extension", - "description": "Extends Checkout with flexible payment terms support, enabling deferred payments, installments, and per-line-item payment schedules.", + "description": "Extends Checkout with selectable payment terms, letting a Business offer alternative schedules for when payment for the checkout is due, and projects the accepted term onto the resulting Order.", "requires": { "protocol": { "min": "2026-04-08" }, "capabilities": { @@ -13,83 +12,54 @@ } }, "$defs": { - "available_payment_term": { - "$ref": "types/available_payment_term.json" - }, "payment_term": { "$ref": "types/payment_term.json" }, - "payment_schedule": { - "$ref": "types/payment_schedule.json" - }, - "schedule_ref": { - "$ref": "types/entity_ref.json" - }, - "available_payment_instrument": { - "title": "Available Payment Instrument (Payment Terms)", - "description": "Extends available_payment_instrument with term reference constraints.", - "allOf": [ - { "$ref": "types/available_payment_instrument.json" }, - { - "type": "object", - "properties": { - "constraints": { - "type": "object", - "properties": { - "term_refs": { - "type": "array", - "items": { "$ref": "#/$defs/schedule_ref" }, - "description": "Payment terms and schedules this instrument supports. References can target a payment term by `id`, a payment schedule by `schedule_id`, a schedule class by `schedule_type`, or a combination. If omitted, the instrument supports all terms and schedules unless constrained by the payment handler." - } - } - } - } - } - ] - }, - "payment_instrument": { - "title": "Payment Instrument (Payment Terms)", - "description": "Payment instrument extended with payment term and payment schedule assignment.", - "allOf": [ - { "$ref": "types/payment_instrument.json" }, - { - "type": "object", - "properties": { - "term_refs": { - "type": "array", - "items": { "$ref": "#/$defs/schedule_ref" }, - "description": "Payment terms or schedules this instrument pays for. References can target a payment term by `id`, a payment schedule by `schedule_id`, a schedule class by `schedule_type`, or a combination. If omitted, the instrument can be applied by the business according to the selected terms and handler constraints." - } - } - } - ] - }, "payment": { "title": "Payment with Terms", - "description": "Payment object extended with payment terms.", + "description": "Payment object extended with selectable payment terms.", "allOf": [ { "$ref": "payment.json" }, { "type": "object", + "required": ["available_terms"], "properties": { "available_terms": { "type": "array", - "items": { "$ref": "#/$defs/available_payment_term" }, - "description": "Available payment terms the buyer can choose from. Each term defines schedules for when payments are due.", + "items": { "$ref": "#/$defs/payment_term" }, + "minItems": 1, + "description": "Payment terms the Buyer can choose from. An unselected term's amounts are indicative; the selected term's schedule totals sum to the checkout total.", "ucp_request": "omit" }, - "instruments": { - "type": "array", - "items": { "$ref": "#/$defs/payment_instrument" }, - "description": "Payment instruments, optionally assigned to selected payment terms or schedules." + "selected_term_id": { + "type": "string", + "description": "ID of the selected payment term. MUST match one `available_terms[].id` from the latest Checkout response. Always present in responses: the Checkout total is the selected term's total, so a response without a selection would show an amount that matches no stated terms. Where the Buyer has made no choice, the Business selects a default. Omitted on create requests because term IDs are checkout-scoped and no terms exist yet. Selecting a term is a Checkout mutation: the Business response is authoritative for all derived state.", + "ucp_request": { + "create": "omit", + "update": "optional", + "complete": "optional" + }, + "ucp_response": "required" } } } ] }, + "order_payment": { + "title": "Order Payment with Accepted Term", + "description": "Order payment details carrying the term the Buyer accepted at checkout.", + "type": "object", + "required": ["accepted_term"], + "properties": { + "accepted_term": { + "$ref": "#/$defs/payment_term", + "description": "The payment term the Buyer accepted at checkout. Businesses MUST carry it forward so the Order states the amounts owed and when, and MUST ensure its schedule totals sum to the Order total. The available terms are checkout state and are not projected." + } + } + }, "dev.ucp.shopping.checkout": { "title": "Checkout with Payment Terms", - "description": "Checkout extended with payment terms and payment schedules.", + "description": "Checkout extended with selectable payment terms.", "allOf": [ { "$ref": "checkout.json" }, { @@ -109,22 +79,17 @@ ] }, "dev.ucp.shopping.order": { - "title": "Order with Payment Terms", - "description": "Order extended with the payment terms accepted during checkout.", + "title": "Order with Accepted Payment Term", + "description": "Order extended with the payment term accepted at checkout.", "allOf": [ { "$ref": "order.json" }, { "type": "object", "properties": { "payment": { - "type": "object", - "properties": { - "terms": { - "type": "array", - "items": { "$ref": "#/$defs/available_payment_term" }, - "description": "Complete payment terms accepted for the originating checkout." - } - } + "$ref": "#/$defs/order_payment", + "description": "Payment details for the Order, including the accepted payment term.", + "ucp_response": "required" } } } diff --git a/source/schemas/shopping/types/available_payment_term.json b/source/schemas/shopping/types/available_payment_term.json deleted file mode 100644 index 5194d5fbb..000000000 --- a/source/schemas/shopping/types/available_payment_term.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ucp.dev/schemas/shopping/types/available_payment_term.json", - "title": "Available Payment Term", - "description": "A payment term offered by the business, defining how line items can be paid for over time.", - "type": "object", - "required": ["id", "name", "schedules"], - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for this payment term." - }, - "name": { - "type": "string", - "description": "Human-readable name for this payment term, such as `Pay Now`, `Net-30`, `Pay in 4`, or `Deposit + Balance at Check-in`." - }, - "description": { - "type": "string", - "description": "Optional buyer-facing explanation of the payment term." - }, - "schedules": { - "type": "array", - "items": { - "$ref": "payment_schedule.json" - }, - "minItems": 1, - "description": "Payment schedules within this term defining when payments are due, if used." - } - } -} diff --git a/source/schemas/shopping/types/entity_ref.json b/source/schemas/shopping/types/entity_ref.json deleted file mode 100644 index 58cc69f17..000000000 --- a/source/schemas/shopping/types/entity_ref.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ucp.dev/schemas/shopping/types/entity_ref.json", - "title": "Entity Reference", - "description": "Flexible reference by ID, schedule ID, schedule type, or any combination thereof. Enables semantic grouping and specific targeting.", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Entity ID to reference. Context-dependent: line item ID for line_item_refs, payment term ID for term_refs." - }, - "schedule_id": { - "type": "string", - "description": "Match by entity schedule ID." - }, - "schedule_type": { - "$ref": "schedule_type.json", - "description": "Match by schedule type." - } - }, - "examples": [ - { "id": "li_123" }, - { "id": "pt_pay_in_4" }, - { "schedule_type": "immediate" }, - { "schedule_type": "deferred" }, - { "id": "pt_pay_in_4", "schedule_type": "immediate" }, - { "schedule_id": "deposit-uuid" } - ] -} diff --git a/source/schemas/shopping/types/payment_schedule.json b/source/schemas/shopping/types/payment_schedule.json index 46cdcaac8..0c5499e06 100644 --- a/source/schemas/shopping/types/payment_schedule.json +++ b/source/schemas/shopping/types/payment_schedule.json @@ -2,25 +2,48 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/payment_schedule.json", "title": "Payment Schedule", - "description": "A named payment obligation within a payment term. Timing is disclosed in buyer-facing text while totals provide a structured allocation amount.", + "description": "A single payment that settles part or all of the checkout under a payment term. Timing is stated in buyer-facing text; `type` and `due_at` are supplementary machine-readable signals derived from it.", "type": "object", - "required": ["id", "type", "description", "totals"], + "required": [ + "id", + "type", + "description", + "totals" + ], + "additionalProperties": true, "properties": { "id": { "type": "string", - "description": "Unique identifier for this payment schedule." + "description": "Identifier for this payment schedule, unique within its payment term. Businesses SHOULD keep it stable across responses while the schedule remains the same payment." }, "type": { - "$ref": "schedule_type.json", - "description": "Broad payment timing class. Well-known values: `immediate` and `deferred`." + "type": "string", + "description": "Timing class, drawn from an open vocabulary. `immediate` is the only value with defined meaning: the payment is captured when the checkout is completed. Any other value means the payment is not captured at completion, and `description` states when it is due. Businesses MAY use additional values (e.g. `deferred`, `on_shipment`); Platforms MUST treat unrecognized values as not captured at completion." }, "description": { + "$ref": "../../common/types/description.json", + "description": "Complete buyer-facing statement of when and how this payment is due. Businesses MUST make this field sufficient on its own: a Platform that recognizes no `type` value and reads no other field MUST be able to present this schedule correctly. Platforms MAY use `type` and `due_at` for enhanced presentation, but MUST NOT present derived timing that contradicts this field." + }, + "due_at": { "type": "string", - "description": "Complete buyer-facing explanation of when and how the payment is due." + "format": "date-time", + "description": "Absolute RFC 3339 date-time when this payment is due, when the Business can determine one at checkout. Supplementary to `description`, never a replacement for it. Omitted when the due date depends on a future event (e.g. 'due on delivery'); the timing is then stated in `description` alone." }, "totals": { - "$ref": "totals.json", - "description": "Aggregate pricing breakdown for every payment represented by this schedule. The entry with `type: total` is the complete amount allocated to the schedule in the checkout or order currency." + "type": "array", + "items": { + "allOf": [ + { "$ref": "total.json" }, + { + "if": { "properties": { "type": { "const": "total" } }, "required": ["type"] }, + "then": { "properties": { "amount": { "minimum": 0 } } } + } + ] + }, + "contains": { "properties": { "type": { "const": "total" } }, "required": ["type"] }, + "minContains": 1, + "maxContains": 1, + "description": "What this one payment costs. Businesses MUST include exactly one `total` entry with a non-negative amount: the final amount charged when this payment is taken, inclusive of tax and every other charge, in the checkout currency's minor units (ISO 4217). Unlike checkout totals, no `subtotal` is required, because the purchase is priced at the checkout rather than once per payment." } } } diff --git a/source/schemas/shopping/types/payment_term.json b/source/schemas/shopping/types/payment_term.json index c8bf8ffb4..372883ca1 100644 --- a/source/schemas/shopping/types/payment_term.json +++ b/source/schemas/shopping/types/payment_term.json @@ -2,13 +2,34 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/payment_term.json", "title": "Payment Term", - "description": "A buyer's selection of a payment term for specific line items.", + "description": "A way of paying for the checkout: one or more payment schedules that together cover its total.", "type": "object", - "required": ["id"], + "required": [ + "id", + "title", + "schedules" + ], + "additionalProperties": true, "properties": { "id": { "type": "string", - "description": "ID of the selected payment term from available_terms." + "description": "Unique identifier for this payment term within the checkout. Referenced by `payment.selected_term_id`." + }, + "title": { + "type": "string", + "description": "Short label that distinguishes this term from its siblings (e.g. 'Pay now', 'Pay in 4', 'Deposit + balance at check-in')." + }, + "description": { + "$ref": "../../common/types/description.json", + "description": "Supplementary context for the title (e.g. 'Save 5% by paying today'). Directly renderable; MUST NOT repeat the title." + }, + "schedules": { + "type": "array", + "items": { + "$ref": "payment_schedule.json" + }, + "minItems": 1, + "description": "Payment schedules that settle this checkout under this term, in the order they come due." } } } diff --git a/source/schemas/shopping/types/schedule_type.json b/source/schemas/shopping/types/schedule_type.json deleted file mode 100644 index b296f387c..000000000 --- a/source/schemas/shopping/types/schedule_type.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ucp.dev/schemas/shopping/types/schedule_type.json", - "title": "Schedule Type", - "description": "Classifies timing for entities such as orders, payments, and fulfillment. 'immediate' refers to immediate capture/fulfillment, 'deferred' refers to future scheduled events.", - "type": "string", - "examples": ["immediate", "deferred"] -} From cd77848efe75c06f4bbce1207ec08d1bd7127523 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Thu, 6 Aug 2026 13:06:27 -0700 Subject: [PATCH 2/5] rename available_terms to terms available_ is not a generic prefix in UCP. It marks a secondary, availability-flavored list sitting beside the real one. shopping/types/fulfillment.json carries both: methods[] is the list, while available_methods[] is "inventory availability hints" with its own type and ucp_request: omit. payment_handler.json uses available_instruments the same way, for discovery-time support. The selectable list is a bare plural wherever it appears: options, methods, groups, instruments. available_terms therefore claimed the wrong slot, reading as hints about terms rather than as the terms themselves. payment.terms[] also states the domain term directly, and lines up with payment.selected_term_id on the Checkout and payment.accepted_term on the Order, all resolving to one type, payment_term.json. --- docs/specification/payment-terms.md | 24 +++++++++++----------- source/schemas/shopping/payment_terms.json | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/specification/payment-terms.md b/docs/specification/payment-terms.md index 2bcdcd5c9..6f6182988 100644 --- a/docs/specification/payment-terms.md +++ b/docs/specification/payment-terms.md @@ -32,12 +32,12 @@ buyer-facing statement of when it is due. This extension adds two properties to `checkout.payment`: -* `available_terms[]` — the terms the Business offers for this checkout. +* `terms[]` — the payment terms the Business offers for this checkout. Response-only. * `selected_term_id` — the selected term. Always present in a response; a Platform writes it to change the selection. -A Buyer picks one of the options in `available_terms[]`, the same way they pick +A Buyer picks one of the options in `terms[]`, the same way they pick one [fulfillment option](fulfillment.md#platform-responsibilities). ## Presenting payment terms @@ -126,10 +126,10 @@ Selecting a payment term is a **Checkout mutation**. A Platform sets discount eligibility, `policies[]`, `messages[]`, and the payment handlers offered. -A Platform **MUST NOT** assume that the amounts shown in `available_terms[]` +A Platform **MUST NOT** assume that the amounts shown in `terms[]` survive selection unchanged, and **MUST** re-render from the response. -A Business **MUST** make `available_terms[].id` unique within a Checkout, so +A Business **MUST** make `terms[].id` unique within a Checkout, so that a selection resolves to exactly one term. A term is always selected. The Checkout `total` is the selected term's total, so @@ -138,7 +138,7 @@ terms. A Business **MUST** return `selected_term_id` in every response. Where the Buyer has made no choice, the Business selects a default. When changing the selection, a Platform **MUST** set `selected_term_id` to an -`id` from the latest `available_terms[]`. A Platform **MUST** omit it on create, +`id` from the latest `terms[]`. A Platform **MUST** omit it on create, because term IDs are scoped to a Checkout and no options exist yet — the create response establishes both the options and the default. A Business receiving a selection that no longer resolves — because the options changed — **MUST** @@ -274,8 +274,8 @@ terms and the selected term. ### Order Payment On the Order, `payment` carries the accepted term. A Business **MUST** ensure -its schedule `total` entries sum to the Order `total`. The available terms are -checkout state and are not projected. +its schedule `total` entries sum to the Order `total`. The terms offered at +checkout are not projected. {{ extension_schema_fields('payment_terms.json#/$defs/order_payment', 'payment_terms') }} @@ -299,14 +299,14 @@ checkout state and are not projected. The two terms have **different payable amounts**. Their schedules sum to their own term's total, and only the selected one is bound to the checkout total. -**Checkout response fragment — the available terms.** The Buyer has not chosen +**Checkout response fragment — the terms on offer.** The Buyer has not chosen yet, so the Business defaults to paying now, and says so: ```json { "selected_term_id": "pt_pay_now", - "available_terms": [ + "terms": [ { "id": "pt_pay_now", "title": "Pay now", @@ -389,7 +389,7 @@ yet, so the Business defaults to paying now, and says so: ], "payment": { "selected_term_id": "pt_deposit_balance", - "available_terms": [ + "terms": [ { "id": "pt_pay_now", "title": "Pay now", @@ -511,7 +511,7 @@ disclosure moves with the term it governs: ``` The schedules sum to the Order `total`, and the policy that named -`$.payment.available_terms[1]` on the Checkout names `$.payment.accepted_term` +`$.payment.terms[1]` on the Checkout names `$.payment.accepted_term` here. No representation of `pt_pay_now` survives. ### Installments @@ -632,7 +632,7 @@ Platforms **MUST**: * Present each term's `title`, and each schedule's `description` and `total` amount, formatted in the Checkout `currency`. * Re-render from the Business response after selecting a term, rather than - reusing amounts read from `available_terms[]`. + reusing amounts read from `terms[]`. * Treat an unrecognized schedule `type` as not captured at completion, and present the term regardless. * Process disclosures attached to terms per diff --git a/source/schemas/shopping/payment_terms.json b/source/schemas/shopping/payment_terms.json index 3beebbc4d..5e71d419e 100644 --- a/source/schemas/shopping/payment_terms.json +++ b/source/schemas/shopping/payment_terms.json @@ -22,9 +22,9 @@ { "$ref": "payment.json" }, { "type": "object", - "required": ["available_terms"], + "required": ["terms"], "properties": { - "available_terms": { + "terms": { "type": "array", "items": { "$ref": "#/$defs/payment_term" }, "minItems": 1, @@ -33,7 +33,7 @@ }, "selected_term_id": { "type": "string", - "description": "ID of the selected payment term. MUST match one `available_terms[].id` from the latest Checkout response. Always present in responses: the Checkout total is the selected term's total, so a response without a selection would show an amount that matches no stated terms. Where the Buyer has made no choice, the Business selects a default. Omitted on create requests because term IDs are checkout-scoped and no terms exist yet. Selecting a term is a Checkout mutation: the Business response is authoritative for all derived state.", + "description": "ID of the selected payment term. MUST match one `terms[].id` from the latest Checkout response. Always present in responses: the Checkout total is the selected term's total, so a response without a selection would show an amount that matches no stated terms. Where the Buyer has made no choice, the Business selects a default. Omitted on create requests because term IDs are checkout-scoped and no terms exist yet. Selecting a term is a Checkout mutation: the Business response is authoritative for all derived state.", "ucp_request": { "create": "omit", "update": "optional", From 599ce9c2a1ea89157df3a336ebcc5e39524d8f16 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 10 Aug 2026 12:54:38 -0700 Subject: [PATCH 3/5] freeze accepted term and name the selection warning The Order sum rule was unconditional, so a partial refund broke it. If the Order total is recomputed net of an adjustment, frozen schedules stop summing and a Business must either rewrite the accepted term -- destroying the record of what was agreed, which is why the projection exists -- or sit in violation. It bites exactly the deferred-payment orders this targets. The rule now binds at Order creation, and a Business MUST NOT modify the accepted term afterwards; post-purchase changes are recorded in adjustments[]. Schedules summing to more than the Order currently owes is the correct outcome of a refund, so the prose says so rather than leaving it to look like a bug. Recovery from a stale selection named no code, so a conformance suite could only assert that some message exists. Both selection MUSTs now name payment_term_changed, registered alongside fulfillment_changed in the open warning_code vocabulary, and a Platform can detect a changed selection from the code rather than by diffing responses. Note that fulfillment_changed is registered and never referenced anywhere; naming this one at its MUSTs is what makes it testable. The lodging Order example reinvented the term it was meant to continue: same id, different title, schedule ids, schedule type, and a due_at six months off -- against the schedule-ID stability SHOULD in this same document. It now matches the Checkout example exactly. The surrounding prose also described a Checkout policy naming $.payment.terms[1] that no example carried; that policy is now shown, so the re-pointing to $.payment.accepted_term is demonstrated rather than asserted. The Order disclosure rule gains its rationale in the sentence that already carried it: the disclosure records money still owed, not context. That distinction, not a deviation from the optional policy snapshot in order.md, is why this is a MUST. --- docs/specification/payment-terms.md | 67 +++++++++++++------ source/schemas/common/types/warning_code.json | 1 + 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/docs/specification/payment-terms.md b/docs/specification/payment-terms.md index 6f6182988..5814a52f6 100644 --- a/docs/specification/payment-terms.md +++ b/docs/specification/payment-terms.md @@ -138,16 +138,19 @@ terms. A Business **MUST** return `selected_term_id` in every response. Where the Buyer has made no choice, the Business selects a default. When changing the selection, a Platform **MUST** set `selected_term_id` to an -`id` from the latest `terms[]`. A Platform **MUST** omit it on create, -because term IDs are scoped to a Checkout and no options exist yet — the create -response establishes both the options and the default. A Business receiving a -selection that no longer resolves — because the options changed — **MUST** -report it in `messages[]` rather than silently substituting a term. +`id` from the latest `terms[]`. A Platform **MUST** omit it on create, because +term IDs are scoped to a Checkout and no options exist yet — the create response +establishes both the options and the default. A Business receiving a selection +that no longer resolves — because the options changed — **MUST NOT** silently +substitute a term, and **MUST** report the change as a `payment_term_changed` +warning in `messages[]`. Selecting a term can invalidate a selection previously accepted elsewhere in the Checkout — a deferred term may not be available with a same-day fulfillment option. The Business resolves the conflict, returns the authoritative state, and -**MUST** report what changed in `messages[]`. +**MUST** report the change as a `payment_term_changed` warning in `messages[]`. +A Platform can therefore detect a changed selection from the code alone, rather +than by comparing responses. ## Payment instruments and eligibility @@ -201,12 +204,12 @@ cannot honor that contract — for example one that collapses a list of terms an so cannot preserve proximity for each — **MUST** escalate through `continue_url` rather than silently dropping the notice. -An obligation disclosed at checkout does not end at checkout. Where a disclosure -governed the term the Buyer accepted, the Business **MUST** return that -disclosure on the Order with its `path` set to `$.payment.accepted_term`, and -**MUST** use the same target in the `applies_to` of any policy paired with it. A -Business **MUST NOT** return disclosures attached to terms the Buyer did not -accept. +An obligation disclosed at checkout does not end at checkout: it records money +still owed, not context. Where a disclosure governed the term the Buyer +accepted, the Business **MUST** return that disclosure on the Order with its +`path` set to `$.payment.accepted_term`, and **MUST** use the same target in +the `applies_to` of any policy paired with it. A Business **MUST NOT** return +disclosures attached to terms the Buyer did not accept. `applies_to` and `path` resolve against the response they appear in, so a target that named the right node on the Checkout does not necessarily name it on the @@ -273,9 +276,15 @@ terms and the selected term. ### Order Payment -On the Order, `payment` carries the accepted term. A Business **MUST** ensure -its schedule `total` entries sum to the Order `total`. The terms offered at -checkout are not projected. +On the Order, `payment` carries the accepted term. The terms offered at checkout +are not projected. + +The accepted term is the agreement, not a running balance. When the Order is +created, a Business **MUST** ensure its schedule `total` entries sum to the +Order `total`. A Business **MUST NOT** modify the accepted term after the Order +is created; post-purchase changes are recorded in `adjustments[]`. A refund can +therefore leave the schedules summing to more than the Order currently owes: the +term states what was agreed, and the adjustment states what happened after. {{ extension_schema_fields('payment_terms.json#/$defs/order_payment', 'payment_terms') }} @@ -436,6 +445,21 @@ $1,200 — the sum of the selected term's two schedules. Had the Buyer selected the selected term is bound to the checkout total. `ucp.payment_handlers` in this response is the set resolved for the selected term. +The deposit terms live in a policy that targets the term they apply to: + + +```json +[ + { + "type": "com.example.policy.deposit_forfeiture", + "applies_to": ["$.payment.terms[1]"], + "description": { + "plain": "The $300 deposit is non-refundable within 48 hours of arrival." + } + } +] +``` + On completion, the accepted term travels to the Order, so the Buyer can still see that $900 is due at check-in. The other terms do not travel, and the deposit disclosure moves with the term it governs: @@ -478,21 +502,22 @@ disclosure moves with the term it governs: "payment": { "accepted_term": { "id": "pt_deposit_balance", - "title": "Reserve with a deposit", + "title": "First night now, balance at check-in", + "description": { "plain": "Hold your room with one night's rate." }, "schedules": [ { - "id": "sched_deposit", + "id": "sched_first_night", "type": "immediate", - "description": { "plain": "$300 deposit, charged today." }, + "description": { "plain": "Due today when you book." }, "totals": [{ "type": "total", "amount": 30000 }] }, { "id": "sched_balance", - "type": "on_arrival", + "type": "deferred", "description": { - "plain": "$900 balance, charged at check-in on March 14." + "plain": "Due at check-in on September 1, 2026 at 3:00 PM PDT." }, - "due_at": "2026-03-14T15:00:00-07:00", + "due_at": "2026-09-01T15:00:00-07:00", "totals": [{ "type": "total", "amount": 90000 }] } ] diff --git a/source/schemas/common/types/warning_code.json b/source/schemas/common/types/warning_code.json index 4cf544038..59071de89 100644 --- a/source/schemas/common/types/warning_code.json +++ b/source/schemas/common/types/warning_code.json @@ -8,6 +8,7 @@ "final_sale", "prop65", "fulfillment_changed", + "payment_term_changed", "age_restricted" ] } From f9b41c5064ef55c290f30484d3d71b923c40258d Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 10 Aug 2026 13:13:21 -0700 Subject: [PATCH 4/5] cover both adjustment directions in the freeze rule adjustment.totals is signed -- negative for money returned to the Buyer by refunds and credits, positive for additional charges from exchanges -- so a frozen accepted term can end up summing to either side of the Order total. The prose illustrated only the refund case. An implementer meeting an exchange would find a rule whose illustration does not cover the situation in front of them, and the available inference is that the freeze does not apply there, so the term gets rewritten to match. That is the outcome the paragraph exists to prevent, reintroduced by an incomplete example. Both directions are now named with the event that produces each. --- docs/specification/payment-terms.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/specification/payment-terms.md b/docs/specification/payment-terms.md index 5814a52f6..2dd7ed76b 100644 --- a/docs/specification/payment-terms.md +++ b/docs/specification/payment-terms.md @@ -282,9 +282,10 @@ are not projected. The accepted term is the agreement, not a running balance. When the Order is created, a Business **MUST** ensure its schedule `total` entries sum to the Order `total`. A Business **MUST NOT** modify the accepted term after the Order -is created; post-purchase changes are recorded in `adjustments[]`. A refund can -therefore leave the schedules summing to more than the Order currently owes: the -term states what was agreed, and the adjustment states what happened after. +is created; post-purchase changes are recorded in `adjustments[]`. The schedules +can therefore sum to more than the Order currently owes after a refund, or less +after an exchange: the term states what was agreed, and the adjustments state +what happened after. {{ extension_schema_fields('payment_terms.json#/$defs/order_payment', 'payment_terms') }} From 8a7d1417422be17f8d1f58183a4fc2149d12a03c Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 10 Aug 2026 14:25:05 -0700 Subject: [PATCH 5/5] define immediate by due date + terms before complete `immediate` was defined as "the payment is captured when the checkout is completed". Capture is payment-handler behavior. The ordinary card flow authorizes at checkout and captures at fulfillment, so an amount due at completion could reasonably be typed `deferred` on the grounds that its capture is deferred, inverting the field's meaning for the most common payment flow there is. The type now states when payment is due, and the schema says outright that authorization, capture, and settlement are handler behavior outside this extension. The two surviving mentions of capture describe instrument eligibility and the execution boundary. selected_term_id was optional on complete requests. Core drops line_items, buyer, and context at complete, and its payment object carries only instruments: complete supplies the instrument and places the order, with every price determinant structurally absent. The fulfillment extension does the same, annotating its entire checkout projection complete: omit. Carrying a pricing field on the one object core mandates at complete left the total changeable after the checkout declared ready_for_complete, and after the instrument was authorized against the prior total. selected_term_id is now omitted there, which makes this match fulfillment rather than quietly diverge from it. Selection was already an Update mutation in practice. The annotation now says so, and the prose gives the reason rather than the rule alone. --- docs/specification/payment-terms.md | 29 ++++++++++--------- source/schemas/shopping/payment_terms.json | 4 +-- .../shopping/types/payment_schedule.json | 2 +- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/specification/payment-terms.md b/docs/specification/payment-terms.md index 2dd7ed76b..4246e2242 100644 --- a/docs/specification/payment-terms.md +++ b/docs/specification/payment-terms.md @@ -80,11 +80,11 @@ are sufficient. | Type | Meaning | | :--- | :------ | -| `immediate` | The payment is captured when the checkout is completed. | +| `immediate` | The payment is due when the checkout is completed. | -Any other value means the payment is **not** captured at completion; the +Any other value means the payment is **not** due at completion; the `description` states when it is due. A Platform **MUST** treat an unrecognized -`type` as not captured at completion. Businesses **MAY** use additional values +`type` as not due at completion. Businesses **MAY** use additional values such as `deferred` or `on_shipment`, but those values carry no protocol meaning beyond "not `immediate`". @@ -137,13 +137,16 @@ a response without a selection would show an amount that matches no stated terms. A Business **MUST** return `selected_term_id` in every response. Where the Buyer has made no choice, the Business selects a default. -When changing the selection, a Platform **MUST** set `selected_term_id` to an -`id` from the latest `terms[]`. A Platform **MUST** omit it on create, because -term IDs are scoped to a Checkout and no options exist yet — the create response -establishes both the options and the default. A Business receiving a selection -that no longer resolves — because the options changed — **MUST NOT** silently -substitute a term, and **MUST** report the change as a `payment_term_changed` -warning in `messages[]`. +A Platform changes the selection through Update Checkout, setting +`selected_term_id` to an `id` from the latest `terms[]`. A Platform **MUST** +omit it on create, because term IDs are scoped to a Checkout and no options +exist yet — the create response establishes both the options and the default. A +Platform **MUST** omit it on complete, because the term is settled before a +Checkout can reach `ready_for_complete`, and the instrument is authorized +against the selected term's total. A Business receiving a selection that no +longer resolves — because the options changed — **MUST NOT** silently substitute +a term, and **MUST** report the change as a `payment_term_changed` warning in +`messages[]`. Selecting a term can invalidate a selection previously accepted elsewhere in the Checkout — a deferred term may not be available with a same-day fulfillment @@ -544,7 +547,7 @@ here. No representation of `pt_pay_now` survives. > Pay 25% today, then 25% every two weeks. -Four payments and four schedules: one captured at completion, and three with +Four payments and four schedules: one due at completion, and three with computed due dates. The Business does the calendar arithmetic; the Platform reads dates. @@ -659,8 +662,8 @@ Platforms **MUST**: amount, formatted in the Checkout `currency`. * Re-render from the Business response after selecting a term, rather than reusing amounts read from `terms[]`. -* Treat an unrecognized schedule `type` as not captured at completion, and - present the term regardless. +* Treat an unrecognized schedule `type` as not due at completion, and present + the term regardless. * Process disclosures attached to terms per [Warning Presentation](checkout.md#warning-presentation), escalating through `continue_url` when the rendering contract cannot be honored. diff --git a/source/schemas/shopping/payment_terms.json b/source/schemas/shopping/payment_terms.json index 5e71d419e..eb9f3f1b4 100644 --- a/source/schemas/shopping/payment_terms.json +++ b/source/schemas/shopping/payment_terms.json @@ -33,11 +33,11 @@ }, "selected_term_id": { "type": "string", - "description": "ID of the selected payment term. MUST match one `terms[].id` from the latest Checkout response. Always present in responses: the Checkout total is the selected term's total, so a response without a selection would show an amount that matches no stated terms. Where the Buyer has made no choice, the Business selects a default. Omitted on create requests because term IDs are checkout-scoped and no terms exist yet. Selecting a term is a Checkout mutation: the Business response is authoritative for all derived state.", + "description": "ID of the selected payment term. MUST match one `terms[].id` from the latest Checkout response. Always present in responses: the Checkout total is the selected term's total, so a response without a selection would show an amount that matches no stated terms. Where the Buyer has made no choice, the Business selects a default. Omitted on create requests because term IDs are checkout-scoped and no terms exist yet, and on complete requests because the term is already settled by then. Selecting a term is an Update Checkout mutation: the Business response is authoritative for all derived state.", "ucp_request": { "create": "omit", "update": "optional", - "complete": "optional" + "complete": "omit" }, "ucp_response": "required" } diff --git a/source/schemas/shopping/types/payment_schedule.json b/source/schemas/shopping/types/payment_schedule.json index 0c5499e06..f00b52ce7 100644 --- a/source/schemas/shopping/types/payment_schedule.json +++ b/source/schemas/shopping/types/payment_schedule.json @@ -18,7 +18,7 @@ }, "type": { "type": "string", - "description": "Timing class, drawn from an open vocabulary. `immediate` is the only value with defined meaning: the payment is captured when the checkout is completed. Any other value means the payment is not captured at completion, and `description` states when it is due. Businesses MAY use additional values (e.g. `deferred`, `on_shipment`); Platforms MUST treat unrecognized values as not captured at completion." + "description": "Timing class, drawn from an open vocabulary. `immediate` is the only value with defined meaning: the payment is due when the checkout is completed. Any other value means the payment is not due at completion, and `description` states when it is due. Whether a due payment is authorized, captured, or settled at that moment is payment-handler behavior and outside this extension. Businesses MAY use additional values (e.g. `deferred`, `on_shipment`); Platforms MUST treat unrecognized values as not due at completion." }, "description": { "$ref": "../../common/types/description.json",