portage-cli 0.6.4 → 0.7.3
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 +216 -0
- data/README.md +263 -7
- data/lib/portage/cli/buy.rb +529 -104
- data/lib/portage/cli/checkout_handoff.rb +6 -18
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +104 -1
- data/lib/portage/cli/find.rb +10 -9
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +26 -27
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +367 -27
- metadata +46 -5
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -5,8 +5,18 @@ require "portage/ucp"
|
|
|
5
5
|
require "portage/ucp/client"
|
|
6
6
|
require "portage/ucp/journal"
|
|
7
7
|
require_relative "payment_methods"
|
|
8
|
+
require_relative "setting"
|
|
9
|
+
require_relative "decisions"
|
|
10
|
+
require_relative "confidence_check"
|
|
8
11
|
require_relative "checkout_handoff"
|
|
9
12
|
require_relative "notifier"
|
|
13
|
+
require_relative "user_agent"
|
|
14
|
+
require_relative "homepage_fetch"
|
|
15
|
+
require_relative "permissive_authenticator"
|
|
16
|
+
require_relative "handoff_reconciler"
|
|
17
|
+
require_relative "handoff_spend_mode"
|
|
18
|
+
require_relative "webmcp"
|
|
19
|
+
require_relative "webmcp_checkout_mode"
|
|
10
20
|
|
|
11
21
|
module Portage
|
|
12
22
|
module Cli
|
|
@@ -37,11 +47,35 @@ module Portage
|
|
|
37
47
|
# webhook URL a dead-end checkout_url is POSTed to — nil (the
|
|
38
48
|
# default) defers to PORTAGE_NOTIFY_WEBHOOK_URL / config.json (see
|
|
39
49
|
# Notifier).
|
|
50
|
+
# @param confidence_check [ConfidenceCheck, nil] the opt-in confidence
|
|
51
|
+
# gate in front of an unattended completion. nil (the default) builds
|
|
52
|
+
# one from PORTAGE_DECISION_BACKEND / PORTAGE_MIN_CONFIDENCE, which is
|
|
53
|
+
# a no-op when no backend is named.
|
|
54
|
+
# @param transaction_log [Portage::Ucp::Support::TransactionLog, nil]
|
|
55
|
+
# where a remote purchase is recorded, and what the spend policy's
|
|
56
|
+
# rolling cap and velocity limit count. nil (the default) is the
|
|
57
|
+
# real ~/.portage/transactions.json, the file Dispatcher writes for
|
|
58
|
+
# own-store purchases.
|
|
59
|
+
# @param max_price [Integer, nil] the most one unit may cost, in minor
|
|
60
|
+
# units (same as Find's). A product priced above it is never picked,
|
|
61
|
+
# even with a --product-id; one with no price to go on still is, as
|
|
62
|
+
# in Find, and the checkout's own total then meets the spend policy.
|
|
63
|
+
# @param webmcp_bridge [#list_tools, #execute_tool, nil] docs/plans/
|
|
64
|
+
# handoff-reconcile.md Phase 4 — a `portage-ucp-webmcp` outbound
|
|
65
|
+
# Bridge (already pointed at a navigated page) an embedding caller
|
|
66
|
+
# already holds. nil (the default, and the only option from the
|
|
67
|
+
# `portage buy` CLI, which has no browser of its own) skips WebMCP
|
|
68
|
+
# entirely — zero behavior change from before this parameter
|
|
69
|
+
# existed. Given one, attempted after native-UCP discovery finds
|
|
70
|
+
# nothing at this URL and before a platform-adapter fallback (see
|
|
71
|
+
# #webmcp_flow).
|
|
72
|
+
# rubocop:disable Metrics/ParameterLists -- all keywords; one per flag, plus injectable collaborators
|
|
40
73
|
def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
|
|
41
|
-
auto_open: nil, notify_webhook: nil
|
|
74
|
+
auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil,
|
|
75
|
+
max_price: nil, webmcp_bridge: nil)
|
|
76
|
+
# rubocop:enable Metrics/ParameterLists
|
|
42
77
|
raw = url.to_s.strip
|
|
43
|
-
|
|
44
|
-
@uri = URI.parse(raw)
|
|
78
|
+
@uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
|
|
45
79
|
@query = query
|
|
46
80
|
@qty = qty
|
|
47
81
|
@payment_token = payment_token
|
|
@@ -50,6 +84,11 @@ module Portage
|
|
|
50
84
|
@product_id = product_id
|
|
51
85
|
@auto_open = auto_open
|
|
52
86
|
@notify_webhook = notify_webhook
|
|
87
|
+
@confidence_check = confidence_check
|
|
88
|
+
@transaction_log = transaction_log
|
|
89
|
+
@max_price = max_price
|
|
90
|
+
@webmcp_bridge = webmcp_bridge
|
|
91
|
+
@decisions = {}
|
|
53
92
|
end
|
|
54
93
|
|
|
55
94
|
# Link `type`s that are never the checkout — see #checkout_url_of.
|
|
@@ -67,6 +106,9 @@ module Portage
|
|
|
67
106
|
return native_flow(session) if session
|
|
68
107
|
end
|
|
69
108
|
|
|
109
|
+
webmcp_result = webmcp_flow
|
|
110
|
+
return webmcp_result if webmcp_result
|
|
111
|
+
|
|
70
112
|
platform = Portage::Ucp::Resolver.detect_platform(body, headers)
|
|
71
113
|
adapter_flow(platform) || dead_end
|
|
72
114
|
end
|
|
@@ -76,7 +118,7 @@ module Portage
|
|
|
76
118
|
# --- Step 1: native UCP manifest ---
|
|
77
119
|
|
|
78
120
|
def discover(url)
|
|
79
|
-
Portage::Ucp::Client.discover(url.to_s)
|
|
121
|
+
Portage::Ucp::Client.discover(url.to_s, headers: UserAgent.headers)
|
|
80
122
|
rescue Portage::Ucp::Client::ManifestShapeError => e
|
|
81
123
|
# The store *is* running UCP — this client just couldn't parse its
|
|
82
124
|
# manifest. Distinct from a genuine 404/unreachable host below:
|
|
@@ -103,11 +145,11 @@ module Portage
|
|
|
103
145
|
def native_flow_error_report(error)
|
|
104
146
|
case error
|
|
105
147
|
when Portage::Ucp::Client::MissingAgentProfileError
|
|
106
|
-
build_report(source: "native_ucp", browse: false, checkout: false,
|
|
148
|
+
build_report(source: "native_ucp", outcome: "agent_profile_missing", browse: false, checkout: false,
|
|
107
149
|
message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — " \
|
|
108
150
|
"#{@uri} verifies it before answering any UCP call.")
|
|
109
151
|
when Portage::Ucp::Client::UnsupportedWireShapeError
|
|
110
|
-
build_report(source: "native_ucp", browse: true, checkout: false,
|
|
152
|
+
build_report(source: "native_ucp", outcome: "unsupported_wire_shape", browse: true, checkout: false,
|
|
111
153
|
message: "Can't complete checkout on #{@uri} yet: #{error.message}")
|
|
112
154
|
when Portage::Ucp::Client::ServerError
|
|
113
155
|
server_error_report(error)
|
|
@@ -117,7 +159,7 @@ module Portage
|
|
|
117
159
|
# explanation — a common cause is PORTAGE_AGENT_PROFILE not
|
|
118
160
|
# pointing at a real, JSON agent-profile document the store's UCP
|
|
119
161
|
# endpoint accepts.
|
|
120
|
-
build_report(source: "native_ucp", browse: false, checkout: false,
|
|
162
|
+
build_report(source: "native_ucp", outcome: "request_rejected", browse: false, checkout: false,
|
|
121
163
|
message: "#{@uri} rejected the request (#{error.message}) — if PORTAGE_AGENT_PROFILE " \
|
|
122
164
|
"is set, check it points at a real agent-profile document the store accepts.")
|
|
123
165
|
end
|
|
@@ -140,7 +182,7 @@ module Portage
|
|
|
140
182
|
def server_error_report(error)
|
|
141
183
|
url = error.continue_url
|
|
142
184
|
build_report(
|
|
143
|
-
source: "native_ucp", browse: true, checkout: false, checkout_url: url,
|
|
185
|
+
source: "native_ucp", outcome: "store_refused", browse: true, checkout: false, checkout_url: url,
|
|
144
186
|
message: "#{@uri} couldn't complete this: #{error.summary}#{url && " — finish it at #{url}"}"
|
|
145
187
|
)
|
|
146
188
|
end
|
|
@@ -148,7 +190,7 @@ module Portage
|
|
|
148
190
|
def catalog_only(session)
|
|
149
191
|
products = safe_search(session)
|
|
150
192
|
report = build_report(
|
|
151
|
-
source: "native_ucp", browse: true, checkout: false, products: products,
|
|
193
|
+
source: "native_ucp", outcome: "browse_only", browse: true, checkout: false, products: products,
|
|
152
194
|
message: "I can browse this store but can't check out via UCP yet."
|
|
153
195
|
)
|
|
154
196
|
merge_adapter_checkout_fallback(report)
|
|
@@ -164,7 +206,13 @@ module Portage
|
|
|
164
206
|
fallback = platform && adapter_flow(platform)
|
|
165
207
|
return report unless fallback && fallback[:checkout]
|
|
166
208
|
|
|
167
|
-
report
|
|
209
|
+
# The original `report[:message]` ("I can browse this store but
|
|
210
|
+
# can't check out via UCP yet.") goes stale the moment `checkout:`
|
|
211
|
+
# flips to true here — leaving it would tell the caller there's no
|
|
212
|
+
# checkout path in the same report that hands them a checkout_url.
|
|
213
|
+
report.merge(source: fallback[:source], checkout: true, checkout_url: fallback[:checkout_url],
|
|
214
|
+
message: "#{report[:message]} Falling back to #{fallback[:source]} — " \
|
|
215
|
+
"follow the link to buy it that way instead.")
|
|
168
216
|
end
|
|
169
217
|
|
|
170
218
|
# --- Step 1 fallback: alternate manifest pointer in <head> ---
|
|
@@ -177,6 +225,64 @@ module Portage
|
|
|
177
225
|
match && URI.join(@uri, match[1])
|
|
178
226
|
end
|
|
179
227
|
|
|
228
|
+
# --- Step 1b: WebMCP outbound (docs/plans/handoff-reconcile.md Phase 4) ---
|
|
229
|
+
#
|
|
230
|
+
# Opt-in only: nil unless a caller passed `webmcp_bridge:` (see
|
|
231
|
+
# #initialize), so `portage buy` from the shell — with no browser of
|
|
232
|
+
# its own — sees no behavior change here at all. Attempted after
|
|
233
|
+
# native-UCP discovery finds nothing at this URL and before falling
|
|
234
|
+
# back to a platform adapter, since a WebMCP page, like native UCP,
|
|
235
|
+
# works without this process holding the store's own credentials.
|
|
236
|
+
#
|
|
237
|
+
# `express_stop` (the only implemented mode) builds the cart and
|
|
238
|
+
# checkout, then always hands off rather than attempting
|
|
239
|
+
# #complete_checkout — which a WebMCP page doesn't expose by default
|
|
240
|
+
# in the first place (ToolCatalog leaves it out: it needs a
|
|
241
|
+
# server-side Confirmer swap this gem doesn't have a seam for yet).
|
|
242
|
+
# That hand-off feeds Phases 1-3 completely unchanged: reason
|
|
243
|
+
# `express_stop` reserves a pending record, notifies, and later
|
|
244
|
+
# reconciles exactly like any other hand-off.
|
|
245
|
+
def webmcp_flow
|
|
246
|
+
return nil unless @webmcp_bridge
|
|
247
|
+
return webmcp_not_installed_report unless Portage::Cli::Webmcp.available?
|
|
248
|
+
|
|
249
|
+
session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge)
|
|
250
|
+
return nil unless session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
|
|
251
|
+
|
|
252
|
+
return webmcp_token_unsupported_report if webmcp_checkout_mode == "token"
|
|
253
|
+
|
|
254
|
+
full_buy(session, source: "webmcp", force_handoff: true)
|
|
255
|
+
rescue Portage::Ucp::WebMcp::BridgeError, Portage::Ucp::WebMcp::ToolNotFoundError,
|
|
256
|
+
Portage::Ucp::Client::ServerError => e
|
|
257
|
+
build_report(source: "webmcp", outcome: "webmcp_error", browse: false, checkout: false,
|
|
258
|
+
message: "WebMCP checkout failed: #{e.message}")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def webmcp_checkout_mode
|
|
262
|
+
Portage::Cli::WebmcpCheckoutMode.resolve
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def webmcp_not_installed_report
|
|
266
|
+
build_report(source: "webmcp", outcome: "webmcp_not_installed", browse: false, checkout: false,
|
|
267
|
+
message: "A WebMCP bridge was given but portage-ucp-webmcp isn't installed — " \
|
|
268
|
+
"`gem install portage-ucp-webmcp`.")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# `token` mode isn't implemented: it needs `Mcp::Server.build` to swap
|
|
272
|
+
# in a payment-token Confirmer for a WebMCP session (see
|
|
273
|
+
# portage-ucp-webmcp's README, "complete_checkout"), plus PayPal/Stripe
|
|
274
|
+
# agent-token enrollment in `portage payment enroll` — neither exists
|
|
275
|
+
# yet (docs/plans/handoff-reconcile.md Phase 4 is explicitly a sketch
|
|
276
|
+
# pending that design). Fails loudly here rather than silently
|
|
277
|
+
# behaving like `express_stop`, so a caller who configured `token`
|
|
278
|
+
# notices instead of getting a hand-off they didn't ask for.
|
|
279
|
+
def webmcp_token_unsupported_report
|
|
280
|
+
build_report(source: "webmcp", outcome: "webmcp_token_unsupported", browse: false, checkout: false,
|
|
281
|
+
message: "webmcp_checkout_mode=token isn't supported yet — see " \
|
|
282
|
+
"docs/plans/handoff-reconcile.md Phase 4. Use express_stop (the default), or " \
|
|
283
|
+
"complete the checkout via the page's own payment button.")
|
|
284
|
+
end
|
|
285
|
+
|
|
180
286
|
# --- Step 2/3: platform detection + adapter fallback ---
|
|
181
287
|
|
|
182
288
|
def adapter_flow(platform)
|
|
@@ -189,6 +295,14 @@ module Portage
|
|
|
189
295
|
adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
|
|
190
296
|
rescue LoadError
|
|
191
297
|
return nil
|
|
298
|
+
rescue StandardError => e
|
|
299
|
+
# Distinct from LoadError above: the gem *is* installed and this
|
|
300
|
+
# platform *was* detected, so a raise here is a real config
|
|
301
|
+
# problem (e.g. malformed WOOCOMMERCE_BILLING_ADDRESS JSON) — same
|
|
302
|
+
# "actionable, don't hide it behind dead_end" posture as
|
|
303
|
+
# #run_adapter_flow's own rescue, just one step earlier.
|
|
304
|
+
return build_report(source: "adapter:#{platform.name}", outcome: "adapter_misconfigured", browse: false,
|
|
305
|
+
checkout: false, message: "#{platform.name} adapter misconfigured: #{e.message}")
|
|
192
306
|
end
|
|
193
307
|
|
|
194
308
|
run_adapter_flow(adapter, platform)
|
|
@@ -206,8 +320,15 @@ module Portage
|
|
|
206
320
|
catalog_only_adapter(adapter, platform)
|
|
207
321
|
end
|
|
208
322
|
rescue StandardError => e
|
|
209
|
-
|
|
210
|
-
|
|
323
|
+
# `e.message` is the store's own text where the adapter raised an
|
|
324
|
+
# ApiError (see Support::ApiError#detail) — quoted verbatim, not
|
|
325
|
+
# replaced with a generic message, so the actual cause (a missing
|
|
326
|
+
# param, an out-of-stock line, a bad gateway id) is visible. Always
|
|
327
|
+
# paired with a next action, since "here's an error" alone leaves the
|
|
328
|
+
# caller to guess whether to retry, reconfigure, or give up.
|
|
329
|
+
build_report(source: "adapter:#{platform.name}", outcome: "adapter_error", browse: false, checkout: false,
|
|
330
|
+
message: "#{platform.name} adapter error: #{e.message} — visit #{@uri} yourself, " \
|
|
331
|
+
"or fix the adapter's env/config and retry.")
|
|
211
332
|
end
|
|
212
333
|
|
|
213
334
|
def adapter_supports_checkout?(adapter)
|
|
@@ -233,7 +354,7 @@ module Portage
|
|
|
233
354
|
products = CatalogProducts.from(adapter.search_catalog(query: @query, limit: 10))
|
|
234
355
|
checkout = redirect_checkout(adapter, products)
|
|
235
356
|
build_report(
|
|
236
|
-
source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
|
|
357
|
+
source: "adapter:#{platform.name}", outcome: "browse_only", browse: true, checkout: !!checkout,
|
|
237
358
|
products: products, checkout_url: checkout && checkout.links.first&.url,
|
|
238
359
|
message: "Found it on #{platform.name}, but checkout there isn't a live UCP transaction — " \
|
|
239
360
|
"#{checkout ? 'follow the link to buy it yourself.' : 'no checkout path at all.'}"
|
|
@@ -258,11 +379,15 @@ module Portage
|
|
|
258
379
|
# remote store, so shipping-address/rate selection stays loopback-only
|
|
259
380
|
# for now rather than guessing at a wire shape no real UCP server has
|
|
260
381
|
# confirmed (see docs/design-log.md).
|
|
261
|
-
|
|
382
|
+
# @param force_handoff [Boolean] docs/plans/handoff-reconcile.md Phase
|
|
383
|
+
# 4's `express_stop` — skips escalation/confirmation/completion
|
|
384
|
+
# entirely and always hands the built checkout off, once past the
|
|
385
|
+
# `--dry-run` check. Only #webmcp_flow ever sets this.
|
|
386
|
+
def full_buy(session, source:, fulfillment_adapter: nil, force_handoff: false)
|
|
262
387
|
products = safe_search(session)
|
|
263
388
|
product = select_product(products)
|
|
264
389
|
unless product
|
|
265
|
-
return build_report(source: source, browse: true, checkout: true, products: products,
|
|
390
|
+
return build_report(source: source, outcome: "no_match", browse: true, checkout: true, products: products,
|
|
266
391
|
message: no_match_message)
|
|
267
392
|
end
|
|
268
393
|
|
|
@@ -270,7 +395,45 @@ module Portage
|
|
|
270
395
|
fulfillment: requested_fulfillment(fulfillment_adapter),
|
|
271
396
|
context: buyer_context, meta: agent_meta)
|
|
272
397
|
checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
|
|
273
|
-
|
|
398
|
+
|
|
399
|
+
warnings = reconcile_checkout(product, checkout)
|
|
400
|
+
finish_checkout(session, source, products, checkout, warnings, force_handoff: force_handoff)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# A store can silently drop the requested line, change its quantity, or
|
|
404
|
+
# price it differently than its own catalog just quoted a moment
|
|
405
|
+
# earlier in #safe_search — none of that raises, since it's a normal
|
|
406
|
+
# (if surprising) checkout response, not a transport error. Buying
|
|
407
|
+
# blind against a mismatch this method could have caught defeats the
|
|
408
|
+
# point of an agent shopping on the buyer's behalf, so this always
|
|
409
|
+
# checks and surfaces what it finds; PORTAGE_ABORT_ON_CHECKOUT_MISMATCH
|
|
410
|
+
# additionally refuses to proceed rather than merely warning.
|
|
411
|
+
def reconcile_checkout(product, checkout)
|
|
412
|
+
item_id = line_item_id_of(product)
|
|
413
|
+
line = Array(checkout["line_items"]).find { |li| li.dig("item", "id") == item_id }
|
|
414
|
+
return ["Store dropped the requested item (#{item_id}) from checkout."] unless line
|
|
415
|
+
|
|
416
|
+
warnings = []
|
|
417
|
+
if line["quantity"] != @qty
|
|
418
|
+
warnings << "Store checked out quantity #{line['quantity']}, not the requested #{@qty}."
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
expected = expected_unit_price(product, item_id)
|
|
422
|
+
actual = line.dig("item", "price")
|
|
423
|
+
if expected && actual && expected != actual
|
|
424
|
+
warnings << "Store priced the item at #{actual} #{checkout['currency']} minor units per unit, " \
|
|
425
|
+
"not the catalog's #{expected}."
|
|
426
|
+
end
|
|
427
|
+
warnings
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def expected_unit_price(product, item_id)
|
|
431
|
+
variant = Array(product["variants"]).find { |v| v["id"] == item_id }
|
|
432
|
+
variant&.dig("price", "amount") || product.dig("price_range", "min", "amount")
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def abort_on_mismatch?
|
|
436
|
+
Setting.flag?(env: "PORTAGE_ABORT_ON_CHECKOUT_MISMATCH")
|
|
274
437
|
end
|
|
275
438
|
|
|
276
439
|
# Submits PORTAGE_SHIP_* (see Portage::Cli::ShippingProfile) as the
|
|
@@ -330,20 +493,52 @@ module Portage
|
|
|
330
493
|
end
|
|
331
494
|
|
|
332
495
|
def no_match_message
|
|
333
|
-
|
|
496
|
+
budget = @max_price ? " at or under #{@max_price} minor units" : ""
|
|
497
|
+
return "No product matched \"#{@query}\"#{budget}." unless @product_id
|
|
334
498
|
|
|
335
|
-
"Product #{@product_id} isn't in this store's results for \"#{@query}\"."
|
|
499
|
+
"Product #{@product_id} isn't in this store's results for \"#{@query}\"#{budget}."
|
|
336
500
|
end
|
|
337
501
|
|
|
338
502
|
# With a --product-id, that exact product or nothing: falling back to the
|
|
339
503
|
# top search hit when the requested id isn't in the results would buy
|
|
340
504
|
# something the caller never chose.
|
|
505
|
+
#
|
|
506
|
+
# Without one, the top hit a shopper could actually buy: checking out a
|
|
507
|
+
# sold-out top hit dead-ends on the store's "Sold out" refusal even
|
|
508
|
+
# when an in-stock match sits right below it (confirmed live
|
|
509
|
+
# 2026-09-23 on allbirds.com and billabong.com). Falls back to the top
|
|
510
|
+
# hit when nothing reports stock, so the store still gets to answer.
|
|
511
|
+
#
|
|
512
|
+
# Either way, only among products within --max-price: before it
|
|
513
|
+
# reached here, `buy <url> --max-price` checked out whatever the store
|
|
514
|
+
# ranked first (confirmed live 2026-09-24: a $679.95 board on
|
|
515
|
+
# burton.com under --max-price 600).
|
|
341
516
|
def select_product(products)
|
|
342
|
-
|
|
517
|
+
products = products.select { |product| within_max_price?(product) }
|
|
518
|
+
return products.find { |product| available?(product) } || products.first unless @product_id
|
|
343
519
|
|
|
344
520
|
products.find { |product| product_id_of(product) == @product_id }
|
|
345
521
|
end
|
|
346
522
|
|
|
523
|
+
# Priced the way #reconcile_checkout expects the store to charge: the
|
|
524
|
+
# variant #line_item_id_of would check out, else the product's lowest
|
|
525
|
+
# price.
|
|
526
|
+
def within_max_price?(product)
|
|
527
|
+
return true unless @max_price
|
|
528
|
+
|
|
529
|
+
price = expected_unit_price(product, line_item_id_of(product))
|
|
530
|
+
price.nil? || price <= @max_price
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# A product with no variant availability to go on counts as buyable —
|
|
534
|
+
# only an explicit `available: false` on every variant rules it out.
|
|
535
|
+
def available?(product)
|
|
536
|
+
variants = Array(product["variants"])
|
|
537
|
+
variants.empty? || variants.any? { |variant| variant_available?(variant) }
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def variant_available?(variant) = variant.dig("availability", "available") != false
|
|
541
|
+
|
|
347
542
|
# #select_product only ever sees products from #safe_search, which reads
|
|
348
543
|
# through a Session — native remote or the own-store loopback session
|
|
349
544
|
# built via Client.for_adapter alike — so Dispatcher#wrap has already
|
|
@@ -362,37 +557,197 @@ module Portage
|
|
|
362
557
|
# #product_id_of returns for display/--product-id matching. A
|
|
363
558
|
# product with no variants (or a backend that doesn't distinguish
|
|
364
559
|
# the two) falls back to the product id unchanged (see
|
|
365
|
-
# docs/design-log.md §41).
|
|
560
|
+
# docs/design-log.md §41). The first in-stock variant wins over the
|
|
561
|
+
# first variant, for the same reason #select_product skips sold-out
|
|
562
|
+
# products (a live Brooklinen sheet set lists its sold-out size first).
|
|
366
563
|
def line_item_id_of(product)
|
|
367
|
-
product["variants"]
|
|
564
|
+
variants = Array(product["variants"])
|
|
565
|
+
(variants.find { |variant| variant_available?(variant) } || variants.first)&.dig("id") ||
|
|
566
|
+
product_id_of(product)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def finish_checkout(session, source, products, checkout, warnings = [], force_handoff: false)
|
|
570
|
+
escalation = decide_escalation(checkout, warnings)
|
|
571
|
+
return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
|
|
572
|
+
return dry_run_report(source, products, checkout, warnings) if @dry_run
|
|
573
|
+
return webmcp_handoff_report(source, products, checkout, warnings) if force_handoff
|
|
574
|
+
return confirmation_needed_report(source, products, checkout, warnings) unless confirmed?
|
|
575
|
+
|
|
576
|
+
complete(session, source, products, checkout, warnings)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Hand off vs. keep going is Decisions.escalation's call
|
|
580
|
+
# (docs/plans/system-one-decision-layer.md § Responsibilities 2): a
|
|
581
|
+
# literal `requires_escalation` status always escalates. A mismatch
|
|
582
|
+
# from #reconcile_checkout escalates only under
|
|
583
|
+
# PORTAGE_ABORT_ON_CHECKOUT_MISMATCH. By default the warnings are
|
|
584
|
+
# surfaced on the report, and the purchase is not stopped for them.
|
|
585
|
+
# The verdict lands on the report's `decisions:`, and the report's
|
|
586
|
+
# `outcome:` names which gate (if any) stopped the purchase.
|
|
587
|
+
def decide_escalation(checkout, warnings)
|
|
588
|
+
verdict = Decisions.escalation(checkout_status: checkout["status"],
|
|
589
|
+
warnings: abort_on_mismatch? ? warnings : [])
|
|
590
|
+
@decisions[:escalation] = verdict
|
|
591
|
+
verdict
|
|
368
592
|
end
|
|
369
593
|
|
|
370
|
-
def
|
|
371
|
-
|
|
372
|
-
return escalation_report(source, products, checkout) if status == "requires_escalation"
|
|
373
|
-
return dry_run_report(source, products, checkout) if @dry_run
|
|
374
|
-
return confirmation_needed_report(source, products, checkout) unless confirmed?
|
|
594
|
+
def escalated_report(source, products, checkout, warnings, verdict)
|
|
595
|
+
return escalation_report(source, products, checkout, warnings) unless verdict[:reason] == "mismatch"
|
|
375
596
|
|
|
376
|
-
|
|
597
|
+
handoff_report(source, products, checkout, warnings,
|
|
598
|
+
outcome: "checkout_mismatch",
|
|
599
|
+
message: "Aborted before purchase — checkout didn't match the request: #{warnings.join(' ')}")
|
|
377
600
|
end
|
|
378
601
|
|
|
379
|
-
|
|
602
|
+
# One guard per gate, in the order they run: a payment token, the
|
|
603
|
+
# buyer's spend policy, the opt-in confidence check, then the store's
|
|
604
|
+
# own answer to the completion.
|
|
605
|
+
def complete(session, source, products, checkout, warnings = [])
|
|
380
606
|
@payment_token ||= PaymentMethods.default
|
|
381
|
-
unless @payment_token
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
checkout_url: url, handoff: hand_off(checkout, reason: "no_payment_token", source: source),
|
|
386
|
-
message: "No --payment-token given, and no default payment method on file — run " \
|
|
387
|
-
"`portage payment enroll` or pass --payment-token, or visit the link to " \
|
|
388
|
-
"finish this checkout yourself."
|
|
389
|
-
)
|
|
390
|
-
end
|
|
607
|
+
return no_payment_token_report(source, products, checkout, warnings) unless @payment_token
|
|
608
|
+
|
|
609
|
+
held = held_report(source, products, checkout, warnings)
|
|
610
|
+
return held if held
|
|
391
611
|
|
|
392
|
-
completed =
|
|
393
|
-
|
|
612
|
+
completed = recording_transaction(source, checkout) do
|
|
613
|
+
session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
|
|
614
|
+
end
|
|
615
|
+
# `complete_checkout` can hand back `requires_escalation` too (e.g.
|
|
616
|
+
# Shopify's cartSubmitForCompletion result carrying `errors`) — this
|
|
617
|
+
# used to report "Purchased." unconditionally regardless of
|
|
618
|
+
# `completed["status"]`, silently misreporting an escalation as a
|
|
619
|
+
# successful purchase.
|
|
620
|
+
return escalation_report(source, products, completed, warnings) if decide_escalation(completed, [])[:escalate]
|
|
621
|
+
|
|
622
|
+
checkout_report(source, products, completed, outcome: "purchased", message: "Purchased.",
|
|
623
|
+
warnings: warnings + Array(@unrecorded_warning))
|
|
394
624
|
rescue Portage::Ucp::Client::PaymentPermissionError
|
|
395
|
-
permission_denied_report(source, products, checkout)
|
|
625
|
+
permission_denied_report(source, products, checkout, warnings)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# PolicyGuard's rolling cap and velocity limit count the completed
|
|
629
|
+
# records in the transaction log. On the own-store loopback path the
|
|
630
|
+
# in-process Dispatcher writes those itself, so only a remote
|
|
631
|
+
# completion is recorded here. Recording both would count a loopback
|
|
632
|
+
# purchase twice.
|
|
633
|
+
#
|
|
634
|
+
# Same shape as Dispatcher's record: reserved `pending` before the
|
|
635
|
+
# store is asked to complete, so a crash mid-charge leaves evidence,
|
|
636
|
+
# then settled. Only a completion that came back purchased settles as
|
|
637
|
+
# `complete`, the one status those limits count.
|
|
638
|
+
def recording_transaction(source, checkout)
|
|
639
|
+
return yield unless source == "native_ucp"
|
|
640
|
+
|
|
641
|
+
key = "portage-buy:#{@uri.host}:#{checkout['id']}"
|
|
642
|
+
transaction_log.reserve(idempotency_key: key, checkout_id: checkout["id"], shop: @uri.host,
|
|
643
|
+
payment_token_ref: token_ref, amount: checkout_total(checkout),
|
|
644
|
+
currency: checkout["currency"])
|
|
645
|
+
begin
|
|
646
|
+
completed = yield
|
|
647
|
+
rescue StandardError
|
|
648
|
+
transaction_log.complete(idempotency_key: key, status: "failed", policy_decision: @decisions[:policy])
|
|
649
|
+
raise
|
|
650
|
+
end
|
|
651
|
+
settle_transaction(key, completed)
|
|
652
|
+
completed
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Money has moved by now. TransactionLog raises on a failed write so
|
|
656
|
+
# spend-cap state never drifts silently, but an escaped exception here
|
|
657
|
+
# would drop the purchase's report and history entry too, so the
|
|
658
|
+
# failure is put on the report as a warning instead.
|
|
659
|
+
def settle_transaction(key, completed)
|
|
660
|
+
purchased = Portage::Ucp::Support::Escalation.reason(checkout_status: completed["status"]).nil?
|
|
661
|
+
transaction_log.complete(idempotency_key: key, status: purchased ? "complete" : "failed",
|
|
662
|
+
amount: checkout_total(completed), currency: completed["currency"],
|
|
663
|
+
policy_decision: @decisions[:policy])
|
|
664
|
+
rescue StandardError => e
|
|
665
|
+
@unrecorded_warning = "Purchased, but it couldn't be recorded in the transaction log (#{e.message}), " \
|
|
666
|
+
"so your spend policy's rolling cap and velocity limit won't count it."
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def transaction_log
|
|
670
|
+
@transaction_log ||= Portage::Ucp::Support::TransactionLog.new
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# nil when both pre-completion gates pass; otherwise the report for
|
|
674
|
+
# the first one that held the purchase.
|
|
675
|
+
def held_report(source, products, checkout, warnings)
|
|
676
|
+
policy = decide_policy(checkout)
|
|
677
|
+
return policy_blocked_report(source, products, checkout, warnings, policy) unless policy[:allowed]
|
|
678
|
+
|
|
679
|
+
confidence = decide_confidence(checkout, warnings)
|
|
680
|
+
return nil if confidence.nil? || confidence[:proceed]
|
|
681
|
+
|
|
682
|
+
handoff_report(source, products, checkout, warnings, outcome: "low_confidence",
|
|
683
|
+
message: low_confidence_message(confidence))
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# The buyer's own spend policy (`portage policy set`), checked through
|
|
687
|
+
# Decisions.policy before any completion is attempted. PolicyGuard is
|
|
688
|
+
# core, so this runs with or without the optional decision gem.
|
|
689
|
+
# Dispatcher runs PolicyGuard as well, but only in-process. A remote
|
|
690
|
+
# native-UCP store's Dispatcher belongs to the merchant, not to this
|
|
691
|
+
# buyer, so without this check the buyer's caps, allowlist and token
|
|
692
|
+
# scopes never applied to a remote store at all. PolicyGuard.check!
|
|
693
|
+
# only reads, so for the own-store adapter flow this repeats a check
|
|
694
|
+
# Dispatcher also runs. It doesn't double-count anything.
|
|
695
|
+
#
|
|
696
|
+
# The rolling cap and velocity limit count the transaction log's
|
|
697
|
+
# completed records: Dispatcher writes them for own-store purchases,
|
|
698
|
+
# and #recording_transaction for remote ones.
|
|
699
|
+
def decide_policy(checkout)
|
|
700
|
+
verdict = Decisions.policy(amount: checkout_total(checkout), currency: checkout["currency"],
|
|
701
|
+
merchant: @uri.host, token_ref: token_ref, transaction_log: transaction_log)
|
|
702
|
+
@decisions[:policy] = verdict
|
|
703
|
+
verdict
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def token_ref = Portage::Ucp::Support::TokenRef.for(@payment_token)
|
|
707
|
+
|
|
708
|
+
def checkout_total(checkout)
|
|
709
|
+
Portage::Ucp::Support::Totals.amount(checkout["totals"])
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Never includes the payment token: the state goes to a model backend,
|
|
713
|
+
# which may be a hosted API (Jev).
|
|
714
|
+
def decide_confidence(checkout, warnings)
|
|
715
|
+
verdict = confidence_check.call(
|
|
716
|
+
query: @query, merchant: @uri.host, quantity: @qty, warnings: warnings,
|
|
717
|
+
checkout: checkout.slice("id", "status", "currency", "line_items", "totals")
|
|
718
|
+
)
|
|
719
|
+
@decisions[:confidence] = verdict if verdict
|
|
720
|
+
verdict
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def confidence_check
|
|
724
|
+
@confidence_check ||= ConfidenceCheck.new
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def policy_blocked_report(source, products, checkout, warnings, verdict)
|
|
728
|
+
handoff_report(source, products, checkout, warnings,
|
|
729
|
+
outcome: "policy_blocked",
|
|
730
|
+
message: "Blocked by your spend policy (#{verdict[:reason]}) — not completed. Review it " \
|
|
731
|
+
"with `portage policy show`, or visit the link to finish this checkout yourself.")
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def low_confidence_message(verdict)
|
|
735
|
+
backend = verdict[:backend]
|
|
736
|
+
unless verdict[:reason] == "below_threshold"
|
|
737
|
+
return "Confidence check (#{backend}) couldn't answer — not completed: #{verdict[:error]} " \
|
|
738
|
+
"Visit the link to finish this checkout yourself."
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
"Confidence check (#{backend}) scored this checkout #{verdict[:confidence].round(2)}, below the " \
|
|
742
|
+
"#{verdict[:threshold]} threshold — not completed. Visit the link to review and finish it yourself."
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def no_payment_token_report(source, products, checkout, warnings)
|
|
746
|
+
handoff_report(source, products, checkout, warnings,
|
|
747
|
+
outcome: "no_payment_token",
|
|
748
|
+
message: "No --payment-token given, and no default payment method on file — run " \
|
|
749
|
+
"`portage payment enroll` or pass --payment-token, or visit the link to " \
|
|
750
|
+
"finish this checkout yourself.")
|
|
396
751
|
end
|
|
397
752
|
|
|
398
753
|
# Same posture as #escalation_report: a completion this agent isn't
|
|
@@ -400,14 +755,41 @@ module Portage
|
|
|
400
755
|
# shopper finishes on the merchant's own continue_url/checkout link,
|
|
401
756
|
# same hand-off requires_escalation already uses (see
|
|
402
757
|
# Client::PaymentPermissionError).
|
|
403
|
-
def permission_denied_report(source, products, checkout)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
758
|
+
def permission_denied_report(source, products, checkout, warnings)
|
|
759
|
+
handoff_report(source, products, checkout, warnings,
|
|
760
|
+
outcome: "permission_denied",
|
|
761
|
+
message: "This agent isn't yet granted permission to complete checkout on this store — " \
|
|
762
|
+
"visit the link to finish it yourself.")
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def escalation_report(source, products, checkout, warnings)
|
|
766
|
+
handoff_report(source, products, checkout, warnings,
|
|
767
|
+
outcome: "requires_escalation",
|
|
768
|
+
message: "Checkout requires buyer escalation — visit the link to complete it.")
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Every checkout this process can't finish itself — a gate held it, or
|
|
772
|
+
# the store escalated or refused permission — hands the shopper its URL
|
|
773
|
+
# (and fires the auto-open/webhook side effects) rather than leaving
|
|
774
|
+
# them at a dead end. `outcome` doubles as the webhook's `reason`, so a
|
|
775
|
+
# relay and an agent loop branch on the same value.
|
|
776
|
+
def handoff_report(source, products, checkout, warnings, outcome:, message:)
|
|
777
|
+
handoff = hand_off(checkout, reason: outcome, source: source, message: message, warnings: warnings)
|
|
778
|
+
checkout_report(source, products, checkout, outcome: outcome, message: message,
|
|
779
|
+
warnings: warnings + Array(@pending_handoff_warning),
|
|
780
|
+
checkout_url: checkout_url_of(checkout), handoff: handoff)
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# docs/plans/handoff-reconcile.md Phase 4 — #webmcp_flow's
|
|
784
|
+
# `express_stop` mode. Reuses #handoff_report as-is: reason
|
|
785
|
+
# `express_stop` reserves a pending shopper record and fires
|
|
786
|
+
# auto-open/notify exactly like any other hand-off (Phases 1-3 apply
|
|
787
|
+
# unchanged), even though it got here because a mode setting chose to
|
|
788
|
+
# stop, not because anything was denied or escalated.
|
|
789
|
+
def webmcp_handoff_report(source, products, checkout, warnings)
|
|
790
|
+
message = "Cart and checkout are built — finish payment with the store's own express-pay button " \
|
|
791
|
+
"on the page."
|
|
792
|
+
handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message)
|
|
411
793
|
end
|
|
412
794
|
|
|
413
795
|
# Never fires on --dry-run (a dry run creates a real checkout but never
|
|
@@ -417,24 +799,77 @@ module Portage
|
|
|
417
799
|
# out of #call (see CheckoutHandoff, Notifier), so the checkout itself
|
|
418
800
|
# — created, or correctly escalated — stays the outcome of record
|
|
419
801
|
# either way.
|
|
420
|
-
|
|
802
|
+
#
|
|
803
|
+
# The webhook body carries the report's own `message`, plus the store
|
|
804
|
+
# and the shopper's query, so a Slack/Zapier relay can post it as-is
|
|
805
|
+
# without a lookup back into this process.
|
|
806
|
+
#
|
|
807
|
+
# Also where docs/plans/handoff-reconcile.md Phase 1 records a pending
|
|
808
|
+
# `settled_by: "shopper"` TransactionLog row (best-effort — a failed
|
|
809
|
+
# write surfaces as a warning, never blocks the hand-off itself), and
|
|
810
|
+
# where Phase 2's `precheck` spend mode suppresses auto-open when this
|
|
811
|
+
# checkout's total would already exceed the buyer's own spend cap.
|
|
812
|
+
def hand_off(checkout, reason:, source:, message:, warnings:)
|
|
421
813
|
url = checkout_url_of(checkout)
|
|
422
814
|
return nil if @dry_run || url.nil?
|
|
423
815
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
816
|
+
over_cap = precheck_mode? && over_spend_cap?(checkout)
|
|
817
|
+
record_pending_handoff(checkout, reason: reason)
|
|
818
|
+
|
|
819
|
+
opened = over_cap ? false : CheckoutHandoff.new(auto_open: @auto_open).call(url)
|
|
820
|
+
error = notifier.call(handoff_notify_payload(checkout, url, reason: reason, source: source, message: message,
|
|
821
|
+
warnings: warnings, over_cap: over_cap))
|
|
822
|
+
result = { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
|
|
823
|
+
over_cap ? result.merge(over_cap: true) : result
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def handoff_notify_payload(checkout, url, reason:, source:, message:, warnings:, over_cap:)
|
|
827
|
+
payload = { event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
|
|
828
|
+
query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
|
|
829
|
+
totals: checkout["totals"], warnings: warnings }
|
|
830
|
+
over_cap ? payload.merge(over_cap: true) : payload
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# Never raises: a record that can't be written is a warning on the
|
|
834
|
+
# report (surfaced via @pending_handoff_warning, see #handoff_report),
|
|
835
|
+
# not a reason to fail the hand-off itself — same posture as
|
|
836
|
+
# #settle_transaction's own @unrecorded_warning.
|
|
837
|
+
def record_pending_handoff(checkout, reason:)
|
|
838
|
+
transaction_log.reserve(idempotency_key: handoff_transaction_key(checkout), checkout_id: checkout["id"],
|
|
839
|
+
shop: @uri.host, payment_token_ref: token_ref, amount: checkout_total(checkout),
|
|
840
|
+
currency: checkout["currency"], settled_by: "shopper", handoff_reason: reason,
|
|
841
|
+
store_url: @uri.to_s, expires_at: checkout["expires_at"])
|
|
842
|
+
rescue StandardError => e
|
|
843
|
+
@pending_handoff_warning = "Couldn't save this hand-off for later reconcile (#{e.message}) — " \
|
|
844
|
+
"run `portage orders reconcile --checkout #{checkout['id']}` once fixed, " \
|
|
845
|
+
"or it'll never be picked up automatically."
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def handoff_transaction_key(checkout)
|
|
849
|
+
"portage-buy:#{@uri.host}:#{checkout['id']}"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def precheck_mode?
|
|
853
|
+
Portage::Cli::HandoffSpendMode.resolve == "precheck"
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Same PolicyGuard the buyer's own spend cap already goes through
|
|
857
|
+
# (#decide_policy) — reused here so a hand-off that never reached
|
|
858
|
+
# #decide_policy at all (escalation, permission_denied, no_payment_token,
|
|
859
|
+
# checkout_mismatch) still gets the buyer a heads-up that finishing
|
|
860
|
+
# this checkout by hand would blow the cap, before they do.
|
|
861
|
+
def over_spend_cap?(checkout)
|
|
862
|
+
Portage::Ucp::PolicyGuard.check!(amount: checkout_total(checkout), currency: checkout["currency"],
|
|
863
|
+
merchant: @uri.host, token_ref: token_ref, transaction_log: transaction_log)
|
|
864
|
+
false
|
|
865
|
+
rescue Portage::Ucp::PolicyViolationError
|
|
866
|
+
true
|
|
428
867
|
end
|
|
429
868
|
|
|
430
869
|
def notifier
|
|
431
870
|
@notifier ||= Notifier.new(webhook_url: @notify_webhook)
|
|
432
871
|
end
|
|
433
872
|
|
|
434
|
-
# Every checkout that can't be finished by this process — no
|
|
435
|
-
# permission, no token, or an explicit requires_escalation — hands the
|
|
436
|
-
# shopper a URL rather than leaving them at a dead end.
|
|
437
|
-
#
|
|
438
873
|
# `continue_url` first, because on a real store it is the only field
|
|
439
874
|
# that ever holds the checkout. This used to be
|
|
440
875
|
# `links.find { |l| l["url"] }` on the reasoning that not every
|
|
@@ -466,31 +901,35 @@ module Portage
|
|
|
466
901
|
@yes
|
|
467
902
|
end
|
|
468
903
|
|
|
469
|
-
def
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
source, products, checkout,
|
|
473
|
-
checkout_url: url, handoff: hand_off(checkout, reason: "requires_escalation", source: source),
|
|
474
|
-
message: "Checkout requires buyer escalation — visit the link to complete it."
|
|
475
|
-
)
|
|
904
|
+
def dry_run_report(source, products, checkout, warnings = [])
|
|
905
|
+
checkout_report(source, products, checkout, outcome: "dry_run", warnings: warnings,
|
|
906
|
+
message: "Dry run — checkout created but not completed.")
|
|
476
907
|
end
|
|
477
908
|
|
|
478
|
-
def
|
|
479
|
-
checkout_report(source, products, checkout,
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
def confirmation_needed_report(source, products, checkout)
|
|
483
|
-
checkout_report(source, products, checkout, message: "Checkout ready — pass --yes to confirm the purchase.")
|
|
909
|
+
def confirmation_needed_report(source, products, checkout, warnings = [])
|
|
910
|
+
checkout_report(source, products, checkout, outcome: "needs_confirmation", warnings: warnings,
|
|
911
|
+
message: "Checkout ready — pass --yes to confirm the purchase.")
|
|
484
912
|
end
|
|
485
913
|
|
|
486
914
|
# Flattens the parts of a Checkout wire hash a CLI caller actually
|
|
487
915
|
# wants to see (id/status/totals) onto the report, rather than nesting
|
|
488
916
|
# the raw hash under a key that'd collide with the boolean `checkout:`
|
|
489
|
-
# field the output struct already reserves (§ output shape).
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
917
|
+
# field the output struct already reserves (§ output shape). `items:`
|
|
918
|
+
# is what the checkout actually holds; `products:` is only what the
|
|
919
|
+
# search returned, most of which was never bought.
|
|
920
|
+
def checkout_report(source, products, checkout, outcome:, message:, checkout_url: nil, handoff: nil,
|
|
921
|
+
warnings: [])
|
|
922
|
+
build_report(source: source, outcome: outcome, browse: true, checkout: true, products: products,
|
|
923
|
+
message: message, checkout_url: checkout_url, checkout_id: checkout["id"],
|
|
924
|
+
checkout_status: checkout["status"], currency: checkout["currency"],
|
|
925
|
+
totals: checkout["totals"], items: checkout_items(checkout), handoff: handoff,
|
|
926
|
+
warnings: warnings, decisions: @decisions.dup)
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def checkout_items(checkout)
|
|
930
|
+
Array(checkout["line_items"]).map do |line|
|
|
931
|
+
{ id: line.dig("item", "id"), title: line.dig("item", "title"), quantity: line["quantity"] }
|
|
932
|
+
end
|
|
494
933
|
end
|
|
495
934
|
|
|
496
935
|
def safe_search(session)
|
|
@@ -502,7 +941,17 @@ module Portage
|
|
|
502
941
|
# prices — a call is scoped to from this (see
|
|
503
942
|
# Portage::Cli::BuyerContext). The loopback/stdio transports drop it.
|
|
504
943
|
def buyer_context
|
|
505
|
-
@buyer_context ||= BuyerContext.from_env
|
|
944
|
+
@buyer_context ||= BuyerContext.from_env.tap do |ctx|
|
|
945
|
+
next unless ctx.empty?
|
|
946
|
+
|
|
947
|
+
# See BuyerContext's own comment: a live Shopify store scoped a
|
|
948
|
+
# cart to no market and dropped every line item with no context at
|
|
949
|
+
# all, reporting `merchandise_out_of_stock` for products its own
|
|
950
|
+
# search just returned. That failure mode looks like a store bug
|
|
951
|
+
# from the report alone — this names the likely cause up front.
|
|
952
|
+
warn "portage: no buyer context set (#{BuyerContext::ENV_VARS.values.join(', ')}) — some stores " \
|
|
953
|
+
"drop line items or misprice without one. Set at least PORTAGE_SHIP_COUNTRY."
|
|
954
|
+
end
|
|
506
955
|
end
|
|
507
956
|
|
|
508
957
|
# Real UCP servers fetch this URL to verify the caller's identity
|
|
@@ -517,40 +966,16 @@ module Portage
|
|
|
517
966
|
# catalog-only-native adapter-checkout-fallback path) ---
|
|
518
967
|
|
|
519
968
|
def fetch_homepage(uri, limit = REDIRECT_LIMIT)
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
523
|
-
open_timeout: 5, read_timeout: 5) do |http|
|
|
524
|
-
http.get(uri.request_uri, { "User-Agent" => "portage-buy" })
|
|
525
|
-
end
|
|
526
|
-
|
|
527
|
-
case response
|
|
528
|
-
when Net::HTTPRedirection
|
|
529
|
-
fetch_homepage(URI.join(uri, response["location"]), limit - 1)
|
|
530
|
-
when Net::HTTPSuccess
|
|
531
|
-
[response.body, response.to_hash]
|
|
532
|
-
else
|
|
533
|
-
[nil, {}]
|
|
534
|
-
end
|
|
535
|
-
rescue StandardError
|
|
536
|
-
[nil, {}]
|
|
969
|
+
HomepageFetch.call(uri, limit: limit)
|
|
537
970
|
end
|
|
538
971
|
|
|
539
972
|
def dead_end
|
|
540
|
-
build_report(source: "none", browse: false, checkout: false,
|
|
973
|
+
build_report(source: "none", outcome: "dead_end", browse: false, checkout: false,
|
|
541
974
|
message: "No automated path — visit #{@uri} yourself.")
|
|
542
975
|
end
|
|
543
976
|
|
|
544
977
|
def build_report(**fields)
|
|
545
|
-
{ url: @uri.to_s, checkout_url: nil, products: [] }.merge(fields)
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
# Loopback buy against your own store needs *some* authenticator (§9
|
|
549
|
-
# rejects anonymous mutation by default) — since this process already
|
|
550
|
-
# has this platform's own credentials (that's the gate to even reach
|
|
551
|
-
# here), authenticating this local CLI session is reasonable.
|
|
552
|
-
class PermissiveAuthenticator < Portage::Ucp::Authenticator
|
|
553
|
-
def call(_server_context) = :local_cli
|
|
978
|
+
{ url: @uri.to_s, checkout_url: nil, products: [], warnings: [] }.merge(fields)
|
|
554
979
|
end
|
|
555
980
|
end
|
|
556
981
|
end
|