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.
@@ -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
- raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
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.merge(source: fallback[:source], checkout: true, checkout_url: fallback[:checkout_url])
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
- build_report(source: "adapter:#{platform.name}", browse: false, checkout: false,
210
- message: "#{platform.name} adapter error: #{e.message}")
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
- def full_buy(session, source:, fulfillment_adapter: nil)
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
- finish_checkout(session, source, products, checkout)
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
- return "No product matched \"#{@query}\"." unless @product_id
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
- return products.first unless @product_id
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"]&.first&.dig("id") || product_id_of(product)
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 finish_checkout(session, source, products, checkout)
371
- status = checkout["status"]
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
- complete(session, source, products, checkout)
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
- def complete(session, source, products, checkout)
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
- url = checkout_url_of(checkout)
383
- return checkout_report(
384
- source, products, checkout,
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 = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
393
- checkout_report(source, products, completed, message: "Purchased.")
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
- url = checkout_url_of(checkout)
405
- checkout_report(
406
- source, products, checkout,
407
- checkout_url: url, handoff: hand_off(checkout, reason: "permission_denied", source: source),
408
- message: "This agent isn't yet granted permission to complete checkout on this store — " \
409
- "visit the link to finish it yourself."
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
- def hand_off(checkout, reason:, source:)
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
- opened = CheckoutHandoff.new(auto_open: @auto_open).call(url)
425
- error = notifier.call(event: "checkout_handoff", reason: reason, checkout_url: url,
426
- checkout_id: checkout["id"], source: source, totals: checkout["totals"])
427
- { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
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 escalation_report(source, products, checkout)
470
- url = checkout_url_of(checkout)
471
- checkout_report(
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 dry_run_report(source, products, checkout)
479
- checkout_report(source, products, checkout, message: "Dry run — checkout created but not completed.")
480
- end
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
- def checkout_report(source, products, checkout, message:, checkout_url: nil, handoff: nil)
491
- build_report(source: source, browse: true, checkout: true, products: products, message: message,
492
- checkout_url: checkout_url, checkout_id: checkout["id"], checkout_status: checkout["status"],
493
- totals: checkout["totals"], handoff: handoff)
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
- return [nil, {}] if limit.zero?
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