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 +4 -4
- data/CHANGELOG.md +27 -1
- data/lib/portage/ucp/adapter.rb +64 -4
- data/lib/portage/ucp/capabilities/discount.rb +16 -0
- data/lib/portage/ucp/capabilities/fulfillment.rb +16 -0
- data/lib/portage/ucp/capabilities/identity_linking.rb +1 -1
- data/lib/portage/ucp/capabilities/order.rb +6 -1
- data/lib/portage/ucp/capability.rb +12 -2
- data/lib/portage/ucp/errors.rb +8 -0
- data/lib/portage/ucp/value_objects.rb +185 -5
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +2 -0
- metadata +5 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: add1fc4dcd1b12c3dea3ade248972c2fd2616233b074230838daccce41c9033d
|
|
4
|
+
data.tar.gz: e75364c190c83ee0b9f6bc43f2aca2ef5067d8a4baf3ad5c1b6534fd1096abd7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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`,
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -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:
|
|
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
|
|
@@ -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: {
|
|
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
|
-
|
|
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
|
|
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
|
|
data/lib/portage/ucp/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
data/lib/portage/ucp/version.rb
CHANGED
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.
|
|
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:
|
|
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:
|
|
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: []
|