portage-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,11 @@ 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"
11
+ require_relative "checkout_handoff"
12
+ require_relative "notifier"
8
13
 
9
14
  module Portage
10
15
  module Cli
@@ -27,7 +32,27 @@ module Portage
27
32
  # whatever the catalog search happens to rank first — how `portage
28
33
  # find` hands a picked offer over without the ranking being guessed
29
34
  # twice.
30
- def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil)
35
+ # @param auto_open [Boolean, nil] per-invocation override for whether a
36
+ # dead-end checkout_url auto-opens in the shopper's browser — nil
37
+ # (the default) defers to PORTAGE_AUTO_OPEN_CHECKOUT / config.json
38
+ # (see CheckoutHandoff).
39
+ # @param notify_webhook [String, nil] per-invocation override for the
40
+ # webhook URL a dead-end checkout_url is POSTed to — nil (the
41
+ # default) defers to PORTAGE_NOTIFY_WEBHOOK_URL / config.json (see
42
+ # Notifier).
43
+ # @param confidence_check [ConfidenceCheck, nil] the opt-in confidence
44
+ # gate in front of an unattended completion. nil (the default) builds
45
+ # one from PORTAGE_DECISION_BACKEND / PORTAGE_MIN_CONFIDENCE, which is
46
+ # a no-op when no backend is named.
47
+ # @param transaction_log [Portage::Ucp::Support::TransactionLog, nil]
48
+ # where a remote purchase is recorded, and what the spend policy's
49
+ # rolling cap and velocity limit count. nil (the default) is the
50
+ # real ~/.portage/transactions.json, the file Dispatcher writes for
51
+ # own-store purchases.
52
+ # rubocop:disable Metrics/ParameterLists -- all keywords; one per flag, plus two injectable collaborators
53
+ def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
54
+ auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil)
55
+ # rubocop:enable Metrics/ParameterLists
31
56
  raw = url.to_s.strip
32
57
  raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
33
58
  @uri = URI.parse(raw)
@@ -37,8 +62,16 @@ module Portage
37
62
  @yes = yes
38
63
  @dry_run = dry_run
39
64
  @product_id = product_id
65
+ @auto_open = auto_open
66
+ @notify_webhook = notify_webhook
67
+ @confidence_check = confidence_check
68
+ @transaction_log = transaction_log
69
+ @decisions = {}
40
70
  end
41
71
 
72
+ # Link `type`s that are never the checkout — see #checkout_url_of.
73
+ POLICY_LINK_TYPES = /policy|policies|terms|contact|privacy|legal|imprint/i
74
+
42
75
  def call
43
76
  session = discover(@uri)
44
77
  return native_flow(session) if session
@@ -80,35 +113,59 @@ module Portage
80
113
  catalog_only(session)
81
114
  end
82
115
  rescue Portage::Ucp::Client::MissingAgentProfileError, Portage::Ucp::Client::UnsupportedWireShapeError,
83
- MCP::Client::RequestHandlerError => e
116
+ Portage::Ucp::Client::ServerError, MCP::Client::RequestHandlerError => e
84
117
  native_flow_error_report(e)
85
118
  end
86
119
 
87
120
  def native_flow_error_report(error)
88
121
  case error
89
122
  when Portage::Ucp::Client::MissingAgentProfileError
90
- build_report(source: "native_ucp", browse: false, checkout: false,
123
+ build_report(source: "native_ucp", outcome: "agent_profile_missing", browse: false, checkout: false,
91
124
  message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — " \
92
125
  "#{@uri} verifies it before answering any UCP call.")
93
126
  when Portage::Ucp::Client::UnsupportedWireShapeError
94
- build_report(source: "native_ucp", browse: true, checkout: false,
127
+ build_report(source: "native_ucp", outcome: "unsupported_wire_shape", browse: true, checkout: false,
95
128
  message: "Can't complete checkout on #{@uri} yet: #{error.message}")
129
+ when Portage::Ucp::Client::ServerError
130
+ server_error_report(error)
96
131
  else
97
132
  # MCP::Client::RequestHandlerError doesn't retain the server's JSON
98
133
  # error body on this path, so this can't quote the server's own
99
134
  # explanation — a common cause is PORTAGE_AGENT_PROFILE not
100
135
  # pointing at a real, JSON agent-profile document the store's UCP
101
136
  # endpoint accepts.
102
- build_report(source: "native_ucp", browse: false, checkout: false,
137
+ build_report(source: "native_ucp", outcome: "request_rejected", browse: false, checkout: false,
103
138
  message: "#{@uri} rejected the request (#{error.message}) — if PORTAGE_AGENT_PROFILE " \
104
139
  "is set, check it points at a real agent-profile document the store accepts.")
105
140
  end
106
141
  end
107
142
 
143
+ # A store refusing a cart/checkout call on its own terms — out of stock,
144
+ # a line it won't accept, a cart that expired — is an answer, not a
145
+ # crash. This used to escape `#call` as an unhandled ServerError,
146
+ # printing a Ruby backtrace whose "message" was the server's entire
147
+ # several-kilobyte `ucp` envelope (confirmed live 2026-09-22: a
148
+ # genuinely sold-out variant on a Shopify store). Report the server's
149
+ # own sentence instead, and hand back the `continue_url` it supplied so
150
+ # the shopper has somewhere to go — same posture as
151
+ # #escalation_report/#permission_denied_report.
152
+ #
153
+ # Deliberately not routed through #hand_off: that fires the auto-open
154
+ # and webhook side effects, which belong to a checkout this agent
155
+ # actually built. There's no checkout here — the call that failed is
156
+ # what would have created one.
157
+ def server_error_report(error)
158
+ url = error.continue_url
159
+ build_report(
160
+ source: "native_ucp", outcome: "store_refused", browse: true, checkout: false, checkout_url: url,
161
+ message: "#{@uri} couldn't complete this: #{error.summary}#{url && " — finish it at #{url}"}"
162
+ )
163
+ end
164
+
108
165
  def catalog_only(session)
109
166
  products = safe_search(session)
110
167
  report = build_report(
111
- source: "native_ucp", browse: true, checkout: false, products: products,
168
+ source: "native_ucp", outcome: "browse_only", browse: true, checkout: false, products: products,
112
169
  message: "I can browse this store but can't check out via UCP yet."
113
170
  )
114
171
  merge_adapter_checkout_fallback(report)
@@ -124,7 +181,13 @@ module Portage
124
181
  fallback = platform && adapter_flow(platform)
125
182
  return report unless fallback && fallback[:checkout]
126
183
 
127
- report.merge(source: fallback[:source], checkout: true, checkout_url: fallback[:checkout_url])
184
+ # The original `report[:message]` ("I can browse this store but
185
+ # can't check out via UCP yet.") goes stale the moment `checkout:`
186
+ # flips to true here — leaving it would tell the caller there's no
187
+ # checkout path in the same report that hands them a checkout_url.
188
+ report.merge(source: fallback[:source], checkout: true, checkout_url: fallback[:checkout_url],
189
+ message: "#{report[:message]} Falling back to #{fallback[:source]} — " \
190
+ "follow the link to buy it that way instead.")
128
191
  end
129
192
 
130
193
  # --- Step 1 fallback: alternate manifest pointer in <head> ---
@@ -145,14 +208,44 @@ module Portage
145
208
  env = Portage::Ucp::Resolver.env_for(platform)
146
209
  return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
147
210
 
148
- adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
211
+ begin
212
+ adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
213
+ rescue LoadError
214
+ return nil
215
+ rescue StandardError => e
216
+ # Distinct from LoadError above: the gem *is* installed and this
217
+ # platform *was* detected, so a raise here is a real config
218
+ # problem (e.g. malformed WOOCOMMERCE_BILLING_ADDRESS JSON) — same
219
+ # "actionable, don't hide it behind dead_end" posture as
220
+ # #run_adapter_flow's own rescue, just one step earlier.
221
+ return build_report(source: "adapter:#{platform.name}", outcome: "adapter_misconfigured", browse: false,
222
+ checkout: false, message: "#{platform.name} adapter misconfigured: #{e.message}")
223
+ end
224
+
225
+ run_adapter_flow(adapter, platform)
226
+ end
227
+
228
+ # Once the adapter gem is installed and the adapter itself is live, a
229
+ # `StandardError` it raises is a real, actionable failure (e.g. "no
230
+ # payment_method configured") — surface it instead of falling through
231
+ # to `dead_end`'s generic "visit it yourself" message, which would hide
232
+ # it identically to "there's no adapter for this platform at all".
233
+ def run_adapter_flow(adapter, platform)
149
234
  if adapter_supports_checkout?(adapter)
150
235
  full_buy(client_for(adapter), source: "adapter:#{platform.name}", fulfillment_adapter: adapter)
151
236
  else
152
237
  catalog_only_adapter(adapter, platform)
153
238
  end
154
- rescue LoadError, StandardError
155
- nil
239
+ rescue StandardError => e
240
+ # `e.message` is the store's own text where the adapter raised an
241
+ # ApiError (see Support::ApiError#detail) — quoted verbatim, not
242
+ # replaced with a generic message, so the actual cause (a missing
243
+ # param, an out-of-stock line, a bad gateway id) is visible. Always
244
+ # paired with a next action, since "here's an error" alone leaves the
245
+ # caller to guess whether to retry, reconfigure, or give up.
246
+ build_report(source: "adapter:#{platform.name}", outcome: "adapter_error", browse: false, checkout: false,
247
+ message: "#{platform.name} adapter error: #{e.message} — visit #{@uri} yourself, " \
248
+ "or fix the adapter's env/config and retry.")
156
249
  end
157
250
 
158
251
  def adapter_supports_checkout?(adapter)
@@ -178,7 +271,7 @@ module Portage
178
271
  products = CatalogProducts.from(adapter.search_catalog(query: @query, limit: 10))
179
272
  checkout = redirect_checkout(adapter, products)
180
273
  build_report(
181
- source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
274
+ source: "adapter:#{platform.name}", outcome: "browse_only", browse: true, checkout: !!checkout,
182
275
  products: products, checkout_url: checkout && checkout.links.first&.url,
183
276
  message: "Found it on #{platform.name}, but checkout there isn't a live UCP transaction — " \
184
277
  "#{checkout ? 'follow the link to buy it yourself.' : 'no checkout path at all.'}"
@@ -207,15 +300,53 @@ module Portage
207
300
  products = safe_search(session)
208
301
  product = select_product(products)
209
302
  unless product
210
- return build_report(source: source, browse: true, checkout: true, products: products,
303
+ return build_report(source: source, outcome: "no_match", browse: true, checkout: true, products: products,
211
304
  message: no_match_message)
212
305
  end
213
306
 
214
307
  checkout = session.create_checkout(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
215
308
  fulfillment: requested_fulfillment(fulfillment_adapter),
216
- meta: agent_meta)
309
+ context: buyer_context, meta: agent_meta)
217
310
  checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
218
- finish_checkout(session, source, products, checkout)
311
+
312
+ warnings = reconcile_checkout(product, checkout)
313
+ finish_checkout(session, source, products, checkout, warnings)
314
+ end
315
+
316
+ # A store can silently drop the requested line, change its quantity, or
317
+ # price it differently than its own catalog just quoted a moment
318
+ # earlier in #safe_search — none of that raises, since it's a normal
319
+ # (if surprising) checkout response, not a transport error. Buying
320
+ # blind against a mismatch this method could have caught defeats the
321
+ # point of an agent shopping on the buyer's behalf, so this always
322
+ # checks and surfaces what it finds; PORTAGE_ABORT_ON_CHECKOUT_MISMATCH
323
+ # additionally refuses to proceed rather than merely warning.
324
+ def reconcile_checkout(product, checkout)
325
+ item_id = line_item_id_of(product)
326
+ line = Array(checkout["line_items"]).find { |li| li.dig("item", "id") == item_id }
327
+ return ["Store dropped the requested item (#{item_id}) from checkout."] unless line
328
+
329
+ warnings = []
330
+ if line["quantity"] != @qty
331
+ warnings << "Store checked out quantity #{line['quantity']}, not the requested #{@qty}."
332
+ end
333
+
334
+ expected = expected_unit_price(product, item_id)
335
+ actual = line.dig("item", "price")
336
+ if expected && actual && expected != actual
337
+ warnings << "Store priced the item at #{actual} #{checkout['currency']} minor units per unit, " \
338
+ "not the catalog's #{expected}."
339
+ end
340
+ warnings
341
+ end
342
+
343
+ def expected_unit_price(product, item_id)
344
+ variant = Array(product["variants"]).find { |v| v["id"] == item_id }
345
+ variant&.dig("price", "amount") || product.dig("price_range", "min", "amount")
346
+ end
347
+
348
+ def abort_on_mismatch?
349
+ Setting.flag?(env: "PORTAGE_ABORT_ON_CHECKOUT_MISMATCH")
219
350
  end
220
351
 
221
352
  # Submits PORTAGE_SHIP_* (see Portage::Cli::ShippingProfile) as the
@@ -283,12 +414,27 @@ module Portage
283
414
  # With a --product-id, that exact product or nothing: falling back to the
284
415
  # top search hit when the requested id isn't in the results would buy
285
416
  # something the caller never chose.
417
+ #
418
+ # Without one, the top hit a shopper could actually buy: checking out a
419
+ # sold-out top hit dead-ends on the store's "Sold out" refusal even
420
+ # when an in-stock match sits right below it (confirmed live
421
+ # 2026-09-23 on allbirds.com and billabong.com). Falls back to the top
422
+ # hit when nothing reports stock, so the store still gets to answer.
286
423
  def select_product(products)
287
- return products.first unless @product_id
424
+ return products.find { |product| available?(product) } || products.first unless @product_id
288
425
 
289
426
  products.find { |product| product_id_of(product) == @product_id }
290
427
  end
291
428
 
429
+ # A product with no variant availability to go on counts as buyable —
430
+ # only an explicit `available: false` on every variant rules it out.
431
+ def available?(product)
432
+ variants = Array(product["variants"])
433
+ variants.empty? || variants.any? { |variant| variant_available?(variant) }
434
+ end
435
+
436
+ def variant_available?(variant) = variant.dig("availability", "available") != false
437
+
292
438
  # #select_product only ever sees products from #safe_search, which reads
293
439
  # through a Session — native remote or the own-store loopback session
294
440
  # built via Client.for_adapter alike — so Dispatcher#wrap has already
@@ -307,62 +453,335 @@ module Portage
307
453
  # #product_id_of returns for display/--product-id matching. A
308
454
  # product with no variants (or a backend that doesn't distinguish
309
455
  # the two) falls back to the product id unchanged (see
310
- # docs/design-log.md §41).
456
+ # docs/design-log.md §41). The first in-stock variant wins over the
457
+ # first variant, for the same reason #select_product skips sold-out
458
+ # products (a live Brooklinen sheet set lists its sold-out size first).
311
459
  def line_item_id_of(product)
312
- product["variants"]&.first&.dig("id") || product_id_of(product)
460
+ variants = Array(product["variants"])
461
+ (variants.find { |variant| variant_available?(variant) } || variants.first)&.dig("id") ||
462
+ product_id_of(product)
313
463
  end
314
464
 
315
- def finish_checkout(session, source, products, checkout)
316
- status = checkout["status"]
317
- return escalation_report(source, products, checkout) if status == "requires_escalation"
318
- return dry_run_report(source, products, checkout) if @dry_run
319
- return confirmation_needed_report(source, products, checkout) unless confirmed?
465
+ def finish_checkout(session, source, products, checkout, warnings = [])
466
+ escalation = decide_escalation(checkout, warnings)
467
+ return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
468
+ return dry_run_report(source, products, checkout, warnings) if @dry_run
469
+ return confirmation_needed_report(source, products, checkout, warnings) unless confirmed?
320
470
 
321
- complete(session, source, products, checkout)
471
+ complete(session, source, products, checkout, warnings)
322
472
  end
323
473
 
324
- def complete(session, source, products, checkout)
474
+ # Hand off vs. keep going is Decisions.escalation's call
475
+ # (docs/plans/system-one-decision-layer.md § Responsibilities 2): a
476
+ # literal `requires_escalation` status always escalates. A mismatch
477
+ # from #reconcile_checkout escalates only under
478
+ # PORTAGE_ABORT_ON_CHECKOUT_MISMATCH. By default the warnings are
479
+ # surfaced on the report, and the purchase is not stopped for them.
480
+ # The verdict lands on the report's `decisions:`, and the report's
481
+ # `outcome:` names which gate (if any) stopped the purchase.
482
+ def decide_escalation(checkout, warnings)
483
+ verdict = Decisions.escalation(checkout_status: checkout["status"],
484
+ warnings: abort_on_mismatch? ? warnings : [])
485
+ @decisions[:escalation] = verdict
486
+ verdict
487
+ end
488
+
489
+ def escalated_report(source, products, checkout, warnings, verdict)
490
+ return escalation_report(source, products, checkout, warnings) unless verdict[:reason] == "mismatch"
491
+
492
+ handoff_report(source, products, checkout, warnings,
493
+ outcome: "checkout_mismatch",
494
+ message: "Aborted before purchase — checkout didn't match the request: #{warnings.join(' ')}")
495
+ end
496
+
497
+ # One guard per gate, in the order they run: a payment token, the
498
+ # buyer's spend policy, the opt-in confidence check, then the store's
499
+ # own answer to the completion.
500
+ def complete(session, source, products, checkout, warnings = [])
325
501
  @payment_token ||= PaymentMethods.default
326
- unless @payment_token
327
- return checkout_report(source, products, checkout,
328
- message: "No --payment-token given, and no default payment method on file — " \
329
- "run `portage payment enroll` or pass --payment-token.")
502
+ return no_payment_token_report(source, products, checkout, warnings) unless @payment_token
503
+
504
+ held = held_report(source, products, checkout, warnings)
505
+ return held if held
506
+
507
+ completed = recording_transaction(source, checkout) do
508
+ session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
509
+ end
510
+ # `complete_checkout` can hand back `requires_escalation` too (e.g.
511
+ # Shopify's cartSubmitForCompletion result carrying `errors`) — this
512
+ # used to report "Purchased." unconditionally regardless of
513
+ # `completed["status"]`, silently misreporting an escalation as a
514
+ # successful purchase.
515
+ return escalation_report(source, products, completed, warnings) if decide_escalation(completed, [])[:escalate]
516
+
517
+ checkout_report(source, products, completed, outcome: "purchased", message: "Purchased.",
518
+ warnings: warnings + Array(@unrecorded_warning))
519
+ rescue Portage::Ucp::Client::PaymentPermissionError
520
+ permission_denied_report(source, products, checkout, warnings)
521
+ end
522
+
523
+ # PolicyGuard's rolling cap and velocity limit count the completed
524
+ # records in the transaction log. On the own-store loopback path the
525
+ # in-process Dispatcher writes those itself, so only a remote
526
+ # completion is recorded here. Recording both would count a loopback
527
+ # purchase twice.
528
+ #
529
+ # Same shape as Dispatcher's record: reserved `pending` before the
530
+ # store is asked to complete, so a crash mid-charge leaves evidence,
531
+ # then settled. Only a completion that came back purchased settles as
532
+ # `complete`, the one status those limits count.
533
+ def recording_transaction(source, checkout)
534
+ return yield unless source == "native_ucp"
535
+
536
+ key = "portage-buy:#{@uri.host}:#{checkout['id']}"
537
+ transaction_log.reserve(idempotency_key: key, checkout_id: checkout["id"], shop: @uri.host,
538
+ payment_token_ref: token_ref, amount: checkout_total(checkout),
539
+ currency: checkout["currency"])
540
+ begin
541
+ completed = yield
542
+ rescue StandardError
543
+ transaction_log.complete(idempotency_key: key, status: "failed", policy_decision: @decisions[:policy])
544
+ raise
545
+ end
546
+ settle_transaction(key, completed)
547
+ completed
548
+ end
549
+
550
+ # Money has moved by now. TransactionLog raises on a failed write so
551
+ # spend-cap state never drifts silently, but an escaped exception here
552
+ # would drop the purchase's report and history entry too, so the
553
+ # failure is put on the report as a warning instead.
554
+ def settle_transaction(key, completed)
555
+ purchased = Portage::Ucp::Support::Escalation.reason(checkout_status: completed["status"]).nil?
556
+ transaction_log.complete(idempotency_key: key, status: purchased ? "complete" : "failed",
557
+ amount: checkout_total(completed), currency: completed["currency"],
558
+ policy_decision: @decisions[:policy])
559
+ rescue StandardError => e
560
+ @unrecorded_warning = "Purchased, but it couldn't be recorded in the transaction log (#{e.message}), " \
561
+ "so your spend policy's rolling cap and velocity limit won't count it."
562
+ end
563
+
564
+ def transaction_log
565
+ @transaction_log ||= Portage::Ucp::Support::TransactionLog.new
566
+ end
567
+
568
+ # nil when both pre-completion gates pass; otherwise the report for
569
+ # the first one that held the purchase.
570
+ def held_report(source, products, checkout, warnings)
571
+ policy = decide_policy(checkout)
572
+ return policy_blocked_report(source, products, checkout, warnings, policy) unless policy[:allowed]
573
+
574
+ confidence = decide_confidence(checkout, warnings)
575
+ return nil if confidence.nil? || confidence[:proceed]
576
+
577
+ handoff_report(source, products, checkout, warnings, outcome: "low_confidence",
578
+ message: low_confidence_message(confidence))
579
+ end
580
+
581
+ # The buyer's own spend policy (`portage policy set`), checked through
582
+ # Decisions.policy before any completion is attempted. PolicyGuard is
583
+ # core, so this runs with or without the optional decision gem.
584
+ # Dispatcher runs PolicyGuard as well, but only in-process. A remote
585
+ # native-UCP store's Dispatcher belongs to the merchant, not to this
586
+ # buyer, so without this check the buyer's caps, allowlist and token
587
+ # scopes never applied to a remote store at all. PolicyGuard.check!
588
+ # only reads, so for the own-store adapter flow this repeats a check
589
+ # Dispatcher also runs. It doesn't double-count anything.
590
+ #
591
+ # The rolling cap and velocity limit count the transaction log's
592
+ # completed records: Dispatcher writes them for own-store purchases,
593
+ # and #recording_transaction for remote ones.
594
+ def decide_policy(checkout)
595
+ verdict = Decisions.policy(amount: checkout_total(checkout), currency: checkout["currency"],
596
+ merchant: @uri.host, token_ref: token_ref, transaction_log: transaction_log)
597
+ @decisions[:policy] = verdict
598
+ verdict
599
+ end
600
+
601
+ def token_ref = Portage::Ucp::Support::TokenRef.for(@payment_token)
602
+
603
+ def checkout_total(checkout)
604
+ Portage::Ucp::Support::Totals.amount(checkout["totals"])
605
+ end
606
+
607
+ # Never includes the payment token: the state goes to a model backend,
608
+ # which may be a hosted API (Jev).
609
+ def decide_confidence(checkout, warnings)
610
+ verdict = confidence_check.call(
611
+ query: @query, merchant: @uri.host, quantity: @qty, warnings: warnings,
612
+ checkout: checkout.slice("id", "status", "currency", "line_items", "totals")
613
+ )
614
+ @decisions[:confidence] = verdict if verdict
615
+ verdict
616
+ end
617
+
618
+ def confidence_check
619
+ @confidence_check ||= ConfidenceCheck.new
620
+ end
621
+
622
+ def policy_blocked_report(source, products, checkout, warnings, verdict)
623
+ handoff_report(source, products, checkout, warnings,
624
+ outcome: "policy_blocked",
625
+ message: "Blocked by your spend policy (#{verdict[:reason]}) — not completed. Review it " \
626
+ "with `portage policy show`, or visit the link to finish this checkout yourself.")
627
+ end
628
+
629
+ def low_confidence_message(verdict)
630
+ backend = verdict[:backend]
631
+ unless verdict[:reason] == "below_threshold"
632
+ return "Confidence check (#{backend}) couldn't answer — not completed: #{verdict[:error]} " \
633
+ "Visit the link to finish this checkout yourself."
330
634
  end
331
635
 
332
- completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
333
- checkout_report(source, products, completed, message: "Purchased.")
636
+ "Confidence check (#{backend}) scored this checkout #{verdict[:confidence].round(2)}, below the " \
637
+ "#{verdict[:threshold]} threshold — not completed. Visit the link to review and finish it yourself."
638
+ end
639
+
640
+ def no_payment_token_report(source, products, checkout, warnings)
641
+ handoff_report(source, products, checkout, warnings,
642
+ outcome: "no_payment_token",
643
+ message: "No --payment-token given, and no default payment method on file — run " \
644
+ "`portage payment enroll` or pass --payment-token, or visit the link to " \
645
+ "finish this checkout yourself.")
646
+ end
647
+
648
+ # Same posture as #escalation_report: a completion this agent isn't
649
+ # granted permission for is a normal outcome, not a failure — the
650
+ # shopper finishes on the merchant's own continue_url/checkout link,
651
+ # same hand-off requires_escalation already uses (see
652
+ # Client::PaymentPermissionError).
653
+ def permission_denied_report(source, products, checkout, warnings)
654
+ handoff_report(source, products, checkout, warnings,
655
+ outcome: "permission_denied",
656
+ message: "This agent isn't yet granted permission to complete checkout on this store — " \
657
+ "visit the link to finish it yourself.")
658
+ end
659
+
660
+ def escalation_report(source, products, checkout, warnings)
661
+ handoff_report(source, products, checkout, warnings,
662
+ outcome: "requires_escalation",
663
+ message: "Checkout requires buyer escalation — visit the link to complete it.")
664
+ end
665
+
666
+ # Every checkout this process can't finish itself — a gate held it, or
667
+ # the store escalated or refused permission — hands the shopper its URL
668
+ # (and fires the auto-open/webhook side effects) rather than leaving
669
+ # them at a dead end. `outcome` doubles as the webhook's `reason`, so a
670
+ # relay and an agent loop branch on the same value.
671
+ def handoff_report(source, products, checkout, warnings, outcome:, message:)
672
+ handoff = hand_off(checkout, reason: outcome, source: source, message: message, warnings: warnings)
673
+ checkout_report(source, products, checkout, outcome: outcome, warnings: warnings, message: message,
674
+ checkout_url: checkout_url_of(checkout), handoff: handoff)
675
+ end
676
+
677
+ # Never fires on --dry-run (a dry run creates a real checkout but never
678
+ # attempts completion — auto-opening/notifying over a preview run would
679
+ # be actively wrong), and never fires without a checkout_url to hand
680
+ # off. Best-effort: a failed open or failed webhook POST never raises
681
+ # out of #call (see CheckoutHandoff, Notifier), so the checkout itself
682
+ # — created, or correctly escalated — stays the outcome of record
683
+ # either way.
684
+ #
685
+ # The webhook body carries the report's own `message`, plus the store
686
+ # and the shopper's query, so a Slack/Zapier relay can post it as-is
687
+ # without a lookup back into this process.
688
+ def hand_off(checkout, reason:, source:, message:, warnings:)
689
+ url = checkout_url_of(checkout)
690
+ return nil if @dry_run || url.nil?
691
+
692
+ opened = CheckoutHandoff.new(auto_open: @auto_open).call(url)
693
+ error = notifier.call(event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
694
+ query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
695
+ totals: checkout["totals"], warnings: warnings)
696
+ { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
697
+ end
698
+
699
+ def notifier
700
+ @notifier ||= Notifier.new(webhook_url: @notify_webhook)
701
+ end
702
+
703
+ # `continue_url` first, because on a real store it is the only field
704
+ # that ever holds the checkout. This used to be
705
+ # `links.find { |l| l["url"] }` on the reasoning that not every
706
+ # backend's link entries name a type — but live UCP stores put nothing
707
+ # *except* policy links in `links`: five third-party Shopify stores
708
+ # checked 2026-09-22 returned `refund_policy`, `privacy_policy`,
709
+ # `terms_of_service`, `shipping_policy`, `contact_information` and
710
+ # nothing else, with the checkout at `continue_url` every time. So the
711
+ # old "first link with a url" handed the shopper a refund policy on
712
+ # every real store, `--auto-open` opened it, and `--notify-webhook`
713
+ # posted it.
714
+ #
715
+ # The `links` fallback stays for backends whose checkout genuinely
716
+ # lives there, but skips anything named as a policy or contact link:
717
+ # for a hand-off, no URL is a better answer than the wrong one, since
718
+ # the report and the message both then say there's nowhere to go
719
+ # instead of pointing somewhere useless.
720
+ def checkout_url_of(checkout)
721
+ checkout["continue_url"] || checkout_link_url(checkout)
722
+ end
723
+
724
+ def checkout_link_url(checkout)
725
+ Array(checkout["links"])
726
+ .reject { |l| l["type"].to_s.match?(POLICY_LINK_TYPES) }
727
+ .find { |l| l["url"] }&.fetch("url", nil)
334
728
  end
335
729
 
336
730
  def confirmed?
337
731
  @yes
338
732
  end
339
733
 
340
- def escalation_report(source, products, checkout)
341
- checkout_report(source, products, checkout,
342
- checkout_url: checkout["links"]&.find { |l| l["url"] }&.fetch("url", nil),
343
- message: "Checkout requires buyer escalation — visit the link to complete it.")
344
- end
345
-
346
- def dry_run_report(source, products, checkout)
347
- checkout_report(source, products, checkout, message: "Dry run — checkout created but not completed.")
734
+ def dry_run_report(source, products, checkout, warnings = [])
735
+ checkout_report(source, products, checkout, outcome: "dry_run", warnings: warnings,
736
+ message: "Dry run — checkout created but not completed.")
348
737
  end
349
738
 
350
- def confirmation_needed_report(source, products, checkout)
351
- checkout_report(source, products, checkout, message: "Checkout ready — pass --yes to confirm the purchase.")
739
+ def confirmation_needed_report(source, products, checkout, warnings = [])
740
+ checkout_report(source, products, checkout, outcome: "needs_confirmation", warnings: warnings,
741
+ message: "Checkout ready — pass --yes to confirm the purchase.")
352
742
  end
353
743
 
354
744
  # Flattens the parts of a Checkout wire hash a CLI caller actually
355
745
  # wants to see (id/status/totals) onto the report, rather than nesting
356
746
  # the raw hash under a key that'd collide with the boolean `checkout:`
357
- # field the output struct already reserves (§ output shape).
358
- def checkout_report(source, products, checkout, message:, checkout_url: nil)
359
- build_report(source: source, browse: true, checkout: true, products: products, message: message,
360
- checkout_url: checkout_url, checkout_id: checkout["id"], checkout_status: checkout["status"],
361
- totals: checkout["totals"])
747
+ # field the output struct already reserves (§ output shape). `items:`
748
+ # is what the checkout actually holds; `products:` is only what the
749
+ # search returned, most of which was never bought.
750
+ def checkout_report(source, products, checkout, outcome:, message:, checkout_url: nil, handoff: nil,
751
+ warnings: [])
752
+ build_report(source: source, outcome: outcome, browse: true, checkout: true, products: products,
753
+ message: message, checkout_url: checkout_url, checkout_id: checkout["id"],
754
+ checkout_status: checkout["status"], currency: checkout["currency"],
755
+ totals: checkout["totals"], items: checkout_items(checkout), handoff: handoff,
756
+ warnings: warnings, decisions: @decisions.dup)
757
+ end
758
+
759
+ def checkout_items(checkout)
760
+ Array(checkout["line_items"]).map do |line|
761
+ { id: line.dig("item", "id"), title: line.dig("item", "title"), quantity: line["quantity"] }
762
+ end
362
763
  end
363
764
 
364
765
  def safe_search(session)
365
- CatalogProducts.from(session.search_catalog(query: @query, limit: 10, meta: agent_meta))
766
+ CatalogProducts.from(session.search_catalog(query: @query, limit: 10, context: buyer_context,
767
+ meta: agent_meta))
768
+ end
769
+
770
+ # A real store resolves which market — and so which inventory and
771
+ # prices — a call is scoped to from this (see
772
+ # Portage::Cli::BuyerContext). The loopback/stdio transports drop it.
773
+ def buyer_context
774
+ @buyer_context ||= BuyerContext.from_env.tap do |ctx|
775
+ next unless ctx.empty?
776
+
777
+ # See BuyerContext's own comment: a live Shopify store scoped a
778
+ # cart to no market and dropped every line item with no context at
779
+ # all, reporting `merchandise_out_of_stock` for products its own
780
+ # search just returned. That failure mode looks like a store bug
781
+ # from the report alone — this names the likely cause up front.
782
+ warn "portage: no buyer context set (#{BuyerContext::ENV_VARS.values.join(', ')}) — some stores " \
783
+ "drop line items or misprice without one. Set at least PORTAGE_SHIP_COUNTRY."
784
+ end
366
785
  end
367
786
 
368
787
  # Real UCP servers fetch this URL to verify the caller's identity
@@ -397,12 +816,12 @@ module Portage
397
816
  end
398
817
 
399
818
  def dead_end
400
- build_report(source: "none", browse: false, checkout: false,
819
+ build_report(source: "none", outcome: "dead_end", browse: false, checkout: false,
401
820
  message: "No automated path — visit #{@uri} yourself.")
402
821
  end
403
822
 
404
823
  def build_report(**fields)
405
- { url: @uri.to_s, checkout_url: nil, products: [] }.merge(fields)
824
+ { url: @uri.to_s, checkout_url: nil, products: [], warnings: [] }.merge(fields)
406
825
  end
407
826
 
408
827
  # Loopback buy against your own store needs *some* authenticator (§9