portage-cli 0.6.4 → 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,9 @@ 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"
10
13
 
@@ -37,8 +40,19 @@ module Portage
37
40
  # webhook URL a dead-end checkout_url is POSTed to — nil (the
38
41
  # default) defers to PORTAGE_NOTIFY_WEBHOOK_URL / config.json (see
39
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
40
53
  def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
41
- auto_open: nil, notify_webhook: nil)
54
+ auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil)
55
+ # rubocop:enable Metrics/ParameterLists
42
56
  raw = url.to_s.strip
43
57
  raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
44
58
  @uri = URI.parse(raw)
@@ -50,6 +64,9 @@ module Portage
50
64
  @product_id = product_id
51
65
  @auto_open = auto_open
52
66
  @notify_webhook = notify_webhook
67
+ @confidence_check = confidence_check
68
+ @transaction_log = transaction_log
69
+ @decisions = {}
53
70
  end
54
71
 
55
72
  # Link `type`s that are never the checkout — see #checkout_url_of.
@@ -103,11 +120,11 @@ module Portage
103
120
  def native_flow_error_report(error)
104
121
  case error
105
122
  when Portage::Ucp::Client::MissingAgentProfileError
106
- build_report(source: "native_ucp", browse: false, checkout: false,
123
+ build_report(source: "native_ucp", outcome: "agent_profile_missing", browse: false, checkout: false,
107
124
  message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — " \
108
125
  "#{@uri} verifies it before answering any UCP call.")
109
126
  when Portage::Ucp::Client::UnsupportedWireShapeError
110
- build_report(source: "native_ucp", browse: true, checkout: false,
127
+ build_report(source: "native_ucp", outcome: "unsupported_wire_shape", browse: true, checkout: false,
111
128
  message: "Can't complete checkout on #{@uri} yet: #{error.message}")
112
129
  when Portage::Ucp::Client::ServerError
113
130
  server_error_report(error)
@@ -117,7 +134,7 @@ module Portage
117
134
  # explanation — a common cause is PORTAGE_AGENT_PROFILE not
118
135
  # pointing at a real, JSON agent-profile document the store's UCP
119
136
  # endpoint accepts.
120
- build_report(source: "native_ucp", browse: false, checkout: false,
137
+ build_report(source: "native_ucp", outcome: "request_rejected", browse: false, checkout: false,
121
138
  message: "#{@uri} rejected the request (#{error.message}) — if PORTAGE_AGENT_PROFILE " \
122
139
  "is set, check it points at a real agent-profile document the store accepts.")
123
140
  end
@@ -140,7 +157,7 @@ module Portage
140
157
  def server_error_report(error)
141
158
  url = error.continue_url
142
159
  build_report(
143
- source: "native_ucp", browse: true, checkout: false, checkout_url: url,
160
+ source: "native_ucp", outcome: "store_refused", browse: true, checkout: false, checkout_url: url,
144
161
  message: "#{@uri} couldn't complete this: #{error.summary}#{url && " — finish it at #{url}"}"
145
162
  )
146
163
  end
@@ -148,7 +165,7 @@ module Portage
148
165
  def catalog_only(session)
149
166
  products = safe_search(session)
150
167
  report = build_report(
151
- source: "native_ucp", browse: true, checkout: false, products: products,
168
+ source: "native_ucp", outcome: "browse_only", browse: true, checkout: false, products: products,
152
169
  message: "I can browse this store but can't check out via UCP yet."
153
170
  )
154
171
  merge_adapter_checkout_fallback(report)
@@ -164,7 +181,13 @@ module Portage
164
181
  fallback = platform && adapter_flow(platform)
165
182
  return report unless fallback && fallback[:checkout]
166
183
 
167
- 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.")
168
191
  end
169
192
 
170
193
  # --- Step 1 fallback: alternate manifest pointer in <head> ---
@@ -189,6 +212,14 @@ module Portage
189
212
  adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
190
213
  rescue LoadError
191
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}")
192
223
  end
193
224
 
194
225
  run_adapter_flow(adapter, platform)
@@ -206,8 +237,15 @@ module Portage
206
237
  catalog_only_adapter(adapter, platform)
207
238
  end
208
239
  rescue StandardError => e
209
- build_report(source: "adapter:#{platform.name}", browse: false, checkout: false,
210
- message: "#{platform.name} adapter error: #{e.message}")
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.")
211
249
  end
212
250
 
213
251
  def adapter_supports_checkout?(adapter)
@@ -233,7 +271,7 @@ module Portage
233
271
  products = CatalogProducts.from(adapter.search_catalog(query: @query, limit: 10))
234
272
  checkout = redirect_checkout(adapter, products)
235
273
  build_report(
236
- source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
274
+ source: "adapter:#{platform.name}", outcome: "browse_only", browse: true, checkout: !!checkout,
237
275
  products: products, checkout_url: checkout && checkout.links.first&.url,
238
276
  message: "Found it on #{platform.name}, but checkout there isn't a live UCP transaction — " \
239
277
  "#{checkout ? 'follow the link to buy it yourself.' : 'no checkout path at all.'}"
@@ -262,7 +300,7 @@ module Portage
262
300
  products = safe_search(session)
263
301
  product = select_product(products)
264
302
  unless product
265
- 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,
266
304
  message: no_match_message)
267
305
  end
268
306
 
@@ -270,7 +308,45 @@ module Portage
270
308
  fulfillment: requested_fulfillment(fulfillment_adapter),
271
309
  context: buyer_context, meta: agent_meta)
272
310
  checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
273
- 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")
274
350
  end
275
351
 
276
352
  # Submits PORTAGE_SHIP_* (see Portage::Cli::ShippingProfile) as the
@@ -338,12 +414,27 @@ module Portage
338
414
  # With a --product-id, that exact product or nothing: falling back to the
339
415
  # top search hit when the requested id isn't in the results would buy
340
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.
341
423
  def select_product(products)
342
- return products.first unless @product_id
424
+ return products.find { |product| available?(product) } || products.first unless @product_id
343
425
 
344
426
  products.find { |product| product_id_of(product) == @product_id }
345
427
  end
346
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
+
347
438
  # #select_product only ever sees products from #safe_search, which reads
348
439
  # through a Session — native remote or the own-store loopback session
349
440
  # built via Client.for_adapter alike — so Dispatcher#wrap has already
@@ -362,37 +453,196 @@ module Portage
362
453
  # #product_id_of returns for display/--product-id matching. A
363
454
  # product with no variants (or a backend that doesn't distinguish
364
455
  # the two) falls back to the product id unchanged (see
365
- # 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).
366
459
  def line_item_id_of(product)
367
- 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)
463
+ end
464
+
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?
470
+
471
+ complete(session, source, products, checkout, warnings)
472
+ end
473
+
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
368
487
  end
369
488
 
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?
489
+ def escalated_report(source, products, checkout, warnings, verdict)
490
+ return escalation_report(source, products, checkout, warnings) unless verdict[:reason] == "mismatch"
375
491
 
376
- complete(session, source, products, checkout)
492
+ handoff_report(source, products, checkout, warnings,
493
+ outcome: "checkout_mismatch",
494
+ message: "Aborted before purchase — checkout didn't match the request: #{warnings.join(' ')}")
377
495
  end
378
496
 
379
- def complete(session, source, products, checkout)
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 = [])
380
501
  @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
502
+ return no_payment_token_report(source, products, checkout, warnings) unless @payment_token
391
503
 
392
- completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
393
- checkout_report(source, products, completed, message: "Purchased.")
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))
394
519
  rescue Portage::Ucp::Client::PaymentPermissionError
395
- permission_denied_report(source, products, checkout)
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."
634
+ end
635
+
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.")
396
646
  end
397
647
 
398
648
  # Same posture as #escalation_report: a completion this agent isn't
@@ -400,14 +650,28 @@ module Portage
400
650
  # shopper finishes on the merchant's own continue_url/checkout link,
401
651
  # same hand-off requires_escalation already uses (see
402
652
  # 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
- )
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)
411
675
  end
412
676
 
413
677
  # Never fires on --dry-run (a dry run creates a real checkout but never
@@ -417,13 +681,18 @@ module Portage
417
681
  # out of #call (see CheckoutHandoff, Notifier), so the checkout itself
418
682
  # — created, or correctly escalated — stays the outcome of record
419
683
  # either way.
420
- def hand_off(checkout, reason:, source:)
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:)
421
689
  url = checkout_url_of(checkout)
422
690
  return nil if @dry_run || url.nil?
423
691
 
424
692
  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"])
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)
427
696
  { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
428
697
  end
429
698
 
@@ -431,10 +700,6 @@ module Portage
431
700
  @notifier ||= Notifier.new(webhook_url: @notify_webhook)
432
701
  end
433
702
 
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
703
  # `continue_url` first, because on a real store it is the only field
439
704
  # that ever holds the checkout. This used to be
440
705
  # `links.find { |l| l["url"] }` on the reasoning that not every
@@ -466,31 +731,35 @@ module Portage
466
731
  @yes
467
732
  end
468
733
 
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
- )
476
- end
477
-
478
- def dry_run_report(source, products, checkout)
479
- 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.")
480
737
  end
481
738
 
482
- def confirmation_needed_report(source, products, checkout)
483
- 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.")
484
742
  end
485
743
 
486
744
  # Flattens the parts of a Checkout wire hash a CLI caller actually
487
745
  # wants to see (id/status/totals) onto the report, rather than nesting
488
746
  # 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)
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
494
763
  end
495
764
 
496
765
  def safe_search(session)
@@ -502,7 +771,17 @@ module Portage
502
771
  # prices — a call is scoped to from this (see
503
772
  # Portage::Cli::BuyerContext). The loopback/stdio transports drop it.
504
773
  def buyer_context
505
- @buyer_context ||= BuyerContext.from_env
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
506
785
  end
507
786
 
508
787
  # Real UCP servers fetch this URL to verify the caller's identity
@@ -537,12 +816,12 @@ module Portage
537
816
  end
538
817
 
539
818
  def dead_end
540
- build_report(source: "none", browse: false, checkout: false,
819
+ build_report(source: "none", outcome: "dead_end", browse: false, checkout: false,
541
820
  message: "No automated path — visit #{@uri} yourself.")
542
821
  end
543
822
 
544
823
  def build_report(**fields)
545
- { url: @uri.to_s, checkout_url: nil, products: [] }.merge(fields)
824
+ { url: @uri.to_s, checkout_url: nil, products: [], warnings: [] }.merge(fields)
546
825
  end
547
826
 
548
827
  # Loopback buy against your own store needs *some* authenticator (§9
@@ -1,5 +1,6 @@
1
1
  require "uri"
2
2
  require_relative "config"
3
+ require_relative "setting"
3
4
 
4
5
  module Portage
5
6
  module Cli
@@ -8,10 +9,10 @@ module Portage
8
9
  # (escalation, permission denied, no payment token) hands off a link
9
10
  # rather than completing the purchase itself.
10
11
  #
11
- # Default off. Precedence for the toggle (open decision #1, resolved):
12
- # a per-invocation `auto_open:` override (portage buy --auto-open /
13
- # --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which beats
14
- # ~/.portage/config.json's "auto_open_checkout" (Config).
12
+ # Default off. Precedence for the toggle (open decision #1, resolved,
13
+ # see Setting): a per-invocation `auto_open:` override (portage buy
14
+ # --auto-open / --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
15
+ # beats ~/.portage/config.json's "auto_open_checkout" (Config).
15
16
  #
16
17
  # No new gem for the actual open — every other shell-out in this repo
17
18
  # (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
@@ -22,7 +23,6 @@ module Portage
22
23
  class CheckoutHandoff
23
24
  ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
24
25
  CONFIG_KEY = "auto_open_checkout".freeze
25
- TRUE_VALUES = %w[1 true yes].freeze
26
26
 
27
27
  def initialize(auto_open: nil, config: Config.load)
28
28
  @override = auto_open
@@ -30,12 +30,7 @@ module Portage
30
30
  end
31
31
 
32
32
  def auto_open?
33
- return @override unless @override.nil?
34
-
35
- env = env_override
36
- return env unless env.nil?
37
-
38
- !!@config.get(CONFIG_KEY)
33
+ Setting.flag?(override: @override, env: ENV_VAR, config: @config, config_key: CONFIG_KEY)
39
34
  end
40
35
 
41
36
  # @return [Boolean] whether the browser was actually opened.
@@ -47,13 +42,6 @@ module Portage
47
42
 
48
43
  private
49
44
 
50
- def env_override
51
- raw = ENV.fetch(ENV_VAR, nil)
52
- return nil if raw.nil?
53
-
54
- TRUE_VALUES.include?(raw.downcase)
55
- end
56
-
57
45
  def https?(url)
58
46
  URI.parse(url).scheme == "https"
59
47
  rescue URI::InvalidURIError