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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7615fe9307cfc4a996c1f1842bcad557e4b2f67b336516d0dc767f5cbfb88c7b
4
- data.tar.gz: b220392e3d403db32f750805fb18ec008b8c7c23a55f6f15ce0a6cddc71be8ff
3
+ metadata.gz: 9c0a19a01df5cf624b7e08f79b9a8e21a8d9d348ce68469c932233f77e8f65b7
4
+ data.tar.gz: ab6342cc1a9045662a0caba73858f2d1fc97f253c2df1593ab1ca629cc870055
5
5
  SHA512:
6
- metadata.gz: 9664fd1f78821b3afeac62b2fd9cd09971e9daa279aa65e4f1de44ebe7989ccc5d180e7efc0fe9b9ab6b4ca0320a851dd045aae5f736e44bcb64a237b4ac948e
7
- data.tar.gz: 1f2bc7debcc1afb3f54fa4e6072391c76f3347df24e462488db69ab33e67c92fca6129dc6d1b18442e503a7d53f3b2ca2f5f284061e88cf9c4793e42f02ea6b9
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
- total = Array(result.totals).find { |t| t.type == "total" }
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.find { |t| t.type == "total" }.amount / order_line.quantity
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.find { |t| t.type == "total" }.amount }
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)]
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.8.0".freeze
3
+ VERSION = "0.9.0".freeze
4
4
  end
5
5
  end
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.8.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: 1980-01-02 00:00:00.000000000 Z
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: 4.0.21
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: []