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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +109 -0
- data/README.md +117 -6
- data/lib/portage/cli/buy.rb +350 -71
- data/lib/portage/cli/checkout_handoff.rb +6 -18
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +17 -0
- data/lib/portage/cli/find.rb +8 -8
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/notifier.rb +23 -27
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +133 -19
- metadata +16 -5
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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"]
|
|
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
|
|
371
|
-
|
|
372
|
-
return escalation_report(source, products, checkout) if status == "requires_escalation"
|
|
373
|
-
return dry_run_report(source, products, checkout) if @dry_run
|
|
374
|
-
return confirmation_needed_report(source, products, checkout) unless confirmed?
|
|
489
|
+
def escalated_report(source, products, checkout, warnings, verdict)
|
|
490
|
+
return escalation_report(source, products, checkout, warnings) unless verdict[:reason] == "mismatch"
|
|
375
491
|
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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,
|
|
426
|
-
checkout_id: checkout["id"], source: source,
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
source, products, checkout,
|
|
473
|
-
checkout_url: url, handoff: hand_off(checkout, reason: "requires_escalation", source: source),
|
|
474
|
-
message: "Checkout requires buyer escalation — visit the link to complete it."
|
|
475
|
-
)
|
|
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,
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
13
|
-
# --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
|
|
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
|
-
|
|
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
|