portage-ucp 0.8.0 → 0.9.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 +31 -0
- data/README.md +1 -0
- data/lib/portage/ucp/dispatcher.rb +1 -2
- data/lib/portage/ucp/reference_adapter.rb +2 -2
- data/lib/portage/ucp/resolver.rb +45 -2
- data/lib/portage/ucp/schema_validator.rb +5 -1
- data/lib/portage/ucp/support/escalation.rb +31 -0
- data/lib/portage/ucp/support/offer_ranking.rb +30 -0
- data/lib/portage/ucp/support/totals.rb +15 -0
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +2 -0
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c0a19a01df5cf624b7e08f79b9a8e21a8d9d348ce68469c932233f77e8f65b7
|
|
4
|
+
data.tar.gz: ab6342cc1a9045662a0caba73858f2d1fc97f253c2df1593ab1ca629cc870055
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 830f760577b2d590337cb38eced77dc7d8a4b102f0e946dd6e8fcfce24078ebb754334615707217ce6b2e9ce1f966b45e830c6dfd14e7dd220515a72644375b8
|
|
7
|
+
data.tar.gz: 28139b13e1cc2716f16be0b3bfe35182e9bd48b5508a5e61f4bde1a164e93caf6d9b525d87def91153fea157b789b591fdb179251eb11d377a749b1eb6d1d413
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ 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.9.0] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
- New `Support::OfferRanking` and `Support::Escalation`: the offer-ranking
|
|
10
|
+
and escalation rules, held in core the way `PolicyGuard` holds the policy
|
|
11
|
+
rule. `portage-ucp-decision`'s `OfferRanking` and `EscalationPolicy` wrap
|
|
12
|
+
them, and `portage-cli` calls them directly, so there's one copy of each
|
|
13
|
+
rule instead of a gem copy and a CLI fallback.
|
|
14
|
+
- `OfferRanking.rank(offers) { |offer| [buyable, amount] }` puts buyable
|
|
15
|
+
offers first, then priced, then cheapest, and keeps ties in input
|
|
16
|
+
order.
|
|
17
|
+
- `Escalation.reason(checkout_status:, warnings:)` returns
|
|
18
|
+
`:requires_escalation`, then `:mismatch` for any warning, else nil.
|
|
19
|
+
- New `Support::Totals.amount(totals, type: "total")`, the reader for the
|
|
20
|
+
arrays `Totals.summary`/`Totals.line` build. It takes `Total` value
|
|
21
|
+
objects or wire hashes, and returns nil when there's no entry of that
|
|
22
|
+
type. `Dispatcher` and `ReferenceAdapter` use it in place of their own
|
|
23
|
+
copies of the lookup, as does `portage-cli`.
|
|
24
|
+
- Fix: `SchemaValidator` failed to load any vendored UCP schema in a process
|
|
25
|
+
with no `LANG` set (a bare Docker image, a CI runner). The schemas contain
|
|
26
|
+
non-ASCII, and Ruby then reads files as US-ASCII, so every validation
|
|
27
|
+
raised `Encoding::InvalidByteSequenceError`. It now reads them as UTF-8.
|
|
28
|
+
|
|
29
|
+
## [0.8.1] - 2026-09-22
|
|
30
|
+
|
|
31
|
+
- `Resolver`'s WooCommerce platform entry threads `payment_method`/
|
|
32
|
+
`billing_address` from `WOOCOMMERCE_PAYMENT_METHOD`/`WOOCOMMERCE_BILLING_ADDRESS`
|
|
33
|
+
through to `Adapter.new`. It previously built the client and adapter
|
|
34
|
+
without either, so `complete_checkout` failed with no gateway or address
|
|
35
|
+
configured even when both env vars were set — the resolver's `env:` map
|
|
36
|
+
simply never named them.
|
|
37
|
+
|
|
7
38
|
## [0.8.0] - 2026-09-17
|
|
8
39
|
|
|
9
40
|
- **Breaking:** `Manifest#to_h` now emits the shape live UCP stores actually
|
data/README.md
CHANGED
|
@@ -30,6 +30,7 @@ in this gem.
|
|
|
30
30
|
| `Portage::Ucp::Support::OrderLedger` | Durable snapshot written after settlement, alongside (not instead of) the transaction record — a failed snapshot write surfaces without flipping an already-settled charge to failed. |
|
|
31
31
|
| `Portage::Ucp::Confirmer` | Gate run just before `complete_checkout` dispatch, after `PolicyGuard`. `Confirmer::Terminal` blocks on stdin and fails closed on anything but an explicit `"y"`; `Confirmer::AutoApprove` is for specs/conformance kits that need a real `confirm!` without blocking; `Confirmer::Webhook` is an out-of-band transport (POST + poll a status URL, or a caller-supplied `wait:` callback) for a Slack/WhatsApp/etc. approval flow — same fail-closed-on-timeout contract, its own longer default timeout. |
|
|
32
32
|
| `Portage::Ucp::PolicyGuard` / `Portage::Ucp::Policy` | Per-transaction/rolling/velocity caps and a merchant allowlist, checked before `complete_checkout` dispatch; configured via `portage-cli`'s `portage policy show/set`. |
|
|
33
|
+
| `Portage::Ucp::Support::OfferRanking` / `Portage::Ucp::Support::Escalation` | The offer-ranking rule (buyable, then priced, then cheapest, ties stable) and the escalation rule (`requires_escalation`, then a mismatch). `portage-ucp-decision` wraps both as typed verdicts; `portage-cli` calls them directly. |
|
|
33
34
|
| `Portage::Ucp::PaymentEnrollmentGuard` | Validates every `create_payment_enrollment`/`get_payment_enrollment` result an `Adapter` returns — `status` must be `"pending"` (with a `setup_url`, no `payment_token`) or `"complete"` (with a `payment_token`, no `setup_url`). Runs automatically in `Dispatcher#call`. |
|
|
34
35
|
| `Portage::Ucp::Ap2::PaymentMandate` / `Portage::Ucp::Ap2::MandateGuard` | A typed shape for an AP2 payment mandate, and shape-only validation (required fields + expiry — not cryptographic verification) run automatically on any `mandate:` argument passed through `Dispatcher#call`. |
|
|
35
36
|
|
|
@@ -230,8 +230,7 @@ module Portage
|
|
|
230
230
|
# `Total#amount` is a bare integer minor-unit amount (the parent
|
|
231
231
|
# object's `currency` applies), not a Money struct — see
|
|
232
232
|
# value_objects.rb's Total/Item comments.
|
|
233
|
-
|
|
234
|
-
total&.amount
|
|
233
|
+
Support::Totals.amount(result.totals)
|
|
235
234
|
end
|
|
236
235
|
|
|
237
236
|
def settled_currency(result)
|
|
@@ -301,7 +301,7 @@ module Portage
|
|
|
301
301
|
|
|
302
302
|
amount = line_items.sum do |li|
|
|
303
303
|
order_line = order.line_items.find { |oli| oli.id == li[:id] }
|
|
304
|
-
unit_price = order_line.totals
|
|
304
|
+
unit_price = Portage::Ucp::Support::Totals.amount(order_line.totals) / order_line.quantity
|
|
305
305
|
unit_price * li[:quantity]
|
|
306
306
|
end
|
|
307
307
|
[Portage::Ucp::Total.new(type: "total", amount: -amount)]
|
|
@@ -372,7 +372,7 @@ module Portage
|
|
|
372
372
|
end
|
|
373
373
|
|
|
374
374
|
def totals_for(line_items, discounts)
|
|
375
|
-
subtotal = line_items.sum { |li| li.totals
|
|
375
|
+
subtotal = line_items.sum { |li| Portage::Ucp::Support::Totals.amount(li.totals) }
|
|
376
376
|
discount_amount = discounts.applied.sum(&:amount)
|
|
377
377
|
[Portage::Ucp::Total.new(type: "subtotal", amount: subtotal),
|
|
378
378
|
Portage::Ucp::Total.new(type: "total", amount: subtotal - discount_amount)]
|
data/lib/portage/ucp/resolver.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
1
3
|
module Portage
|
|
2
4
|
module Ucp
|
|
3
5
|
# Shared platform-detection + adapter-building logic for anything that
|
|
@@ -39,14 +41,16 @@ module Portage
|
|
|
39
41
|
namespace: "WooCommerce",
|
|
40
42
|
markers: [/woocommerce/i, %r{wp-content/plugins/woocommerce}i],
|
|
41
43
|
env: { site_url: "WOOCOMMERCE_SITE_URL", consumer_key: "WOOCOMMERCE_CONSUMER_KEY",
|
|
42
|
-
consumer_secret: "WOOCOMMERCE_CONSUMER_SECRET", currency: "WOOCOMMERCE_CURRENCY"
|
|
44
|
+
consumer_secret: "WOOCOMMERCE_CONSUMER_SECRET", currency: "WOOCOMMERCE_CURRENCY",
|
|
45
|
+
payment_method: "WOOCOMMERCE_PAYMENT_METHOD", billing_address: "WOOCOMMERCE_BILLING_ADDRESS" },
|
|
43
46
|
required: %i[site_url consumer_key consumer_secret],
|
|
44
47
|
build_client: lambda { |ns, env|
|
|
45
48
|
ns::Client.new(site_url: env.fetch(:site_url), consumer_key: env.fetch(:consumer_key),
|
|
46
49
|
consumer_secret: env.fetch(:consumer_secret))
|
|
47
50
|
},
|
|
48
51
|
build_adapter: lambda { |ns, client, env|
|
|
49
|
-
ns::Adapter.new(client: client, site_url: env.fetch(:site_url), currency: env.fetch(:currency, "USD")
|
|
52
|
+
ns::Adapter.new(client: client, site_url: env.fetch(:site_url), currency: env.fetch(:currency, "USD"),
|
|
53
|
+
payment_method: env[:payment_method], billing_address: woocommerce_billing_address(env))
|
|
50
54
|
}
|
|
51
55
|
),
|
|
52
56
|
Platform.new(
|
|
@@ -128,6 +132,45 @@ module Portage
|
|
|
128
132
|
client = platform.build_client.call(namespace, env)
|
|
129
133
|
platform.build_adapter.call(namespace, client, env)
|
|
130
134
|
end
|
|
135
|
+
|
|
136
|
+
# WOOCOMMERCE_BILLING_ADDRESS wins when set (raises ArgumentError, not
|
|
137
|
+
# a raw JSON::ParserError, on malformed JSON — callers building an
|
|
138
|
+
# adapter from env expect a config error to name what's wrong).
|
|
139
|
+
# Otherwise falls back to the same PORTAGE_SHIP_* env `portage buy`
|
|
140
|
+
# already reads for shipping (Portage::Cli::ShippingProfile) mapped to
|
|
141
|
+
# WooCommerce's Store API billing_address field names, so a shopper
|
|
142
|
+
# who already set a shipping profile doesn't also have to hand-build a
|
|
143
|
+
# separate JSON blob just for WooCommerce's stopgap billing_address
|
|
144
|
+
# (see Adapter's class-level CAVEAT #2). Returns nil when neither is
|
|
145
|
+
# configured — #submit_checkout already raises a clear error for that.
|
|
146
|
+
def self.woocommerce_billing_address(env)
|
|
147
|
+
return parse_woocommerce_billing_address(env[:billing_address]) if env[:billing_address]
|
|
148
|
+
|
|
149
|
+
address = WOOCOMMERCE_BILLING_FROM_SHIP_ENV.filter_map do |var, key|
|
|
150
|
+
value = ENV.fetch(var, nil)
|
|
151
|
+
[key, value] if value && !value.empty?
|
|
152
|
+
end.to_h
|
|
153
|
+
address.empty? ? nil : address
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.parse_woocommerce_billing_address(raw)
|
|
157
|
+
JSON.parse(raw)
|
|
158
|
+
rescue JSON::ParserError => e
|
|
159
|
+
raise ArgumentError, "WOOCOMMERCE_BILLING_ADDRESS must be valid JSON: #{e.message}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Same env vars as Portage::Cli::ShippingProfile::ENV_VARS, mapped to
|
|
163
|
+
# WooCommerce's Store API billing_address keys (first_name/last_name/
|
|
164
|
+
# address_1/address_2/city/state/postcode/country/phone) rather than
|
|
165
|
+
# required here as a gem dependency on portage-cli.
|
|
166
|
+
WOOCOMMERCE_BILLING_FROM_SHIP_ENV = {
|
|
167
|
+
"PORTAGE_SHIP_FIRST_NAME" => "first_name", "PORTAGE_SHIP_LAST_NAME" => "last_name",
|
|
168
|
+
"PORTAGE_SHIP_STREET" => "address_1", "PORTAGE_SHIP_EXTENDED" => "address_2",
|
|
169
|
+
"PORTAGE_SHIP_CITY" => "city", "PORTAGE_SHIP_REGION" => "state",
|
|
170
|
+
"PORTAGE_SHIP_POSTAL_CODE" => "postcode", "PORTAGE_SHIP_COUNTRY" => "country",
|
|
171
|
+
"PORTAGE_SHIP_PHONE" => "phone"
|
|
172
|
+
}.freeze
|
|
173
|
+
private_constant :WOOCOMMERCE_BILLING_FROM_SHIP_ENV
|
|
131
174
|
end
|
|
132
175
|
end
|
|
133
176
|
end
|
|
@@ -66,8 +66,12 @@ module Portage
|
|
|
66
66
|
load(relative_path)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# Explicit encoding: the vendored schemas contain non-ASCII (e.g. "—"),
|
|
70
|
+
# and a process with no LANG set (a bare Docker image, a CI runner)
|
|
71
|
+
# defaults Encoding.default_external to US-ASCII, which made every
|
|
72
|
+
# schema load raise Encoding::InvalidByteSequenceError.
|
|
69
73
|
def load(relative_path)
|
|
70
|
-
JSON.parse(File.read(File.join(@base_dir, relative_path)))
|
|
74
|
+
JSON.parse(File.read(File.join(@base_dir, relative_path), encoding: "UTF-8"))
|
|
71
75
|
end
|
|
72
76
|
end
|
|
73
77
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Support
|
|
4
|
+
# The escalation rule (docs/plans/system-one-decision-layer.md
|
|
5
|
+
# § Responsibilities 2), held in core for the same reason as
|
|
6
|
+
# OfferRanking: portage-ucp-decision's EscalationPolicy wraps it, and
|
|
7
|
+
# portage-cli calls it directly.
|
|
8
|
+
#
|
|
9
|
+
# The store's own `requires_escalation` wins over a mismatch the
|
|
10
|
+
# caller found, so a checkout that is both reports the store's answer.
|
|
11
|
+
module Escalation
|
|
12
|
+
STATUS = "requires_escalation".freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param checkout_status [String, nil] a Checkout#status value.
|
|
17
|
+
# @param warnings [Array<String>, nil] mismatches that should stop
|
|
18
|
+
# the purchase. Any one escalates. Pass none when mismatches are
|
|
19
|
+
# only to be reported.
|
|
20
|
+
# @return [Symbol, nil] `:requires_escalation`, `:mismatch`, or nil
|
|
21
|
+
# to keep going.
|
|
22
|
+
def reason(checkout_status:, warnings: [])
|
|
23
|
+
return :requires_escalation if checkout_status == STATUS
|
|
24
|
+
return :mismatch if Array(warnings).any?
|
|
25
|
+
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Support
|
|
4
|
+
# The offer-ranking rule (docs/plans/system-one-decision-layer.md
|
|
5
|
+
# § Responsibilities 1), held in core the way PolicyGuard holds the
|
|
6
|
+
# policy rule. portage-ucp-decision's OfferRanking is a typed wrapper
|
|
7
|
+
# around it, and portage-cli calls it directly, so `portage find`
|
|
8
|
+
# ranks the same way whether or not that gem is installed.
|
|
9
|
+
#
|
|
10
|
+
# Buyable first, then priced, then cheapest, with ties kept in input
|
|
11
|
+
# order. Sorting on price alone would float a browse-only offer above
|
|
12
|
+
# one you can actually check out from.
|
|
13
|
+
module OfferRanking
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param offers [Array] in whatever shape the caller holds them.
|
|
17
|
+
# @yieldparam offer one element of `offers`.
|
|
18
|
+
# @yieldreturn [Array(Boolean, Integer)] whether the offer can be
|
|
19
|
+
# checked out, and its minor-unit amount (nil when unpriced).
|
|
20
|
+
# @return [Array] the same offers, ranked.
|
|
21
|
+
def rank(offers)
|
|
22
|
+
offers.sort_by.with_index do |offer, index|
|
|
23
|
+
buyable, amount = yield(offer)
|
|
24
|
+
[buyable ? 0 : 1, amount ? 0 : 1, amount || 0, index]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -25,6 +25,21 @@ module Portage
|
|
|
25
25
|
[Portage::Ucp::Total.new(type: "subtotal", amount: subtotal),
|
|
26
26
|
Portage::Ucp::Total.new(type: "total", amount: total)]
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# The reader for the arrays above: the amount of the first entry of
|
|
30
|
+
# `type`, or nil when there is none. Takes `Total` value objects or
|
|
31
|
+
# their wire hashes (string- or symbol-keyed), since callers hold
|
|
32
|
+
# either — an adapter's own result, or a checkout read back through
|
|
33
|
+
# a Session.
|
|
34
|
+
def amount(totals, type: "total")
|
|
35
|
+
entry = Array(totals).find { |total| field(total, :type) == type }
|
|
36
|
+
entry && field(entry, :amount)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def field(total, name)
|
|
40
|
+
total.is_a?(Hash) ? total.fetch(name.to_s) { total[name] } : total.public_send(name)
|
|
41
|
+
end
|
|
42
|
+
private_class_method :field
|
|
28
43
|
end
|
|
29
44
|
end
|
|
30
45
|
end
|
data/lib/portage/ucp/version.rb
CHANGED
data/lib/portage/ucp.rb
CHANGED
|
@@ -10,6 +10,8 @@ require_relative "ucp/adapter"
|
|
|
10
10
|
# conversion, dedup table and HTTP plumbing.
|
|
11
11
|
require_relative "ucp/support/amounts"
|
|
12
12
|
require_relative "ucp/support/totals"
|
|
13
|
+
require_relative "ucp/support/offer_ranking"
|
|
14
|
+
require_relative "ucp/support/escalation"
|
|
13
15
|
require_relative "ucp/support/line_item_status"
|
|
14
16
|
require_relative "ucp/support/idempotency"
|
|
15
17
|
require_relative "ucp/support/checkout_state"
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-ucp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: exe
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: base64
|
|
@@ -137,6 +138,7 @@ dependencies:
|
|
|
137
138
|
version: '0.9'
|
|
138
139
|
description: 'Protocol-only core gem: Adapter contract, capability registry, manifest
|
|
139
140
|
builder, and an MCP server wrapper. No commerce-backend deps.'
|
|
141
|
+
email:
|
|
140
142
|
executables:
|
|
141
143
|
- portage-ucp-check
|
|
142
144
|
extensions: []
|
|
@@ -194,12 +196,14 @@ files:
|
|
|
194
196
|
- lib/portage/ucp/support/amounts.rb
|
|
195
197
|
- lib/portage/ucp/support/api_error.rb
|
|
196
198
|
- lib/portage/ucp/support/checkout_state.rb
|
|
199
|
+
- lib/portage/ucp/support/escalation.rb
|
|
197
200
|
- lib/portage/ucp/support/http_client.rb
|
|
198
201
|
- lib/portage/ucp/support/idempotency.rb
|
|
199
202
|
- lib/portage/ucp/support/idempotency/file_store.rb
|
|
200
203
|
- lib/portage/ucp/support/idempotency/memory_store.rb
|
|
201
204
|
- lib/portage/ucp/support/line_item_status.rb
|
|
202
205
|
- lib/portage/ucp/support/not_found.rb
|
|
206
|
+
- lib/portage/ucp/support/offer_ranking.rb
|
|
203
207
|
- lib/portage/ucp/support/order_ledger.rb
|
|
204
208
|
- lib/portage/ucp/support/order_ledger/file_store.rb
|
|
205
209
|
- lib/portage/ucp/support/order_ledger/store.rb
|
|
@@ -291,6 +295,7 @@ metadata:
|
|
|
291
295
|
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp
|
|
292
296
|
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp/CHANGELOG.md
|
|
293
297
|
rubygems_mfa_required: 'true'
|
|
298
|
+
post_install_message:
|
|
294
299
|
rdoc_options: []
|
|
295
300
|
require_paths:
|
|
296
301
|
- lib
|
|
@@ -305,7 +310,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
305
310
|
- !ruby/object:Gem::Version
|
|
306
311
|
version: '0'
|
|
307
312
|
requirements: []
|
|
308
|
-
rubygems_version:
|
|
313
|
+
rubygems_version: 3.5.22
|
|
314
|
+
signing_key:
|
|
309
315
|
specification_version: 4
|
|
310
316
|
summary: Expose a commerce backend to AI shopping agents over MCP and UCP
|
|
311
317
|
test_files: []
|