portage-ucp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9198c70ed7abc380268adc344d7496c049a3c78c0ed28b16944e3b27d9d01ba
4
- data.tar.gz: c076c1b4e544825d0db160bf2453fed3e256b2024c7aafb8f431227ec22364ef
3
+ metadata.gz: add1fc4dcd1b12c3dea3ade248972c2fd2616233b074230838daccce41c9033d
4
+ data.tar.gz: e75364c190c83ee0b9f6bc43f2aca2ef5067d8a4baf3ad5c1b6534fd1096abd7
5
5
  SHA512:
6
- metadata.gz: 80aac639014575e4416c3e477f451bf9cb25b3cd69ab8a3cda512bd19243d51a95b54c5086b738fc9140be4e0318cb17b81a30767f2ba6b18546b6cd22e9a33b
7
- data.tar.gz: dc0f578ee8d379f9f12c4605dcaf37757d5518f2764087186669a4809773b5bd869192328a0027ef61fd36f5a4dbc629697841e57f9065c96a9a5d5d7796c11a
6
+ metadata.gz: 72c18740172d1aeec163bbc7754d2190f59a92c8fa347256e344ff247ff30b8d4a046ad7f368c7b458b24be9e012748e045f7b4d059b9cef6df509c2aab200da
7
+ data.tar.gz: 2fd42daa906727619d78a276926461c886ec9b20ce81e9bc5c99fe5b0e229172696edef94df5e9ef108ebc6cedcfb1435b62c6590ae96e2ccaaeb218b8778713
data/CHANGELOG.md CHANGED
@@ -4,7 +4,33 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
- ## [0.1.0] - Unreleased
7
+ ## [0.2.0] - 2026-08-21
8
+
9
+ - `Portage::Ucp::OutOfStockError` — the contract for `#complete_checkout`
10
+ (design-log §16 "Stock/availability going stale") now documents that
11
+ adapters should raise it when the platform rejects completion over a
12
+ no-longer-available line item, instead of re-checking with a separate call
13
+ agents could forget to make.
14
+ - `Adapter#cancel_order`, `#request_return`, `#refund_order` — a gem-side
15
+ extension of `dev.ucp.shopping.order` (design-log §16 "Order changes"),
16
+ since the real UCP spec's order lifecycle is get-only. Each returns the
17
+ updated `Order`, with the change recorded as an appended
18
+ `Portage::Ucp::Adjustment`.
19
+ - `Capability#predicate` — a minimal escape hatch for extensions that add
20
+ fields rather than actions (`dev.ucp.shopping.discount`,
21
+ `dev.ucp.shopping.fulfillment`): a capability can name an adapter method
22
+ instead of an action set, and `#advertised_for?` asks it directly.
23
+ `create_cart`/`update_cart`/`create_checkout`/`update_checkout` gain an
24
+ optional `discount_codes:` param defaulting to `nil` (not `[]`), so "not
25
+ mentioned" and "clear the codes" stay distinguishable.
26
+ - `dev.ucp.shopping.fulfillment` — the vendored extension for picking a
27
+ shipping method/rate or pickup location during checkout. New value objects
28
+ `PostalAddress`, `ShippingDestination`, `RetailLocation`,
29
+ `FulfillmentOption`, `FulfillmentGroup`, `FulfillmentMethod`, and the
30
+ `CheckoutFulfillment` container (named apart from `Fulfillment`, which
31
+ Order's post-purchase container already owns — see design-log).
32
+
33
+ ## [0.1.0] - 2026-08-14
8
34
 
9
35
  - Initial pre-release. Protocol-only core: `Adapter` contract, capability
10
36
  registry, manifest builder, MCP server wrapper, offline `SchemaValidator`,
@@ -17,24 +17,53 @@ module Portage
17
17
  # builds the response's Item/Total/LineItem itself.
18
18
  # @return [Portage::Ucp::Cart]
19
19
  def get_cart(cart_id:) = not_implemented
20
+ # `discount_codes:` is the dev.ucp.shopping.discount extension — nil
21
+ # (the default) means the request didn't touch discounts at all; an
22
+ # adapter that doesn't override #discount_codes_supported? never sees
23
+ # anything but nil here (see below). Full-replacement like line_items:
24
+ # once codes are involved, [] clears them, same as UCP's own
25
+ # discounts_object semantics.
20
26
  # @return [Portage::Ucp::Cart]
21
- def create_cart(line_items:, idempotency_key:) = not_implemented
27
+ def create_cart(line_items:, idempotency_key:, discount_codes: nil) = not_implemented
22
28
  # @return [Portage::Ucp::Cart]
23
- def update_cart(cart_id:, line_items:, idempotency_key:) = not_implemented
29
+ def update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil) = not_implemented
24
30
  # @return [Portage::Ucp::Cart]
25
31
  def cancel_cart(cart_id:, idempotency_key:) = not_implemented
26
32
 
27
33
  # --- Checkout (dev.ucp.shopping.checkout) ---
34
+ # `discount_codes:` carries the same dev.ucp.shopping.discount
35
+ # semantics as create_cart/update_cart above.
36
+ # `fulfillment:` is the dev.ucp.shopping.fulfillment extension — nil
37
+ # (the default) means the request didn't touch fulfillment at all; an
38
+ # adapter that doesn't override #fulfillment_supported? never sees
39
+ # anything but nil here (see below). On create it carries the agent's
40
+ # desired methods (type + line_item_ids per shipping/pickup group —
41
+ # `Portage::Ucp::FulfillmentMethod#id`/`#destinations`/`#groups` are
42
+ # omitted since the merchant generates those); on update it carries the
43
+ # agent's `selected_destination_id`/`selected_option_id` choices against
44
+ # the methods/groups the merchant already returned.
28
45
  # @return [Portage::Ucp::Checkout]
29
- def create_checkout(line_items:, idempotency_key:) = not_implemented
46
+ def create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil) = not_implemented
30
47
  # @return [Portage::Ucp::Checkout]
31
48
  def get_checkout(checkout_id:) = not_implemented
49
+
32
50
  # Full-replacement, same as update_cart — line_items is required on
33
51
  # checkout update per the real spec.
34
52
  # @return [Portage::Ucp::Checkout]
35
- def update_checkout(checkout_id:, line_items:, idempotency_key:) = not_implemented
53
+ def update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
54
+ not_implemented
55
+ end
56
+
36
57
  # @param payment_token [String] single-use token from a UCP payment handler / AP2
37
58
  # exchange — NEVER a raw PAN.
59
+ # Re-checks stock at the point of committing money, since search_catalog/
60
+ # get_product (dev.ucp.shopping.catalog) don't promise live inventory and
61
+ # nothing else re-checks between browsing and buying. An adapter whose
62
+ # platform rejects completion because a line item is out of stock or
63
+ # otherwise unavailable should raise Portage::Ucp::OutOfStockError rather
64
+ # than a generic/platform error, so callers can distinguish a stale-stock
65
+ # failure from e.g. a declined payment.
66
+ # @raise [Portage::Ucp::OutOfStockError] if a line item is no longer available
38
67
  # @return [Portage::Ucp::Checkout]
39
68
  def complete_checkout(checkout_id:, payment_token:, idempotency_key:) = not_implemented
40
69
  # @return [Portage::Ucp::Checkout]
@@ -43,6 +72,37 @@ module Portage
43
72
  # --- Order (dev.ucp.shopping.order) ---
44
73
  # @return [Portage::Ucp::Order, nil]
45
74
  def get_order(order_id:) = not_implemented
75
+ # Cancels a placed order. `reason` is an optional human-readable note,
76
+ # not a closed enum — the platform-specific enum mapping (if any) is an
77
+ # adapter concern.
78
+ # @return [Portage::Ucp::Order]
79
+ def cancel_order(order_id:, idempotency_key:, reason: nil) = not_implemented
80
+ # Requests a return for one or more order line items. `line_items` is an
81
+ # array of request-shaped hashes (`{id:, quantity:}`, unsigned) — same
82
+ # request/response asymmetry as `create_cart`'s `line_items:`. A return
83
+ # is a request the merchant still has to process; it shows up as a
84
+ # `pending` `Portage::Ucp::Adjustment` until they do.
85
+ # @return [Portage::Ucp::Order]
86
+ def request_return(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
87
+ # Refunds one or more order line items.
88
+ # @return [Portage::Ucp::Order]
89
+ def refund_order(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
90
+
91
+ # --- Discount (dev.ucp.shopping.discount) ---
92
+ # Extends Cart/Checkout with the `discount_codes:` param above rather
93
+ # than adding actions of its own — Capability::DISCOUNT advertises off
94
+ # this predicate instead of an overridden action method, since there's
95
+ # no dedicated method for #advertised_for? to detect an override on.
96
+ # @return [Boolean]
97
+ def discount_codes_supported? = false
98
+
99
+ # --- Fulfillment (dev.ucp.shopping.fulfillment) ---
100
+ # Extends Checkout with the `fulfillment:` param above rather than
101
+ # adding actions of its own — Capability::FULFILLMENT advertises off
102
+ # this predicate instead of an overridden action method, same rationale
103
+ # as #discount_codes_supported? above.
104
+ # @return [Boolean]
105
+ def fulfillment_supported? = false
46
106
 
47
107
  # --- Identity Linking (dev.ucp.shopping.identity, OAuth 2.0) ---
48
108
  # @return [Portage::Ucp::Identity] linked profile for an exchanged OAuth token
@@ -0,0 +1,16 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # schemas/shopping/discount.json — extends Cart/Checkout with a
5
+ # `discounts` field rather than adding actions of its own, so `actions`
6
+ # is empty and advertisement runs off Adapter#discount_codes_supported?
7
+ # instead (see Capability#advertised_for?).
8
+ DISCOUNT = Portage::Ucp::Capability.new(
9
+ name: "dev.ucp.shopping.discount",
10
+ version: "1",
11
+ actions: {},
12
+ predicate: :discount_codes_supported?
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # schemas/shopping/fulfillment.json — extends Checkout with a
5
+ # `fulfillment` field (shipping/pickup methods, groups, destinations)
6
+ # rather than adding actions of its own, same predicate-based
7
+ # advertisement as DISCOUNT above.
8
+ FULFILLMENT = Portage::Ucp::Capability.new(
9
+ name: "dev.ucp.shopping.fulfillment",
10
+ version: "1",
11
+ actions: {},
12
+ predicate: :fulfillment_supported?
13
+ )
14
+ end
15
+ end
16
+ end
@@ -7,7 +7,7 @@ module Portage
7
7
  actions: { "link_identity" => :link_identity }
8
8
  )
9
9
 
10
- ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING].freeze
10
+ ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT].freeze
11
11
  end
12
12
  end
13
13
  end
@@ -4,7 +4,12 @@ module Portage
4
4
  ORDER = Portage::Ucp::Capability.new(
5
5
  name: "dev.ucp.shopping.order",
6
6
  version: "1",
7
- actions: { "get_order" => :get_order }
7
+ actions: {
8
+ "get_order" => :get_order,
9
+ "cancel_order" => :cancel_order,
10
+ "request_return" => :request_return,
11
+ "refund_order" => :refund_order
12
+ }
8
13
  )
9
14
  end
10
15
  end
@@ -5,15 +5,25 @@ module Portage
5
5
  class Capability
6
6
  attr_reader :name, :version, :actions
7
7
 
8
- def initialize(name:, version:, actions:)
8
+ # `predicate:` is for extension capabilities like
9
+ # dev.ucp.shopping.discount and dev.ucp.shopping.fulfillment that add a
10
+ # param to an existing action rather than an action of their own —
11
+ # there's no dedicated method whose override signals support, so the
12
+ # adapter exposes a boolean method instead and the capability asks it
13
+ # directly rather than inspecting `actions`.
14
+ def initialize(name:, version:, actions:, predicate: nil)
9
15
  @name = name
10
16
  @version = version
11
17
  @actions = actions
18
+ @predicate = predicate
12
19
  end
13
20
 
14
- # Advertised only if at least one backing Adapter method is overridden —
21
+ # Advertised if the predicate says so, or (for ordinary action-based
22
+ # capabilities) if at least one backing Adapter method is overridden —
15
23
  # see Portage::Ucp::Adapter for why (contract can grow without breaking adapters).
16
24
  def advertised_for?(adapter)
25
+ return adapter.public_send(@predicate) if @predicate
26
+
17
27
  actions.values.any? { |method_name| overridden?(adapter, method_name) }
18
28
  end
19
29
 
@@ -8,5 +8,13 @@ module Portage
8
8
  class AuthenticationError < Error; end
9
9
  class RawPanRejectedError < Error; end
10
10
  class RateLimitExceededError < Error; end
11
+ # Raised by #complete_checkout when the platform rejects completion
12
+ # because a line item is out of stock or otherwise unavailable —
13
+ # design-log §16 "Stock/availability going stale": the gem re-checks by
14
+ # surfacing the platform's own rejection rather than adding a separate
15
+ # check_availability call agents could forget to make. Maps to UCP's
16
+ # standardized "out_of_stock"/"item_unavailable" error codes
17
+ # (schemas/shopping/types/error_code.json).
18
+ class OutOfStockError < Error; end
11
19
  end
12
20
  end
@@ -49,13 +49,183 @@ module Portage
49
49
  end
50
50
  end
51
51
 
52
+ # schemas/shopping/discount.json#/$defs/applied_discount — requires title/
53
+ # amount. `code` is omitted for automatic discounts; `allocations` (the
54
+ # per-target breakdown) is left empty rather than guessed at by adapters
55
+ # that can't source it from their platform.
56
+ # `allocation_method` maps to the wire key "method" — bare `:method` as a
57
+ # Data.define member would shadow Kernel#method.
58
+ AppliedDiscount = Data.define(:title, :amount, :code, :automatic, :allocation_method, :priority, :provisional,
59
+ :eligibility, :allocations) do
60
+ def initialize(title:, amount:, code: nil, automatic: false, allocation_method: nil, priority: nil,
61
+ provisional: false, eligibility: nil, allocations: [])
62
+ super
63
+ end
64
+
65
+ def to_wire_h
66
+ h = { "title" => title, "amount" => amount }
67
+ { code: "code", automatic: "automatic", allocation_method: "method", priority: "priority",
68
+ provisional: "provisional", eligibility: "eligibility" }.each do |attr, key|
69
+ h[key] = public_send(attr) if public_send(attr)
70
+ end
71
+ h["allocations"] = allocations if allocations.any?
72
+ h
73
+ end
74
+ end
75
+
76
+ # schemas/shopping/types/postal_address.json — every field optional, so a
77
+ # partial address (e.g. locality/region/country only, before a full
78
+ # street address is known) is still schema-conformant.
79
+ PostalAddress = Data.define(:extended_address, :street_address, :address_locality, :address_region,
80
+ :address_country, :postal_code, :first_name, :last_name, :phone_number) do
81
+ def initialize(extended_address: nil, street_address: nil, address_locality: nil, address_region: nil,
82
+ address_country: nil, postal_code: nil, first_name: nil, last_name: nil, phone_number: nil)
83
+ super
84
+ end
85
+
86
+ def to_wire_h
87
+ { "extended_address" => extended_address, "street_address" => street_address,
88
+ "address_locality" => address_locality, "address_region" => address_region,
89
+ "address_country" => address_country, "postal_code" => postal_code, "first_name" => first_name,
90
+ "last_name" => last_name, "phone_number" => phone_number }.compact
91
+ end
92
+ end
93
+
94
+ # schemas/shopping/types/shipping_destination.json — postal_address plus a
95
+ # destination id (one of fulfillment.json's two FulfillmentDestination
96
+ # shapes, alongside RetailLocation below).
97
+ ShippingDestination = Data.define(:id, :address) do
98
+ def to_wire_h = address.to_wire_h.merge("id" => id)
99
+ end
100
+
101
+ # schemas/shopping/types/retail_location.json — the pickup counterpart to
102
+ # ShippingDestination; only `address` is optional.
103
+ RetailLocation = Data.define(:id, :name, :address) do
104
+ def initialize(id:, name:, address: nil) = super
105
+
106
+ def to_wire_h
107
+ h = { "id" => id, "name" => name }
108
+ h["address"] = address.to_wire_h if address
109
+ h
110
+ end
111
+ end
112
+
113
+ # schemas/shopping/types/fulfillment_option.json — a single priced choice
114
+ # within a FulfillmentGroup (e.g. "Standard Shipping $5", "Express $15").
115
+ FulfillmentOption = Data.define(:id, :title, :totals, :description, :carrier, :earliest_fulfillment_time,
116
+ :latest_fulfillment_time) do
117
+ def initialize(id:, title:, totals:, description: nil, carrier: nil, earliest_fulfillment_time: nil,
118
+ latest_fulfillment_time: nil)
119
+ super
120
+ end
121
+
122
+ def to_wire_h
123
+ h = { "id" => id, "title" => title, "totals" => totals.map(&:to_wire_h) }
124
+ h["description"] = description if description
125
+ h["carrier"] = carrier if carrier
126
+ h["earliest_fulfillment_time"] = earliest_fulfillment_time if earliest_fulfillment_time
127
+ h["latest_fulfillment_time"] = latest_fulfillment_time if latest_fulfillment_time
128
+ h
129
+ end
130
+ end
131
+
132
+ # schemas/shopping/types/fulfillment_group.json — a merchant-generated
133
+ # package of line items; the agent sets `selected_option_id` on update to
134
+ # choose a shipping/pickup rate for that package.
135
+ FulfillmentGroup = Data.define(:id, :line_item_ids, :options, :selected_option_id) do
136
+ def initialize(id:, line_item_ids:, options: [], selected_option_id: nil) = super
137
+
138
+ def to_wire_h
139
+ { "id" => id, "line_item_ids" => line_item_ids, "options" => options.map(&:to_wire_h),
140
+ "selected_option_id" => selected_option_id }
141
+ end
142
+ end
143
+
144
+ # schemas/shopping/types/fulfillment_method.json — shipping or pickup for
145
+ # a subset of line items. The agent submits `type`/`line_item_ids` on
146
+ # create; the merchant fills in `destinations`/`groups` for the agent to
147
+ # then select `selected_destination_id` and each group's
148
+ # `selected_option_id` on update.
149
+ FulfillmentMethod = Data.define(:id, :type, :line_item_ids, :destinations, :selected_destination_id, :groups) do
150
+ def initialize(id:, type:, line_item_ids:, destinations: [], selected_destination_id: nil, groups: [])
151
+ super
152
+ end
153
+
154
+ def to_wire_h
155
+ h = { "id" => id, "type" => type, "line_item_ids" => line_item_ids }
156
+ h["destinations"] = destinations.map(&:to_wire_h) unless destinations.empty?
157
+ h["selected_destination_id"] = selected_destination_id if selected_destination_id
158
+ h["groups"] = groups.map(&:to_wire_h) unless groups.empty?
159
+ h
160
+ end
161
+ end
162
+
163
+ # schemas/shopping/types/fulfillment_available_method.json — an inventory
164
+ # availability hint (preorder/pickup-today/etc.), independent of whether
165
+ # the buyer has actually chosen that method yet.
166
+ FulfillmentAvailableMethod = Data.define(:type, :line_item_ids, :fulfillable_on, :description) do
167
+ def initialize(type:, line_item_ids:, fulfillable_on: nil, description: nil) = super
168
+
169
+ def to_wire_h
170
+ h = { "type" => type, "line_item_ids" => line_item_ids }
171
+ h["fulfillable_on"] = fulfillable_on if fulfillable_on
172
+ h["description"] = description if description
173
+ h
174
+ end
175
+ end
176
+
177
+ # schemas/shopping/fulfillment.json#/$defs/dev.ucp.shopping.fulfillment —
178
+ # the Checkout-level container. Named CheckoutFulfillment (not
179
+ # `Fulfillment`, already taken by Order's post-purchase expectations/
180
+ # events container below) to keep the pre-purchase shipping-selection
181
+ # extension and the post-purchase delivery-tracking extension distinct.
182
+ # The member is `shipping_methods`, not the wire key `methods` — a bare
183
+ # `:methods` Data.define member would shadow Kernel#methods.
184
+ CheckoutFulfillment = Data.define(:shipping_methods, :available_methods) do
185
+ def initialize(shipping_methods: [], available_methods: []) = super
186
+
187
+ def empty? = shipping_methods.empty? && available_methods.empty?
188
+
189
+ def to_wire_h
190
+ h = {}
191
+ h["methods"] = shipping_methods.map(&:to_wire_h) unless shipping_methods.empty?
192
+ h["available_methods"] = available_methods.map(&:to_wire_h) unless available_methods.empty?
193
+ h
194
+ end
195
+ end
196
+
197
+ # schemas/shopping/discount.json#/$defs/discounts_object — `codes` is the
198
+ # request-side input (case-insensitive, full-replacement — send [] to
199
+ # clear); `applied` is the response-side result. Both optional, so a Cart/
200
+ # Checkout with no discount activity omits the whole field (see Cart/
201
+ # Checkout#to_wire_h below), same posture as Order#adjustments.
202
+ Discounts = Data.define(:codes, :applied) do
203
+ def initialize(codes: [], applied: []) = super
204
+
205
+ def empty? = codes.empty? && applied.empty?
206
+
207
+ def to_wire_h
208
+ h = {}
209
+ h["codes"] = codes unless codes.empty?
210
+ h["applied"] = applied.map(&:to_wire_h) unless applied.empty?
211
+ h
212
+ end
213
+ end
214
+
52
215
  # schemas/shopping/cart.json — requires ucp/id/line_items/currency/totals.
53
216
  # The "ucp" envelope is added centrally by Portage::Ucp::WireEnvelope, not stored
54
217
  # on the value object itself.
55
- Cart = Data.define(:id, :line_items, :currency, :totals) do
218
+ # `discounts` is the dev.ucp.shopping.discount extension (optional on both
219
+ # cart.json and its own schema) — omitted from the wire payload when empty,
220
+ # same as Order#adjustments.
221
+ Cart = Data.define(:id, :line_items, :currency, :totals, :discounts) do
222
+ def initialize(id:, line_items:, currency:, totals:, discounts: Discounts.new) = super
223
+
56
224
  def to_wire_h
57
- { "id" => id, "line_items" => line_items.map(&:to_wire_h), "currency" => currency,
58
- "totals" => totals.map(&:to_wire_h) }
225
+ h = { "id" => id, "line_items" => line_items.map(&:to_wire_h), "currency" => currency,
226
+ "totals" => totals.map(&:to_wire_h) }
227
+ h["discounts"] = discounts.to_wire_h unless discounts.empty?
228
+ h
59
229
  end
60
230
  end
61
231
 
@@ -78,13 +248,23 @@ module Portage
78
248
  # canceled) — not the old ad hoc "pending"/"completed" strings. `order` is
79
249
  # the schema's optional order_confirmation — set once complete_checkout
80
250
  # actually produces an order, nil otherwise.
81
- Checkout = Data.define(:id, :status, :line_items, :currency, :totals, :links, :order) do
82
- def initialize(id:, status:, line_items:, currency:, totals:, links:, order: nil) = super
251
+ # `discounts` same dev.ucp.shopping.discount extension as Cart, above.
252
+ # `fulfillment` the dev.ucp.shopping.fulfillment extension (optional,
253
+ # omitted from the wire payload when empty), same posture as
254
+ # Order#adjustments.
255
+ Checkout = Data.define(:id, :status, :line_items, :currency, :totals, :links, :order, :discounts,
256
+ :fulfillment) do
257
+ def initialize(id:, status:, line_items:, currency:, totals:, links:, order: nil, discounts: Discounts.new,
258
+ fulfillment: CheckoutFulfillment.new)
259
+ super
260
+ end
83
261
 
84
262
  def to_wire_h
85
263
  h = { "id" => id, "status" => status, "line_items" => line_items.map(&:to_wire_h),
86
264
  "currency" => currency, "totals" => totals.map(&:to_wire_h), "links" => links.map(&:to_wire_h) }
87
265
  h["order"] = order.to_wire_h if order
266
+ h["discounts"] = discounts.to_wire_h unless discounts.empty?
267
+ h["fulfillment"] = fulfillment.to_wire_h unless fulfillment.empty?
88
268
  h
89
269
  end
90
270
  end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.1.0".freeze
3
+ VERSION = "0.2.0".freeze
4
4
  end
5
5
  end
data/lib/portage/ucp.rb CHANGED
@@ -28,6 +28,8 @@ require_relative "ucp/capabilities/catalog"
28
28
  require_relative "ucp/capabilities/cart"
29
29
  require_relative "ucp/capabilities/checkout"
30
30
  require_relative "ucp/capabilities/order"
31
+ require_relative "ucp/capabilities/discount"
32
+ require_relative "ucp/capabilities/fulfillment"
31
33
  require_relative "ucp/capabilities/identity_linking"
32
34
  require_relative "ucp/capability_registry"
33
35
  require_relative "ucp/capability_negotiator"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: base64
@@ -138,7 +137,6 @@ dependencies:
138
137
  version: '0.9'
139
138
  description: 'Protocol-only core gem: Adapter contract, capability registry, manifest
140
139
  builder, and an MCP server wrapper. No commerce-backend deps.'
141
- email:
142
140
  executables:
143
141
  - portage-ucp-check
144
142
  extensions: []
@@ -154,6 +152,8 @@ files:
154
152
  - lib/portage/ucp/capabilities/cart.rb
155
153
  - lib/portage/ucp/capabilities/catalog.rb
156
154
  - lib/portage/ucp/capabilities/checkout.rb
155
+ - lib/portage/ucp/capabilities/discount.rb
156
+ - lib/portage/ucp/capabilities/fulfillment.rb
157
157
  - lib/portage/ucp/capabilities/identity_linking.rb
158
158
  - lib/portage/ucp/capabilities/order.rb
159
159
  - lib/portage/ucp/capability.rb
@@ -261,7 +261,6 @@ metadata:
261
261
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp
262
262
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp/CHANGELOG.md
263
263
  rubygems_mfa_required: 'true'
264
- post_install_message:
265
264
  rdoc_options: []
266
265
  require_paths:
267
266
  - lib
@@ -276,8 +275,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
276
275
  - !ruby/object:Gem::Version
277
276
  version: '0'
278
277
  requirements: []
279
- rubygems_version: 3.5.22
280
- signing_key:
278
+ rubygems_version: 4.0.18
281
279
  specification_version: 4
282
280
  summary: Expose a commerce backend to AI shopping agents over MCP and UCP
283
281
  test_files: []