Skip to content

negotiate payment terms as a checkout selection - #692

Open
igrigorik wants to merge 2 commits into
raginpirate/payment-terms-simplefrom
feat/payment-terms-selection
Open

negotiate payment terms as a checkout selection#692
igrigorik wants to merge 2 commits into
raginpirate/payment-terms-simplefrom
feat/payment-terms-selection

Conversation

@igrigorik

@igrigorik igrigorik commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Takes #603's direction to its final 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.

The main change here is that a payment term is negotiated. The Business offers a list, the Buyer picks one, and the Business reprices. payment.selected_term_id makes that choice a first-class field, so the term 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, so this copies it rather than inventing a second shape for the same problem:

fulfillment payment terms
list options[] terms[]
selection selected_option_id selected_term_id
list direction response-only response-only (ucp_request: omit)
per-option totals plain total.json array plain total.json array
timestamps absolute (earliest_fulfillment_time) absolute (due_at)
render floor title + description + total title + description + total
type vocabulary open, recognition optional open, recognition optional

Order projection

payment.accepted_term carries the accepted term onto the Order with its schedules and totals. The other terms are checkout state and are not projected. This exists because a Checkout-only disclosure evaporates at completion, which is exactly when a deferred obligation starts mattering — without it, a Buyer who owes $900 at check-in has no protocol record of it. It also gives term-scoped policies a node to survive against, so applies_to: ["$.payment.accepted_term"] resolves on the Order.

Note/dicussion point: this is stricter than core. order.md leaves the policy snapshot at MAY; here, a disclosure that governed the accepted term MUST be returned on the Order. Right choice, or should it remain a MAY?

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 — the two are additive, not competing, but they should land in a known order.

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.
@igrigorik igrigorik self-assigned this Aug 6, 2026
   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.
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Aug 6, 2026
@carolinerg1 carolinerg1 added gov:needs-tc-review and removed status:needs-triage Signal that the PR is ready for human triage labels Aug 7, 2026
@vishkaty

vishkaty commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Read through this from the conformance side and validated the schemas against our oracle — the schema-expressible constraints all enforce cleanly, and using an open-vocabulary type with immediate as the only defined value is a nice way to keep it extensible without a closed enum. A few questions on the parts JSON Schema can't carry:

On your MUST-vs-MAY question — I'd keep the MUST, with a framing that makes it consistent with core rather than stricter than it. core's MAYs (the policy and attribution snapshots) are context — duplicated information whose absence harms no one. accepted_term is obligation state: the only protocol record that money is still owed, which puts it with totals and line_items (both required), not with the policy snapshot. It's the same strengthening you already make requiring payment on the Order while core leaves it optional. A sentence saying the strengthening is deliberate ("…because the accepted term is an outstanding obligation, not context") would pre-empt a future consistency debate.

That does raise the projection's lifetime, though. The Order Payment MUST ("its schedule total entries sum to the Order total") is unconditional, and the note that post-purchase true-ups live in adjustments[] reads as "accepted_term is the frozen agreement." But core doesn't state whether the Order total is recomputed net of adjustments or held fixed, and the two readings diverge for exactly the deferred-payment orders this targets: if total drifts after a partial refund, the frozen schedules stop summing, and a Business has to either mutate the accepted term (losing the record of what was agreed — the thing the projection exists to preserve) or sit in violation. Is the intent that the MUST holds at Order creation ("…sum to the total of the originating checkout"), with adjustments[] carrying subsequent drift?

Two of the invariants are inherently beyond JSON Schemaselected_term_id ∈ terms[].id, and terms[].id uniqueness (we confirmed a dangling selected_term_id validates clean through the oracle). That's expected and fine, but the recovery you specify ("MUST report what changed in messages[]") names no code, so a conformance suite can only assert "some message exists." Would registering a well-known warning_code — mirroring fulfillment_changed, which already exists for exactly this "the Business changed your selection state" case — be worth it? It would make the MUST testable and give Platforms a deterministic recovery trigger rather than string-matching. (fulfillment's selected_option_id carries the identical un-enforceable membership constraint today, so it's a shared pattern either way.)

Minor: the lodging Order example (pt_deposit_balance) seems to have drifted from the Checkout example it continues — the title, schedule id, type, and date all differ, which fights the "keep schedule IDs stable across responses" SHOULD; and the surrounding text references a policy that named $.payment.terms[1] on the Checkout, but that Checkout example doesn't carry a policies[].

Happy to contribute a conformance module for the wire-checkable invariant (selected term's schedule totals == checkout total) once the shape settles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants