portage-cli 0.8.0 → 0.10.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 +69 -0
- data/README.md +52 -3
- data/lib/portage/cli/approval_policy.rb +56 -0
- data/lib/portage/cli/approve.rb +135 -0
- data/lib/portage/cli/browser_opener.rb +38 -0
- data/lib/portage/cli/buy.rb +42 -25
- data/lib/portage/cli/check.rb +154 -0
- data/lib/portage/cli/check_next_step.rb +51 -0
- data/lib/portage/cli/checkout_handoff.rb +5 -24
- data/lib/portage/cli/find.rb +8 -1
- data/lib/portage/cli/handoff_host.rb +33 -0
- data/lib/portage/cli/history.rb +47 -2
- data/lib/portage/cli/human_prompt.rb +118 -0
- data/lib/portage/cli/money.rb +18 -0
- data/lib/portage/cli/offer_choice.rb +38 -0
- data/lib/portage/cli/pick.rb +167 -0
- data/lib/portage/cli/product_page.rb +84 -0
- data/lib/portage/cli/quotes.rb +82 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +459 -43
- metadata +15 -3
data/lib/portage/cli.rb
CHANGED
|
@@ -15,7 +15,16 @@ require_relative "cli/browser_profile"
|
|
|
15
15
|
require_relative "cli/buy"
|
|
16
16
|
require_relative "cli/find"
|
|
17
17
|
require_relative "cli/compare"
|
|
18
|
+
require_relative "cli/check"
|
|
18
19
|
require_relative "cli/history"
|
|
20
|
+
require_relative "cli/quotes"
|
|
21
|
+
require_relative "cli/money"
|
|
22
|
+
require_relative "cli/human_prompt"
|
|
23
|
+
require_relative "cli/product_page"
|
|
24
|
+
require_relative "cli/approval_policy"
|
|
25
|
+
require_relative "cli/offer_choice"
|
|
26
|
+
require_relative "cli/pick"
|
|
27
|
+
require_relative "cli/approve"
|
|
19
28
|
require_relative "cli/payment_methods"
|
|
20
29
|
require_relative "cli/proxy_settings"
|
|
21
30
|
require_relative "cli/handoff_only"
|
|
@@ -42,10 +51,16 @@ module Portage
|
|
|
42
51
|
[--handoff-target default|print|profile|agent:NAME]
|
|
43
52
|
[--decision-backend jev|laya] [--min-confidence N] [--json]
|
|
44
53
|
[--wait [--wait-timeout DURATION|off]]
|
|
54
|
+
portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
|
|
55
|
+
portage buy --quote QUOTE_ID --yes [--json] ...
|
|
45
56
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
46
57
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
47
58
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
48
59
|
[--max-price N] [--json]
|
|
60
|
+
portage check <url> [--json]
|
|
61
|
+
portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
|
|
62
|
+
[--choose REF | --compare REF | --view REF]
|
|
63
|
+
portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
|
|
49
64
|
portage history [list] [--purchases|--searches] [--limit N] [--json]
|
|
50
65
|
portage history clear [--purchases|--searches]
|
|
51
66
|
portage payment list [--json]
|
|
@@ -60,6 +75,7 @@ module Portage
|
|
|
60
75
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
61
76
|
[--velocity-count N --velocity-window-seconds N]
|
|
62
77
|
[--allow HOST ...] [--clear-allowlist]
|
|
78
|
+
[--require-approval person|any|off] (lowering asks at a terminal)
|
|
63
79
|
portage orders reconcile [--checkout ID] [--json]
|
|
64
80
|
portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
65
81
|
portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
@@ -79,13 +95,14 @@ module Portage
|
|
|
79
95
|
portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
|
|
80
96
|
portage --version
|
|
81
97
|
|
|
82
|
-
proxy flags (buy/find/compare/doctor/payment enroll):
|
|
98
|
+
proxy flags (buy/find/compare/check/doctor/payment enroll):
|
|
83
99
|
[--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
|
|
84
100
|
[--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
|
|
85
101
|
[--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
|
|
86
102
|
USAGE
|
|
87
103
|
|
|
88
|
-
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
|
|
104
|
+
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare, "check" => :run_check,
|
|
105
|
+
"pick" => :run_pick, "approve" => :run_approve,
|
|
89
106
|
"history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
|
|
90
107
|
"orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
|
|
91
108
|
"doctor" => :run_doctor,
|
|
@@ -142,18 +159,26 @@ module Portage
|
|
|
142
159
|
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
143
160
|
|
|
144
161
|
report = Find.new(**options).call
|
|
145
|
-
record_find(report)
|
|
162
|
+
report = with_search_id(report, record_find(report))
|
|
146
163
|
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
147
164
|
report[:offers].any? ? 0 : 1
|
|
148
165
|
end
|
|
149
166
|
private_class_method :run_find
|
|
150
167
|
|
|
168
|
+
# @return [Hash, nil] the saved search entry.
|
|
151
169
|
def self.record_find(report)
|
|
152
170
|
History.new.record_search(query: report[:query], offer_count: report[:offers].length,
|
|
153
|
-
message: report[:message])
|
|
171
|
+
message: report[:message], offers: report[:offers])
|
|
154
172
|
end
|
|
155
173
|
private_class_method :record_find
|
|
156
174
|
|
|
175
|
+
# docs/plans/human-pick-and-approve.md Phase 2: the saved search's id,
|
|
176
|
+
# for `portage pick --search`. Additive; absent when nothing was saved.
|
|
177
|
+
def self.with_search_id(report, entry)
|
|
178
|
+
entry.is_a?(Hash) && entry["search_id"] ? report.merge(search_id: entry["search_id"]) : report
|
|
179
|
+
end
|
|
180
|
+
private_class_method :with_search_id
|
|
181
|
+
|
|
157
182
|
def self.parse_find_options(argv)
|
|
158
183
|
opts = {}
|
|
159
184
|
find_option_parser(opts).parse!(argv)
|
|
@@ -184,6 +209,53 @@ module Portage
|
|
|
184
209
|
def self.to_minor_units(major) = (major * 100).round
|
|
185
210
|
private_class_method :to_minor_units
|
|
186
211
|
|
|
212
|
+
# --- check ---
|
|
213
|
+
|
|
214
|
+
# `portage check <url>` — can Portage buy from this store, and how?
|
|
215
|
+
# Exit 0 for `automated`/`webmcp`, 1 for anything else (and for a bad
|
|
216
|
+
# or missing URL, with the usage on stderr).
|
|
217
|
+
def self.run_check(argv)
|
|
218
|
+
opts = parse_check_options(argv)
|
|
219
|
+
return 1 unless opts
|
|
220
|
+
return 1 unless apply_proxy_settings(opts[:proxy])
|
|
221
|
+
|
|
222
|
+
report = Check.new(opts[:url]).call
|
|
223
|
+
puts opts[:json] ? JSON.pretty_generate(report) : format_check(report)
|
|
224
|
+
Check::USABLE_VERDICTS.include?(report[:verdict]) ? 0 : 1
|
|
225
|
+
end
|
|
226
|
+
private_class_method :run_check
|
|
227
|
+
|
|
228
|
+
def self.parse_check_options(argv)
|
|
229
|
+
opts = { proxy: {} }
|
|
230
|
+
OptionParser.new do |parser|
|
|
231
|
+
parser.on("--json") { opts[:json] = true }
|
|
232
|
+
ProxySettings.add_options(parser, opts[:proxy])
|
|
233
|
+
end.parse!(argv)
|
|
234
|
+
opts[:url] = argv.shift.to_s.strip
|
|
235
|
+
return opts if !opts[:url].empty? && argv.empty? && valid_check_url?(opts[:url])
|
|
236
|
+
|
|
237
|
+
warn USAGE
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
private_class_method :parse_check_options
|
|
241
|
+
|
|
242
|
+
def self.valid_check_url?(url)
|
|
243
|
+
uri = URI.parse(url =~ %r{\Ahttps?://}i ? url : "https://#{url}")
|
|
244
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
245
|
+
rescue URI::InvalidURIError
|
|
246
|
+
false
|
|
247
|
+
end
|
|
248
|
+
private_class_method :valid_check_url?
|
|
249
|
+
|
|
250
|
+
def self.format_check(report)
|
|
251
|
+
lines = ["#{report[:url]}: #{report[:verdict]}"]
|
|
252
|
+
lines << " platform: #{report[:platform]}" if report[:platform]
|
|
253
|
+
lines << " webmcp: #{report[:webmcp][:status]}#{" (#{report[:webmcp][:reason]})" if report[:webmcp][:reason]}"
|
|
254
|
+
lines << " next: #{report[:next_step]}"
|
|
255
|
+
lines.join("\n")
|
|
256
|
+
end
|
|
257
|
+
private_class_method :format_check
|
|
258
|
+
|
|
187
259
|
# --- compare ---
|
|
188
260
|
|
|
189
261
|
def self.run_compare(argv)
|
|
@@ -195,16 +267,26 @@ module Portage
|
|
|
195
267
|
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
196
268
|
|
|
197
269
|
report = Compare.new(origin_url: url, **options).call
|
|
198
|
-
|
|
199
|
-
# query string names the compare so `portage history list` doesn't
|
|
200
|
-
# read it as a plain text search for the origin product's own title.
|
|
201
|
-
History.new.record_search(query: "compare: #{url} (product #{options[:origin_product_id]})",
|
|
202
|
-
offer_count: report[:offers].length, message: report[:message])
|
|
270
|
+
report = with_search_id(report, record_compare(url, options[:origin_product_id], report))
|
|
203
271
|
puts json ? JSON.pretty_generate(report) : format_compare(report)
|
|
204
272
|
report[:offers].any? ? 0 : 1
|
|
205
273
|
end
|
|
206
274
|
private_class_method :run_compare
|
|
207
275
|
|
|
276
|
+
# Recorded as a search, not a purchase — compare never checks out. The
|
|
277
|
+
# query string names the compare so `portage history list` doesn't
|
|
278
|
+
# read it as a plain text search for the origin product's own title.
|
|
279
|
+
# Its offers are kept with their refs, like `find`'s
|
|
280
|
+
# (docs/plans/human-pick-and-approve.md Phase 2), each carrying the
|
|
281
|
+
# catalog query compare searched with (the origin product's title), so
|
|
282
|
+
# `buy --offer REF` searches for the product, not for "compare: ...".
|
|
283
|
+
def self.record_compare(url, product_id, report)
|
|
284
|
+
offers = report[:offers].map { |offer| offer.merge(query: report[:query]) }
|
|
285
|
+
History.new.record_search(query: "compare: #{url} (product #{product_id})", offer_count: offers.length,
|
|
286
|
+
message: report[:message], offers: offers)
|
|
287
|
+
end
|
|
288
|
+
private_class_method :record_compare
|
|
289
|
+
|
|
208
290
|
def self.parse_compare_options(argv)
|
|
209
291
|
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
210
292
|
opts = { identity: [] }
|
|
@@ -245,11 +327,21 @@ module Portage
|
|
|
245
327
|
|
|
246
328
|
parsed[:handoff_target] = handoff_target(parsed, url)
|
|
247
329
|
return 1 unless parsed[:handoff_target]
|
|
330
|
+
|
|
331
|
+
dispatch_buy(parsed, url)
|
|
332
|
+
end
|
|
333
|
+
private_class_method :run_buy
|
|
334
|
+
|
|
335
|
+
# A saved quote or offer names its own store; otherwise a url does, and
|
|
336
|
+
# with neither the search picks one.
|
|
337
|
+
def self.dispatch_buy(parsed, url)
|
|
338
|
+
return buy_from_quote(parsed) if parsed[:quote]
|
|
339
|
+
return buy_from_offer(parsed) if parsed[:offer]
|
|
248
340
|
return execute_buy(parsed, url) if url
|
|
249
341
|
|
|
250
342
|
buy_from_search(parsed)
|
|
251
343
|
end
|
|
252
|
-
private_class_method :
|
|
344
|
+
private_class_method :dispatch_buy
|
|
253
345
|
|
|
254
346
|
# Built and validated up front, same posture as #confidence_check — an
|
|
255
347
|
# unknown --handoff-target/PORTAGE_HANDOFF_TARGET/config.json value is a
|
|
@@ -267,48 +359,205 @@ module Portage
|
|
|
267
359
|
# caller picks. `--yes` alone deliberately isn't enough to get here —
|
|
268
360
|
# without a URL the merchant would have been chosen by a search ranker
|
|
269
361
|
# rather than by a person, so either `--store` (handled above) or an
|
|
270
|
-
# interactive pick has to name it.
|
|
362
|
+
# interactive pick has to name it. Runs with no terminal (or under
|
|
363
|
+
# --json) list the offers and stop.
|
|
271
364
|
def self.buy_from_search(parsed)
|
|
272
365
|
report = Find.new(**parsed[:find]).call
|
|
273
|
-
record_find(report)
|
|
366
|
+
report = with_search_id(report, record_find(report))
|
|
274
367
|
offer = pick_offer(report, parsed[:json])
|
|
275
368
|
return report[:offers].any? ? 0 : 1 unless offer
|
|
276
369
|
|
|
370
|
+
parsed[:offer_ref] = offer[:offer_ref]
|
|
371
|
+
parsed[:page] = { title: offer[:title], url: offer[:url] }
|
|
277
372
|
execute_buy(parsed, offer[:store], product_id: offer[:product_id])
|
|
278
373
|
end
|
|
279
374
|
private_class_method :buy_from_search
|
|
280
375
|
|
|
376
|
+
# `--offer REF`: the store, product and query come from the saved
|
|
377
|
+
# `find` that produced the ref, as if they'd been passed as flags.
|
|
378
|
+
def self.buy_from_offer(parsed)
|
|
379
|
+
offer = History.new.offer(parsed[:offer])
|
|
380
|
+
unless offer
|
|
381
|
+
invalid_buy_option("No saved offer #{parsed[:offer]} — run `portage find` again.",
|
|
382
|
+
url: nil, json: parsed[:json], outcome: "offer_not_found")
|
|
383
|
+
return 1
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
parsed[:offer_ref] = parsed[:offer]
|
|
387
|
+
parsed[:page] = { title: offer["title"], url: offer["url"] }
|
|
388
|
+
parsed[:buy][:query] = offer["query"].to_s
|
|
389
|
+
execute_buy(parsed, offer["store"], product_id: offer["product_id"])
|
|
390
|
+
end
|
|
391
|
+
private_class_method :buy_from_offer
|
|
392
|
+
|
|
393
|
+
# `--quote QUOTE_ID`: buys what a `--dry-run` showed. Buy is handed the
|
|
394
|
+
# quoted total as a cap and refuses (`quote_changed`) if the real
|
|
395
|
+
# checkout costs more, so the person's approval always covers the total
|
|
396
|
+
# that gets charged. The quote is spent by #settle_quote, once the run
|
|
397
|
+
# purchases or hands off.
|
|
398
|
+
#
|
|
399
|
+
# docs/plans/human-pick-and-approve.md Phase 2: a real run (`--yes`, not
|
|
400
|
+
# `--dry-run`) of a quote that isn't approved enough for
|
|
401
|
+
# `--require-approval` is refused with `needs_approval` before Buy
|
|
402
|
+
# runs, so it never charges and never hands off.
|
|
403
|
+
def self.buy_from_quote(parsed)
|
|
404
|
+
quote = usable_quote(parsed)
|
|
405
|
+
return 1 unless quote
|
|
406
|
+
|
|
407
|
+
level = ApprovalPolicy.level
|
|
408
|
+
if real_run?(parsed) && !ApprovalPolicy.satisfied?(quote, level)
|
|
409
|
+
return refuse_unapproved_quote(parsed, quote, level)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
parsed[:quote_record] = quote
|
|
413
|
+
buy = parsed[:buy]
|
|
414
|
+
buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
|
|
415
|
+
buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"])
|
|
416
|
+
execute_buy(parsed, quote["store"])
|
|
417
|
+
end
|
|
418
|
+
private_class_method :buy_from_quote
|
|
419
|
+
|
|
420
|
+
def self.usable_quote(parsed)
|
|
421
|
+
quote = Quotes.new.find(parsed[:quote])
|
|
422
|
+
unless quote
|
|
423
|
+
return invalid_buy_option("No saved quote #{parsed[:quote]} — run `portage buy ... --dry-run --json` " \
|
|
424
|
+
"for a new one.", url: nil, json: parsed[:json], outcome: "quote_not_found")
|
|
425
|
+
end
|
|
426
|
+
return quote unless quote["used_at"]
|
|
427
|
+
|
|
428
|
+
invalid_buy_option("Quote #{parsed[:quote]} has already been used — dry-run again for a new one.",
|
|
429
|
+
url: quote["store"], json: parsed[:json], outcome: "quote_used")
|
|
430
|
+
end
|
|
431
|
+
private_class_method :usable_quote
|
|
432
|
+
|
|
433
|
+
def self.refuse_unapproved_quote(parsed, quote, level)
|
|
434
|
+
summary = Approve.summary(quote)
|
|
435
|
+
report = { url: quote["store"], checkout_url: nil, products: [], warnings: [], source: "none", browse: false,
|
|
436
|
+
checkout: false }.merge(Approve.needs_approval(summary, message: approval_message(summary, level)))
|
|
437
|
+
print_buy_report(report, nil, parsed[:json])
|
|
438
|
+
buy_exit_code(report)
|
|
439
|
+
end
|
|
440
|
+
private_class_method :refuse_unapproved_quote
|
|
441
|
+
|
|
442
|
+
# With a terminal (and no --json) the person picks there, through the
|
|
443
|
+
# same HumanPrompt numbered pick `portage pick` uses; otherwise the
|
|
444
|
+
# offers are printed and nothing is bought.
|
|
281
445
|
def self.pick_offer(report, json)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
446
|
+
prompt = HumanPrompt.new(json: json)
|
|
447
|
+
unless prompt.tty? && report[:offers].any?
|
|
448
|
+
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
449
|
+
return nil
|
|
450
|
+
end
|
|
285
451
|
|
|
286
|
-
prompt_for_offer(report
|
|
452
|
+
prompt_for_offer(prompt, report)
|
|
287
453
|
end
|
|
288
454
|
private_class_method :pick_offer
|
|
289
455
|
|
|
290
|
-
def self.prompt_for_offer(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
offers[choice.to_i - 1] if choice.to_i.between?(1, offers.length)
|
|
456
|
+
def self.prompt_for_offer(prompt, report)
|
|
457
|
+
prompt.say(report[:message].to_s)
|
|
458
|
+
choices = report[:offers].map { |offer| OfferChoice.for(offer) }
|
|
459
|
+
index = prompt.choose("Pick one to buy", choices, view: OfferChoice.method(:view_message))
|
|
460
|
+
index && report[:offers][index]
|
|
296
461
|
end
|
|
297
462
|
private_class_method :prompt_for_offer
|
|
298
463
|
|
|
464
|
+
# docs/plans/human-pick-and-approve.md Phase 2: under
|
|
465
|
+
# `--require-approval any|person` a real `--yes` run with no approved
|
|
466
|
+
# `--quote` is run as a dry run instead — never charged, never handed
|
|
467
|
+
# off — which prices it and saves a quote, and the report becomes
|
|
468
|
+
# `needs_approval` naming that quote and the next steps. Done here, not
|
|
469
|
+
# in Buy, so Buy's library callers aren't governed by the CLI policy.
|
|
299
470
|
def self.execute_buy(parsed, url, product_id: nil)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
options
|
|
303
|
-
options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
|
|
471
|
+
gated = approval_gate?(parsed)
|
|
472
|
+
parsed[:buy] = parsed[:buy].merge(yes: false, dry_run: true) if gated
|
|
473
|
+
options = buy_options(parsed, url, product_id: product_id)
|
|
304
474
|
report = Buy.new(**options).call
|
|
305
475
|
record_buy(report, options[:query])
|
|
476
|
+
report = settle_quote(report, parsed, options)
|
|
477
|
+
report = needs_approval_report(report) if gated
|
|
306
478
|
result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
|
|
307
479
|
print_buy_report(report, result, parsed[:json])
|
|
308
|
-
report
|
|
480
|
+
buy_exit_code(report)
|
|
309
481
|
end
|
|
310
482
|
private_class_method :execute_buy
|
|
311
483
|
|
|
484
|
+
def self.buy_options(parsed, url, product_id: nil)
|
|
485
|
+
options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
|
|
486
|
+
handoff_target: parsed[:handoff_target], json: !parsed[:json].nil?)
|
|
487
|
+
options[:product_id] ||= product_id
|
|
488
|
+
options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
|
|
489
|
+
options
|
|
490
|
+
end
|
|
491
|
+
private_class_method :buy_options
|
|
492
|
+
|
|
493
|
+
# `needs_approval` exits 0 like Buy's own `needs_confirmation`: the run
|
|
494
|
+
# did what it could and is waiting on the person.
|
|
495
|
+
def self.buy_exit_code(report)
|
|
496
|
+
report[:outcome] == "needs_approval" || report[:checkout] || report[:browse] ? 0 : 1
|
|
497
|
+
end
|
|
498
|
+
private_class_method :buy_exit_code
|
|
499
|
+
|
|
500
|
+
def self.real_run?(parsed) = parsed[:buy][:yes] && !parsed[:buy][:dry_run]
|
|
501
|
+
private_class_method :real_run?
|
|
502
|
+
|
|
503
|
+
# An approved quote already passed ApprovalPolicy in #buy_from_quote.
|
|
504
|
+
def self.approval_gate?(parsed)
|
|
505
|
+
real_run?(parsed) && !parsed[:quote_record] && ApprovalPolicy.level != "off"
|
|
506
|
+
end
|
|
507
|
+
private_class_method :approval_gate?
|
|
508
|
+
|
|
509
|
+
# A gated run that got as far as a priced checkout saved a quote; one
|
|
510
|
+
# that didn't (no match, a dead end) is reported as it is — nothing
|
|
511
|
+
# was bought either way.
|
|
512
|
+
def self.needs_approval_report(report)
|
|
513
|
+
quote = report[:quote_id] && Quotes.new.find(report[:quote_id])
|
|
514
|
+
return report unless quote
|
|
515
|
+
|
|
516
|
+
summary = Approve.summary(quote)
|
|
517
|
+
report.merge(Approve.needs_approval(summary, message: approval_message(summary, ApprovalPolicy.level)))
|
|
518
|
+
end
|
|
519
|
+
private_class_method :needs_approval_report
|
|
520
|
+
|
|
521
|
+
def self.approval_message(summary, level)
|
|
522
|
+
id = summary[:quote_id]
|
|
523
|
+
held = if summary[:approved_by]
|
|
524
|
+
"Quote #{id} was approved by #{summary[:approved_by]}, which require_approval #{level} doesn't accept."
|
|
525
|
+
else
|
|
526
|
+
"Quote #{id} (#{Approve.describe(summary)}) needs approval first (require_approval: #{level})."
|
|
527
|
+
end
|
|
528
|
+
how = level == "person" ? "`portage approve #{id} --via tty` at a terminal" : "`portage approve #{id}`"
|
|
529
|
+
"Nothing was bought. #{held} Approve it with #{how}, then run `portage buy --quote #{id} --yes`."
|
|
530
|
+
end
|
|
531
|
+
private_class_method :approval_message
|
|
532
|
+
|
|
533
|
+
# A dry run saves a quote and reports its `quote_id`. A run of a saved
|
|
534
|
+
# quote spends it once it purchases or hands off; any other outcome
|
|
535
|
+
# (needs_confirmation, a dry run, an error) leaves it usable.
|
|
536
|
+
def self.settle_quote(report, parsed, options)
|
|
537
|
+
quote = parsed[:quote_record]
|
|
538
|
+
if quote
|
|
539
|
+
Quotes.new.consume(quote["quote_id"]) if report[:outcome] == "purchased" || report[:handoff]
|
|
540
|
+
return report[:outcome] == "quote_changed" ? report.merge(quote_id: quote["quote_id"]) : report
|
|
541
|
+
end
|
|
542
|
+
return report unless report[:outcome] == "dry_run"
|
|
543
|
+
|
|
544
|
+
saved = Quotes.new.create(offer_ref: parsed[:offer_ref], store: report[:url],
|
|
545
|
+
product_id: options[:product_id], query: options[:query], qty: options[:qty],
|
|
546
|
+
total: report_total(report), currency: report[:currency],
|
|
547
|
+
**quote_page(report, parsed))
|
|
548
|
+
saved ? report.merge(quote_id: saved["quote_id"]) : report
|
|
549
|
+
end
|
|
550
|
+
private_class_method :settle_quote
|
|
551
|
+
|
|
552
|
+
# What `portage approve` shows (docs/plans/human-pick-and-approve.md
|
|
553
|
+
# Phase 2): the title of what's in the checkout, else the picked
|
|
554
|
+
# offer's, and the offer's product page when the buy came from one.
|
|
555
|
+
def self.quote_page(report, parsed)
|
|
556
|
+
page = parsed[:page] || {}
|
|
557
|
+
{ title: Array(report[:items]).first&.dig(:title) || page[:title], url: page[:url] }
|
|
558
|
+
end
|
|
559
|
+
private_class_method :quote_page
|
|
560
|
+
|
|
312
561
|
# docs/plans/buy-skill-and-local-browser.md Phase 6: `--handoff-target
|
|
313
562
|
# profile` gives `portage buy` a browser of its own — the Portage
|
|
314
563
|
# profile — so it's attached here as Buy's `webmcp_bridge:`, exactly
|
|
@@ -458,14 +707,14 @@ module Portage
|
|
|
458
707
|
# any other, with outcome `invalid_option`, so an agent loop reading
|
|
459
708
|
# stdout gets JSON rather than nothing and a line on stderr.
|
|
460
709
|
# @return [nil]
|
|
461
|
-
def self.invalid_buy_option(message, url:, json:)
|
|
710
|
+
def self.invalid_buy_option(message, url:, json:, outcome: "invalid_option")
|
|
462
711
|
unless json
|
|
463
712
|
warn message
|
|
464
713
|
return nil
|
|
465
714
|
end
|
|
466
715
|
|
|
467
716
|
puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
|
|
468
|
-
outcome:
|
|
717
|
+
outcome: outcome, browse: false, checkout: false, message: message)
|
|
469
718
|
nil
|
|
470
719
|
end
|
|
471
720
|
private_class_method :invalid_buy_option
|
|
@@ -510,7 +759,7 @@ module Portage
|
|
|
510
759
|
parser.parse!(argv)
|
|
511
760
|
url = reinterpret_bare_query(url, buy, parsed)
|
|
512
761
|
buy[:query] ||= ""
|
|
513
|
-
return parsed if url
|
|
762
|
+
return parsed if buy_target?(url, buy, parsed)
|
|
514
763
|
|
|
515
764
|
warn USAGE
|
|
516
765
|
nil
|
|
@@ -519,6 +768,11 @@ module Portage
|
|
|
519
768
|
end
|
|
520
769
|
private_class_method :parse_buy_options
|
|
521
770
|
|
|
771
|
+
def self.buy_target?(url, buy, parsed)
|
|
772
|
+
url || !buy[:query].strip.empty? || parsed[:offer] || parsed[:quote]
|
|
773
|
+
end
|
|
774
|
+
private_class_method :buy_target?
|
|
775
|
+
|
|
522
776
|
# Bare arg is normally the store URL (`portage buy <url> --query "..."`),
|
|
523
777
|
# but `portage buy "coffee"` — no --query, and "coffee" doesn't look like
|
|
524
778
|
# a URL/domain — means the same thing as `portage buy --query "coffee"`:
|
|
@@ -583,6 +837,8 @@ module Portage
|
|
|
583
837
|
# and the catalog search once a store is settled, so it's registered once
|
|
584
838
|
# here rather than twice on the same parser.
|
|
585
839
|
def self.add_search_options(parser, buy, parsed)
|
|
840
|
+
parser.on("--offer REF") { |v| parsed[:offer] = v }
|
|
841
|
+
parser.on("--quote QUOTE_ID") { |v| parsed[:quote] = v }
|
|
586
842
|
parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
|
|
587
843
|
parser.on("--store URL") { |v| parsed[:store] = v }
|
|
588
844
|
parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
|
|
@@ -590,6 +846,94 @@ module Portage
|
|
|
590
846
|
end
|
|
591
847
|
private_class_method :add_search_options
|
|
592
848
|
|
|
849
|
+
# --- pick / approve (docs/plans/human-pick-and-approve.md Phase 2) ---
|
|
850
|
+
|
|
851
|
+
# Outcomes a pick/approve run exits 0 on: an answer, a page shown, or a
|
|
852
|
+
# question handed to the agent. Everything else (cancelled, not found,
|
|
853
|
+
# used, refused, no terminal) exits 1.
|
|
854
|
+
PROMPT_OK_OUTCOMES = %w[picked approved viewed needs_pick needs_approval].freeze
|
|
855
|
+
|
|
856
|
+
def self.run_pick(argv)
|
|
857
|
+
opts = parse_prompt_options(argv) do |parser, o|
|
|
858
|
+
parser.on("--search ID") { |v| o[:search] = v }
|
|
859
|
+
parser.on("--choose REF") { |v| o[:choose] = v }
|
|
860
|
+
parser.on("--compare REF") { |v| o[:compare] = v }
|
|
861
|
+
parser.on("--view REF") { |v| o[:view] = v }
|
|
862
|
+
end
|
|
863
|
+
return 1 unless opts
|
|
864
|
+
|
|
865
|
+
pick = Pick.new(prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]), comparer: method(:compare_offer),
|
|
866
|
+
**opts.slice(:search, :choose, :view, :compare))
|
|
867
|
+
print_prompt_result(pick.call, opts[:json])
|
|
868
|
+
end
|
|
869
|
+
private_class_method :run_pick
|
|
870
|
+
|
|
871
|
+
def self.run_approve(argv)
|
|
872
|
+
quote_id = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
873
|
+
opts = parse_prompt_options(argv) do |parser, o|
|
|
874
|
+
parser.on("--relayed-yes") { o[:relayed_yes] = true }
|
|
875
|
+
parser.on("--view") { o[:view] = true }
|
|
876
|
+
end
|
|
877
|
+
return 1 unless opts
|
|
878
|
+
return prompt_usage(opts[:json], "portage approve needs a QUOTE_ID.") unless quote_id
|
|
879
|
+
|
|
880
|
+
approve = Approve.new(quote_id: quote_id, prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]),
|
|
881
|
+
**opts.slice(:relayed_yes, :view))
|
|
882
|
+
print_prompt_result(approve.call, opts[:json])
|
|
883
|
+
end
|
|
884
|
+
private_class_method :run_approve
|
|
885
|
+
|
|
886
|
+
# `--via`/`--json` for both commands, plus whatever the block adds.
|
|
887
|
+
# @return [Hash, nil] nil on a bad flag (already reported).
|
|
888
|
+
def self.parse_prompt_options(argv)
|
|
889
|
+
opts = { via: "auto" }
|
|
890
|
+
json = argv.include?("--json")
|
|
891
|
+
OptionParser.new do |parser|
|
|
892
|
+
parser.on("--via SURFACE", HumanPrompt::VIAS) { |v| opts[:via] = v }
|
|
893
|
+
parser.on("--json") { opts[:json] = true }
|
|
894
|
+
yield parser, opts
|
|
895
|
+
end.parse!(argv)
|
|
896
|
+
opts
|
|
897
|
+
rescue OptionParser::ParseError => e
|
|
898
|
+
prompt_usage(json, e.message)
|
|
899
|
+
nil
|
|
900
|
+
end
|
|
901
|
+
private_class_method :parse_prompt_options
|
|
902
|
+
|
|
903
|
+
def self.prompt_usage(json, message)
|
|
904
|
+
json ? puts(JSON.pretty_generate(outcome: "invalid_option", message: message)) : warn("#{message}\n#{USAGE}")
|
|
905
|
+
1
|
|
906
|
+
end
|
|
907
|
+
private_class_method :prompt_usage
|
|
908
|
+
|
|
909
|
+
# Pick's "Compare an offer across stores": the same Compare run
|
|
910
|
+
# `portage compare` does, from the saved offer's store and product.
|
|
911
|
+
def self.compare_offer(offer)
|
|
912
|
+
return { offers: [], message: "Couldn't compare — see the proxy error above." } unless apply_proxy_settings({})
|
|
913
|
+
|
|
914
|
+
Compare.new(origin_url: offer["store"], origin_product_id: offer["product_id"]).call
|
|
915
|
+
end
|
|
916
|
+
private_class_method :compare_offer
|
|
917
|
+
|
|
918
|
+
def self.print_prompt_result(result, json)
|
|
919
|
+
puts json ? JSON.pretty_generate(result) : format_prompt_result(result)
|
|
920
|
+
PROMPT_OK_OUTCOMES.include?(result[:outcome]) ? 0 : 1
|
|
921
|
+
end
|
|
922
|
+
private_class_method :print_prompt_result
|
|
923
|
+
|
|
924
|
+
def self.format_prompt_result(result)
|
|
925
|
+
lines = ["[#{result[:outcome]}] #{result[:message]}"]
|
|
926
|
+
Array(result[:choices]).each_with_index { |choice, index| lines << " #{index + 1}. #{choice_line(choice)}" }
|
|
927
|
+
lines << " #{approval_line(result[:summary])}" if result[:summary]
|
|
928
|
+
lines.join("\n")
|
|
929
|
+
end
|
|
930
|
+
private_class_method :format_prompt_result
|
|
931
|
+
|
|
932
|
+
def self.choice_line(choice)
|
|
933
|
+
[choice[:label], choice[:url], ("ref #{choice[:ref]}" if choice[:ref])].compact.join(" — ")
|
|
934
|
+
end
|
|
935
|
+
private_class_method :choice_line
|
|
936
|
+
|
|
593
937
|
# --- history ---
|
|
594
938
|
|
|
595
939
|
def self.run_history(argv)
|
|
@@ -783,53 +1127,110 @@ module Portage
|
|
|
783
1127
|
end
|
|
784
1128
|
private_class_method :run_policy
|
|
785
1129
|
|
|
1130
|
+
# `require_approval` is always shown at its effective value, the
|
|
1131
|
+
# default included, so "what does `--yes` need right now" is never a
|
|
1132
|
+
# guess.
|
|
786
1133
|
def self.run_policy_show(argv)
|
|
787
1134
|
json = false
|
|
788
1135
|
OptionParser.new { |parser| parser.on("--json") { json = true } }.parse!(argv)
|
|
789
|
-
policy = Portage::Ucp::Policy.load
|
|
790
|
-
|
|
1136
|
+
policy = Portage::Ucp::Policy.load
|
|
1137
|
+
effective = policy.to_h.merge(ApprovalPolicy::KEY => ApprovalPolicy.level(policy))
|
|
1138
|
+
puts json ? JSON.pretty_generate(effective) : format_policy(policy)
|
|
791
1139
|
0
|
|
792
1140
|
end
|
|
793
1141
|
private_class_method :run_policy_show
|
|
794
1142
|
|
|
795
1143
|
def self.format_policy(policy)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1144
|
+
spending = policy.to_h.except(ApprovalPolicy::KEY)
|
|
1145
|
+
body = spending.empty? ? "(no policy configured — every spending check passes)" : JSON.pretty_generate(spending)
|
|
1146
|
+
default = " (default)" unless ApprovalPolicy.configured?(policy)
|
|
1147
|
+
"#{body}\nrequire_approval: #{ApprovalPolicy.level(policy)}#{default}"
|
|
799
1148
|
end
|
|
800
1149
|
private_class_method :format_policy
|
|
801
1150
|
|
|
802
1151
|
def self.parse_policy_set_options(argv)
|
|
803
1152
|
opts = { allow: [] }
|
|
804
1153
|
OptionParser.new do |parser|
|
|
805
|
-
parser
|
|
806
|
-
parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
|
|
807
|
-
parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
|
|
808
|
-
parser.on("--currency CUR") { |v| opts[:currency] = v }
|
|
1154
|
+
add_policy_cap_options(parser, opts)
|
|
809
1155
|
parser.on("--velocity-count N", Integer) { |v| opts[:velocity_count] = v }
|
|
810
1156
|
parser.on("--velocity-window-seconds N", Integer) { |v| opts[:velocity_window_seconds] = v }
|
|
811
1157
|
parser.on("--allow HOST") { |v| opts[:allow] << v }
|
|
812
1158
|
parser.on("--clear-allowlist") { opts[:clear_allowlist] = true }
|
|
1159
|
+
parser.on("--require-approval LEVEL", ApprovalPolicy::LEVELS) { |v| opts[:require_approval] = v }
|
|
813
1160
|
end.parse!(argv)
|
|
814
1161
|
opts
|
|
815
1162
|
end
|
|
816
1163
|
private_class_method :parse_policy_set_options
|
|
817
1164
|
|
|
1165
|
+
def self.add_policy_cap_options(parser, opts)
|
|
1166
|
+
parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
|
|
1167
|
+
parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
|
|
1168
|
+
parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
|
|
1169
|
+
parser.on("--currency CUR") { |v| opts[:currency] = v }
|
|
1170
|
+
end
|
|
1171
|
+
private_class_method :add_policy_cap_options
|
|
1172
|
+
|
|
818
1173
|
# Each `--*` group is applied independently and only when its required
|
|
819
1174
|
# fields are present — `portage policy set --allow shop.example.com`
|
|
820
1175
|
# touches only the allowlist, leaving caps/velocity untouched, so caps
|
|
821
1176
|
# and the allowlist can be configured in separate invocations.
|
|
1177
|
+
#
|
|
1178
|
+
# `--require-approval` goes first: a lowering the person doesn't confirm
|
|
1179
|
+
# at the terminal refuses the whole invocation, so nothing else in it
|
|
1180
|
+
# changes either.
|
|
822
1181
|
def self.run_policy_set(argv)
|
|
823
1182
|
opts = parse_policy_set_options(argv)
|
|
824
1183
|
policy = Portage::Ucp::Policy.load
|
|
1184
|
+
return 1 unless require_approval_applied?(policy, opts[:require_approval])
|
|
1185
|
+
|
|
825
1186
|
set_policy_cap(policy, opts)
|
|
826
1187
|
set_policy_velocity(policy, opts)
|
|
827
1188
|
set_policy_allowlist(policy, opts)
|
|
828
|
-
puts format_policy(policy
|
|
1189
|
+
puts format_policy(policy)
|
|
829
1190
|
0
|
|
1191
|
+
rescue OptionParser::ParseError => e
|
|
1192
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1193
|
+
1
|
|
830
1194
|
end
|
|
831
1195
|
private_class_method :run_policy_set
|
|
832
1196
|
|
|
1197
|
+
# docs/plans/human-pick-and-approve.md Phase 2: raising the level (or
|
|
1198
|
+
# setting the same one) needs nothing; lowering it (person -> any/off,
|
|
1199
|
+
# any -> off) needs a yes typed on the tty, since an agent with a shell
|
|
1200
|
+
# can run `policy set` but can't type on /dev/tty. No terminal, no
|
|
1201
|
+
# change.
|
|
1202
|
+
# @return [Boolean] false when a lowering was refused (nothing changed).
|
|
1203
|
+
def self.require_approval_applied?(policy, level)
|
|
1204
|
+
return true unless level
|
|
1205
|
+
|
|
1206
|
+
current = ApprovalPolicy.level(policy)
|
|
1207
|
+
return false if ApprovalPolicy.lowering?(current, level) && !confirm_lowering(current, level)
|
|
1208
|
+
|
|
1209
|
+
policy.set(ApprovalPolicy::KEY, level)
|
|
1210
|
+
true
|
|
1211
|
+
end
|
|
1212
|
+
private_class_method :require_approval_applied?
|
|
1213
|
+
|
|
1214
|
+
def self.confirm_lowering(current, level)
|
|
1215
|
+
prompt = HumanPrompt.new(via: "tty")
|
|
1216
|
+
return true if prompt.confirm("Lower require_approval from #{current} to #{level}? #{lowering_effect(level)}")
|
|
1217
|
+
|
|
1218
|
+
warn "require_approval left at #{current}."
|
|
1219
|
+
false
|
|
1220
|
+
rescue HumanPrompt::NoTerminal
|
|
1221
|
+
warn "Lowering require_approval (#{current} -> #{level}) needs a yes typed at a terminal, and there's no " \
|
|
1222
|
+
"terminal here — nothing changed. Run it yourself from a terminal."
|
|
1223
|
+
false
|
|
1224
|
+
end
|
|
1225
|
+
private_class_method :confirm_lowering
|
|
1226
|
+
|
|
1227
|
+
def self.lowering_effect(level)
|
|
1228
|
+
return "`portage buy --yes` would then buy without anyone approving the total." if level == "off"
|
|
1229
|
+
|
|
1230
|
+
"An agent relaying your yes would then be enough to buy."
|
|
1231
|
+
end
|
|
1232
|
+
private_class_method :lowering_effect
|
|
1233
|
+
|
|
833
1234
|
def self.set_policy_cap(policy, opts)
|
|
834
1235
|
if opts[:per_transaction_cap]
|
|
835
1236
|
policy.set("per_transaction_cap",
|
|
@@ -1454,6 +1855,7 @@ module Portage
|
|
|
1454
1855
|
lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
|
|
1455
1856
|
report[:products].each { |p| lines << " - #{product_line(p)}" }
|
|
1456
1857
|
lines.concat(format_checkout(report))
|
|
1858
|
+
lines.concat(format_quote(report))
|
|
1457
1859
|
lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
|
|
1458
1860
|
lines.concat(format_handoff(report[:handoff])) if report[:handoff]
|
|
1459
1861
|
lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
|
|
@@ -1461,6 +1863,18 @@ module Portage
|
|
|
1461
1863
|
end
|
|
1462
1864
|
private_class_method :format_report
|
|
1463
1865
|
|
|
1866
|
+
def self.format_quote(report)
|
|
1867
|
+
lines = report[:quote_id] ? [" quote: #{report[:quote_id]}"] : []
|
|
1868
|
+
lines << " approve: #{approval_line(report[:summary])}" if report[:summary]
|
|
1869
|
+
lines
|
|
1870
|
+
end
|
|
1871
|
+
private_class_method :format_quote
|
|
1872
|
+
|
|
1873
|
+
def self.approval_line(summary)
|
|
1874
|
+
[Approve.describe(summary), summary[:url]].compact.join(" — ")
|
|
1875
|
+
end
|
|
1876
|
+
private_class_method :approval_line
|
|
1877
|
+
|
|
1464
1878
|
# What the checkout holds, as opposed to the search results above it,
|
|
1465
1879
|
# and where it differs from the request.
|
|
1466
1880
|
def self.format_checkout(report)
|
|
@@ -1493,6 +1907,7 @@ module Portage
|
|
|
1493
1907
|
def self.format_find(report)
|
|
1494
1908
|
lines = [report[:message].to_s]
|
|
1495
1909
|
report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{offer_line(offer)}" }
|
|
1910
|
+
lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
|
|
1496
1911
|
lines.join("\n")
|
|
1497
1912
|
end
|
|
1498
1913
|
private_class_method :format_find
|
|
@@ -1500,6 +1915,7 @@ module Portage
|
|
|
1500
1915
|
def self.offer_line(offer)
|
|
1501
1916
|
parts = ["#{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
|
|
1502
1917
|
parts << "browse only" unless offer[:checkout]
|
|
1918
|
+
parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
|
|
1503
1919
|
parts.join(" — ")
|
|
1504
1920
|
end
|
|
1505
1921
|
private_class_method :offer_line
|
|
@@ -1507,6 +1923,7 @@ module Portage
|
|
|
1507
1923
|
def self.format_compare(report)
|
|
1508
1924
|
lines = [report[:message].to_s]
|
|
1509
1925
|
report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
|
|
1926
|
+
lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
|
|
1510
1927
|
lines.join("\n")
|
|
1511
1928
|
end
|
|
1512
1929
|
private_class_method :format_compare
|
|
@@ -1514,6 +1931,7 @@ module Portage
|
|
|
1514
1931
|
def self.compare_offer_line(offer)
|
|
1515
1932
|
parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
|
|
1516
1933
|
parts << "browse only" unless offer[:checkout]
|
|
1934
|
+
parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
|
|
1517
1935
|
parts.join(" — ")
|
|
1518
1936
|
end
|
|
1519
1937
|
private_class_method :compare_offer_line
|
|
@@ -1525,9 +1943,7 @@ module Portage
|
|
|
1525
1943
|
end
|
|
1526
1944
|
private_class_method :format_price
|
|
1527
1945
|
|
|
1528
|
-
def self.format_amount(amount, currency)
|
|
1529
|
-
"#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
|
|
1530
|
-
end
|
|
1946
|
+
def self.format_amount(amount, currency) = Money.format_amount(amount, currency)
|
|
1531
1947
|
private_class_method :format_amount
|
|
1532
1948
|
end
|
|
1533
1949
|
end
|