rvgp 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +23 -0
  4. data/LICENSE +504 -0
  5. data/README.md +223 -0
  6. data/Rakefile +32 -0
  7. data/bin/rvgp +8 -0
  8. data/lib/rvgp/application/config.rb +159 -0
  9. data/lib/rvgp/application/descendant_registry.rb +122 -0
  10. data/lib/rvgp/application/status_output.rb +139 -0
  11. data/lib/rvgp/application.rb +170 -0
  12. data/lib/rvgp/base/command.rb +457 -0
  13. data/lib/rvgp/base/grid.rb +531 -0
  14. data/lib/rvgp/base/reader.rb +29 -0
  15. data/lib/rvgp/base/reconciler.rb +434 -0
  16. data/lib/rvgp/base/validation.rb +261 -0
  17. data/lib/rvgp/commands/cashflow.rb +160 -0
  18. data/lib/rvgp/commands/grid.rb +70 -0
  19. data/lib/rvgp/commands/ireconcile.rb +95 -0
  20. data/lib/rvgp/commands/new_project.rb +296 -0
  21. data/lib/rvgp/commands/plot.rb +41 -0
  22. data/lib/rvgp/commands/publish_gsheets.rb +83 -0
  23. data/lib/rvgp/commands/reconcile.rb +58 -0
  24. data/lib/rvgp/commands/rotate_year.rb +202 -0
  25. data/lib/rvgp/commands/validate_journal.rb +59 -0
  26. data/lib/rvgp/commands/validate_system.rb +44 -0
  27. data/lib/rvgp/commands.rb +160 -0
  28. data/lib/rvgp/dashboard.rb +252 -0
  29. data/lib/rvgp/fakers/fake_feed.rb +245 -0
  30. data/lib/rvgp/fakers/fake_journal.rb +57 -0
  31. data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
  32. data/lib/rvgp/fakers/faker_helpers.rb +25 -0
  33. data/lib/rvgp/gem.rb +80 -0
  34. data/lib/rvgp/journal/commodity.rb +453 -0
  35. data/lib/rvgp/journal/complex_commodity.rb +214 -0
  36. data/lib/rvgp/journal/currency.rb +101 -0
  37. data/lib/rvgp/journal/journal.rb +141 -0
  38. data/lib/rvgp/journal/posting.rb +156 -0
  39. data/lib/rvgp/journal/pricer.rb +267 -0
  40. data/lib/rvgp/journal.rb +24 -0
  41. data/lib/rvgp/plot/gnuplot.rb +478 -0
  42. data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
  43. data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
  44. data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
  45. data/lib/rvgp/plot.rb +293 -0
  46. data/lib/rvgp/pta/hledger.rb +237 -0
  47. data/lib/rvgp/pta/ledger.rb +308 -0
  48. data/lib/rvgp/pta.rb +311 -0
  49. data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
  50. data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
  51. data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
  52. data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
  53. data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
  54. data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
  55. data/lib/rvgp/utilities/grid_query.rb +190 -0
  56. data/lib/rvgp/utilities/yaml.rb +131 -0
  57. data/lib/rvgp/utilities.rb +44 -0
  58. data/lib/rvgp/validations/balance_validation.rb +68 -0
  59. data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
  60. data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
  61. data/lib/rvgp.rb +66 -0
  62. data/resources/README.MD/2022-cashflow-google.png +0 -0
  63. data/resources/README.MD/2022-cashflow.png +0 -0
  64. data/resources/README.MD/all-wealth-growth-google.png +0 -0
  65. data/resources/README.MD/all-wealth-growth.png +0 -0
  66. data/resources/gnuplot/default.yml +80 -0
  67. data/resources/i18n/en.yml +192 -0
  68. data/resources/iso-4217-currencies.json +171 -0
  69. data/resources/skel/Rakefile +5 -0
  70. data/resources/skel/app/grids/cashflow_grid.rb +27 -0
  71. data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
  72. data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
  73. data/resources/skel/app/plots/cashflow.yml +33 -0
  74. data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
  75. data/resources/skel/app/plots/wealth-growth.yml +20 -0
  76. data/resources/skel/config/csv-format-acme-checking.yml +9 -0
  77. data/resources/skel/config/google-secrets.yml +5 -0
  78. data/resources/skel/config/rvgp.yml +0 -0
  79. data/resources/skel/journals/prices.db +0 -0
  80. data/rvgp.gemspec +6 -0
  81. data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
  82. data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
  83. data/test/test_command_base.rb +61 -0
  84. data/test/test_commodity.rb +270 -0
  85. data/test/test_csv_reconciler.rb +60 -0
  86. data/test/test_currency.rb +24 -0
  87. data/test/test_fake_feed.rb +228 -0
  88. data/test/test_fake_journal.rb +98 -0
  89. data/test/test_fake_reconciler.rb +60 -0
  90. data/test/test_journal_parse.rb +545 -0
  91. data/test/test_ledger.rb +102 -0
  92. data/test/test_plot.rb +133 -0
  93. data/test/test_posting.rb +50 -0
  94. data/test/test_pricer.rb +139 -0
  95. data/test/test_pta_adapter.rb +575 -0
  96. data/test/test_utilities.rb +45 -0
  97. metadata +268 -0
@@ -0,0 +1,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../utilities'
4
+
5
+ module RVGP
6
+ module Base
7
+ # See {RVGP::Reconcilers} for extensive detail on the structure and function of reconciler yaml files, and
8
+ # reconciler functionality.
9
+ #
10
+ # @attr_reader [String] label The contents of the yaml :label parameter (see above)
11
+ # @attr_reader [String] file The full path to the reconciler yaml file this class was parsed from
12
+ # @attr_reader [String] output_file The contents of the yaml :output parameter (see above)
13
+ # @attr_reader [String] input_file The contents of the yaml :input parameter (see above)
14
+ # @attr_reader [Date] starts_on The contents of the yaml :starts_on parameter (see above)
15
+ # @attr_reader [Hash<String, String>] balances A hash of dates (in 'YYYY-MM-DD') to commodities (as string)
16
+ # corresponding to the balance that are expected on those dates.
17
+ # See {RVGP::Validations::BalanceValidation} for details on this
18
+ # feature.
19
+ # @attr_reader [Array<String>] disable_checks The JournalValidations that are disabled on this reconciler (see
20
+ # above)
21
+ # @attr_reader [String] from The contents of the yaml :from parameter (see above)
22
+ # @attr_reader [Array<Hash>] income_rules The contents of the yaml :income_rules parameter (see above)
23
+ # @attr_reader [Array<Hash>] expense_rules The contents of the yaml :expense_rules parameter (see above)
24
+ # @attr_reader [Array<Hash>] tag_accounts The contents of the yaml :tag_accounts parameter (see above)
25
+ # @attr_reader [Regexp] cash_back The contents of the :match parameter, inside the yaml's :cash_back parameter (see
26
+ # above)
27
+ # @attr_reader [String] cash_back_to The contents of the :to parameter, inside the yaml's :cash_back parameter (see
28
+ # above)
29
+ # @attr_reader [TrueClass,FalseClass] reverse_order The contents of the yaml :reverse_order parameter (see above)
30
+ # @attr_reader [String] default_currency The contents of the yaml :default_currency parameter (see above)
31
+ class Reconciler
32
+ include RVGP::Utilities
33
+
34
+ # This error is thrown when a reconciler yaml is missing one or more require parameters
35
+ class MissingFields < StandardError
36
+ def initialize(*args)
37
+ super format('One or more required keys %s, were missing in the yaml', args.map(&:inspect).join(', '))
38
+ end
39
+ end
40
+
41
+ # @!visibility private
42
+ # This class exists as an intermediary class, mostly to support the source
43
+ # formats of both .csv and .journal files, without forcing one conform to the
44
+ # other.
45
+ class Posting
46
+ attr_accessor :line_number, :date, :description, :commodity, :complex_commodity, :from, :to, :tags, :targets
47
+
48
+ def initialize(line_number, opts = {})
49
+ @line_number = line_number
50
+ @date = opts[:date]
51
+ @description = opts[:description]
52
+ @commodity = opts[:commodity]
53
+ @complex_commodity = opts[:complex_commodity]
54
+ @from = opts[:from]
55
+ @to = opts[:to]
56
+ @tags = opts[:tags] || []
57
+ @targets = opts[:targets] || []
58
+ end
59
+
60
+ # @!visibility private
61
+ def to_journal_posting
62
+ transfers = targets.map do |target|
63
+ RVGP::Journal::Posting::Transfer.new target[:to],
64
+ commodity: target[:commodity],
65
+ complex_commodity: target[:complex_commodity],
66
+ tags: target[:tags] ? target[:tags].map(&:to_tag) : nil
67
+ end
68
+
69
+ RVGP::Journal::Posting.new date,
70
+ description,
71
+ tags: tags ? tags.map(&:to_tag) : nil,
72
+ transfers: transfers + [RVGP::Journal::Posting::Transfer.new(from)]
73
+ end
74
+ end
75
+
76
+ attr_reader :label, :file, :output_file, :input_file, :starts_on, :balances, :disable_checks,
77
+ :from, :income_rules, :expense_rules, :tag_accounts, :cash_back, :cash_back_to,
78
+ :reverse_order, :default_currency
79
+
80
+ # @!visibility private
81
+ HEADER = "; -*- %s -*-¬\n; vim: syntax=ledger"
82
+
83
+ # Create a Reconciler from the provided yaml
84
+ # @param [RVGP::Utilities::Yaml] yaml A file containing the settings to use in the construction of this reconciler
85
+ # . (see above)
86
+ def initialize(yaml)
87
+ @label = yaml[:label]
88
+ @file = yaml.path
89
+ @dependencies = yaml.dependencies
90
+
91
+ @starts_on = yaml.key?(:starts_on) ? Date.strptime(yaml[:starts_on], '%Y-%m-%d') : nil
92
+
93
+ missing_fields = %i[label output input from income expense].find_all { |attr| !yaml.key? attr }
94
+
95
+ raise MissingFields.new(*missing_fields) unless missing_fields.empty?
96
+
97
+ if RVGP.app
98
+ @output_file = RVGP.app.config.build_path format('journals/%s', yaml[:output])
99
+ @input_file = RVGP.app.config.project_path format('feeds/%s', yaml[:input])
100
+ else
101
+ # ATM this path is found in the test environment... possibly we should
102
+ # decouple RVGP.app from this class....
103
+ @output_file = yaml[:output]
104
+ @input_file = yaml[:input]
105
+ end
106
+
107
+ @from = yaml[:from]
108
+ @income_rules = yaml[:income]
109
+ @expense_rules = yaml[:expense]
110
+ @transform_commodities = yaml[:transform_commodities] || {}
111
+ @balances = yaml[:balances]
112
+ @disable_checks = yaml[:disable_checks]&.map(&:to_sym) if yaml.key?(:disable_checks)
113
+ @disable_checks ||= []
114
+
115
+ if yaml.key? :tag_accounts
116
+ @tag_accounts = yaml[:tag_accounts]
117
+
118
+ unless @tag_accounts.all? { |ta| %i[account tag].all? { |k| ta.key? k } }
119
+ raise StandardError, 'One or more tag_accounts entries is missing an :account or :tag key'
120
+ end
121
+ end
122
+
123
+ if yaml.key? :format
124
+ @default_currency = yaml[:format][:default_currency] || '$'
125
+ @reverse_order = yaml[:format][:reverse_order] if yaml[:format].key? :reverse_order
126
+
127
+ if yaml[:format].key?(:cash_back)
128
+ @cash_back = string_to_regex yaml[:format][:cash_back][:match]
129
+ @cash_back_to = yaml[:format][:cash_back][:to]
130
+ end
131
+ end
132
+ end
133
+
134
+ # Returns the taskname to use by rake, for this reconciler
135
+ # @return [String] The taskname, based off the :file basename
136
+ def as_taskname
137
+ File.basename(file, File.extname(file)).tr('^a-z0-9', '-')
138
+ end
139
+
140
+ # @!visibility private
141
+ # This is kinda weird I guess, but, we use it to identify whether the
142
+ # provided str matches one of the unique fields that identifying this object
143
+ # this is mostly (only?) used by the command objects, to resolve parameters
144
+ def matches_argument?(str)
145
+ str_as_file = File.expand_path str
146
+ (as_taskname == str ||
147
+ from == str ||
148
+ label == str ||
149
+ file == str_as_file ||
150
+ input_file == str_as_file ||
151
+ output_file == str_as_file)
152
+ end
153
+
154
+ # Returns the file paths that were referenced by this reconciler in one form or another.
155
+ # Useful for determining build freshness.
156
+ # @return [Array<String>] dependent files, in this reconciler.
157
+ def dependencies
158
+ [file, input_file] + @dependencies
159
+ end
160
+
161
+ # @!visibility private
162
+ def uptodate?
163
+ FileUtils.uptodate? output_file, dependencies
164
+ end
165
+
166
+ # @!visibility private
167
+ # This file is used to mtime the last success
168
+ def validated_touch_file_path
169
+ format('%s.valid', output_file)
170
+ end
171
+
172
+ # @!visibility private
173
+ def mark_validated!
174
+ FileUtils.touch validated_touch_file_path
175
+ end
176
+
177
+ # @!visibility private
178
+ def validated?
179
+ FileUtils.uptodate? validated_touch_file_path, [output_file]
180
+ end
181
+
182
+ # @!visibility private
183
+ def transform_commodity(from)
184
+ # NOTE: We could be dealing with a ComplexCommodity, hence the check
185
+ # for a .code
186
+ if from.respond_to?(:code) && @transform_commodities.key?(from.code.to_sym)
187
+ # NOTE: Maybe we need to Create a new Journal::Commodity, so that the
188
+ # alphacode reloads?
189
+ from.code = @transform_commodities[from.code.to_sym]
190
+ end
191
+
192
+ from
193
+ end
194
+
195
+ # @!visibility private
196
+ def reconcile_posting(rule, posting)
197
+ # NOTE: The shorthand(s) produce more than one tx per csv line, sometimes:
198
+
199
+ to = rule[:to].dup
200
+ posting.from = rule[:from] if rule.key? :from
201
+
202
+ posting.tags << rule[:tag] if rule.key? :tag
203
+
204
+ # Let's do a find and replace on the :to if we have anything captured
205
+ # This is kind of rudimentary, and only supports named_caputers atm
206
+ # but I think it's fine for now. Probably it's broken wrt cash back or
207
+ # something...
208
+ if rule[:captures]
209
+ to.scan(/\$([0-9a-z]+)/i).each do |substitutes|
210
+ substitutes.each do |substitute|
211
+ replace = rule[:captures][substitute]
212
+ to.sub! format('$%s', substitute), replace if replace
213
+ end
214
+ end
215
+ end
216
+
217
+ if rule.key? :to_shorthand
218
+ rule_key = posting.commodity.positive? ? :expense : :income
219
+
220
+ @shorthand ||= {}
221
+ @shorthand[rule_key] ||= {}
222
+ mod = @shorthand[rule_key][rule[:index]]
223
+
224
+ unless mod
225
+ shorthand_klass = format 'RVGP::Reconcilers::Shorthand::%s', rule[:to_shorthand]
226
+
227
+ unless Object.const_defined?(shorthand_klass)
228
+ raise StandardError, format('Unknown shorthand %s', shorthand_klass)
229
+ end
230
+
231
+ mod = Object.const_get(shorthand_klass).new rule
232
+
233
+ @shorthand[rule_key][rule[:index]] = mod
234
+ end
235
+
236
+ mod.to_tx posting
237
+ elsif rule.key?(:targets)
238
+ # NOTE: I guess we don't support cashback when multiple targets are
239
+ # specified ATM
240
+
241
+ # If it turns out we need this feature in the future, I guess,
242
+ # implement it?
243
+ raise StandardError, 'Unimplemented.' if cash_back&.match(posting.description)
244
+
245
+ posting.targets = rule[:targets].map do |rule_target|
246
+ if rule_target.key? :currency
247
+ commodity = RVGP::Journal::Commodity.from_symbol_and_amount(
248
+ rule_target[:currency] || default_currency,
249
+ rule_target[:amount].to_s
250
+ )
251
+ elsif rule_target.key? :complex_commodity
252
+ complex_commodity = RVGP::Journal::ComplexCommodity.from_s(rule_target[:complex_commodity])
253
+ else
254
+ commodity = rule_target[:amount].to_s.to_commodity
255
+ end
256
+
257
+ { to: rule_target[:to],
258
+ commodity: commodity,
259
+ complex_commodity: complex_commodity,
260
+ tags: rule_target[:tags] }
261
+ end
262
+
263
+ posting
264
+ else
265
+ # We unroll some of the allocation in here, since (I think) the logic
266
+ # relating to cash backs and such are in 'the bank' and not 'the transaction'
267
+ residual_commodity = posting.commodity
268
+
269
+ if cash_back&.match(posting.description)
270
+ cash_back_commodity = RVGP::Journal::Commodity.from_symbol_and_amount(
271
+ ::Regexp.last_match(1), Regexp.last_match(2)
272
+ )
273
+ residual_commodity -= cash_back_commodity
274
+ posting.targets << { to: cash_back_to, commodity: cash_back_commodity }
275
+ end
276
+
277
+ to_target = { to: to, commodity: residual_commodity }
278
+
279
+ to_target[:tags] = [rule[:to_tag]] if rule[:to_tag]
280
+ posting.targets << to_target
281
+
282
+ posting
283
+ end
284
+ end
285
+
286
+ # @!visibility private
287
+ def postings
288
+ @postings ||= (reverse_order ? source_postings.reverse! : source_postings).map do |source_posting|
289
+ # See what rule applies to this posting:
290
+ rule = match_rule source_posting.commodity.positive? ? expense_rules : income_rules, source_posting
291
+
292
+ # Reconcile the posting, according to that rule:
293
+ Array(reconcile_posting(rule, source_posting)).flatten.compact.map do |posting|
294
+ tag_accounts&.each do |tag_rule|
295
+ # Note that we're operating under a kind of target model here, where
296
+ # the posting itself isnt tagged, but the targets of the posting are.
297
+ # This is a bit different than the reconcile_posting
298
+ posting.targets.each do |target|
299
+ # NOTE: This section should possibly DRY up with the
300
+ # reconcile_posting() method
301
+ next if yaml_rule_matches_string(tag_rule[:account_is_not], target[:to]) ||
302
+ yaml_rule_matches_string(tag_rule[:from_is_not], posting.from) ||
303
+ yaml_rule_matches_string(tag_rule[:account], target[:to], :!=) ||
304
+ yaml_rule_matches_string(tag_rule[:from], posting.from, :!=)
305
+
306
+ target[:tags] ||= []
307
+ target[:tags] << tag_rule[:tag]
308
+ end
309
+ end
310
+
311
+ # And now we can convert it to the journal posting format
312
+ journal_posting = posting.to_journal_posting
313
+
314
+ # NOTE: Might want to return a row number here if it ever triggers:
315
+ raise format('Invalid Transaction found %s', journal_posting.inspect) unless journal_posting.valid?
316
+
317
+ # Cull only the transactions after the specified date:
318
+ next if starts_on && journal_posting.date < starts_on
319
+
320
+ journal_posting
321
+ end
322
+ end.flatten.compact
323
+ end
324
+
325
+ # @!visibility private
326
+ def match_rule(rules, posting)
327
+ rules.each_with_index do |rule, i|
328
+ captures = nil
329
+
330
+ if rule.key? :match
331
+ isnt_matching, captures = *yaml_rule_matches_string_with_capture(rule[:match], posting.description, :!=)
332
+ next if isnt_matching
333
+ end
334
+
335
+ if rule.key? :account
336
+ # :account was added when we added journal_reconcile
337
+ isnt_matching, captures = *yaml_rule_matches_string_with_capture(rule[:account], posting.to, :!=)
338
+ next if isnt_matching
339
+ end
340
+
341
+ next if yaml_rule_asserts_commodity(rule[:amount_less_than], posting.commodity, :>=) ||
342
+ yaml_rule_asserts_commodity(rule[:amount_greater_than], posting.commodity, :<=) ||
343
+ yaml_rule_asserts_commodity(rule[:amount_equals], posting.commodity, :!=) ||
344
+ yaml_rule_matches_date(rule[:on_date], posting.date, :!=) ||
345
+ (rule.key?(:before_date) && posting.date >= rule[:before_date]) ||
346
+ (rule.key?(:after_date) && posting.date < rule[:after_date])
347
+
348
+ # Success, there was a match:
349
+ return rule.merge(index: i, captures: captures)
350
+ end
351
+
352
+ nil
353
+ end
354
+
355
+ # Builds the contents of this reconcilere's output file, and returns it. This is the finished
356
+ # product of this class
357
+ # @return [String] a PTA journal, composed of the input_file's transactions, after all rules are applied.
358
+ def to_ledger
359
+ [HEADER % label, postings.map(&:to_ledger), ''].flatten.join("\n\n")
360
+ end
361
+
362
+ # Writes the contents of #to_ledger, to the :output_file specified in the reconciler yaml.
363
+ # @return [void]
364
+ def to_ledger!
365
+ File.write output_file, to_ledger
366
+ end
367
+
368
+ # Returns an array of all of the reconcilers found in the specified path.
369
+ # @param [String] directory_path The path containing your yml reconciler files
370
+ # @return [Array<RVGP::Reconcilers::CsvReconciler, RVGP::Reconcilers::JournalReconciler>]
371
+ # An array of parsed reconcilers.
372
+ def self.all(directory_path)
373
+ # NOTE: I'm not crazy about this method. Probably we should have
374
+ # implemented a single Reconciler class, with CSV/Journal drivers.
375
+ # Nonetheless, this code works for now. Maybe if we add another
376
+ # driver, we can renovate it, and add some kind of registry for drivers.
377
+
378
+ Dir.glob(format('%s/app/reconcilers/*.yml', directory_path)).map do |path|
379
+ yaml = RVGP::Utilities::Yaml.new path, RVGP.app.config.project_path
380
+
381
+ raise MissingFields.new, :input unless yaml.key? :input
382
+
383
+ # We could probably make this a registry, though, I'd like to support
384
+ # web addresses eventually. So, probably this designe pattern would
385
+ # have to just be reconsidered entirely around that time.
386
+ case File.extname(yaml[:input])
387
+ when '.csv' then RVGP::Reconcilers::CsvReconciler.new(yaml)
388
+ when '.journal' then RVGP::Reconcilers::JournalReconciler.new(yaml)
389
+ else
390
+ raise StandardError, format('Unrecognized file extension for input file "%s"', yaml[:input])
391
+ end
392
+ end
393
+ end
394
+
395
+ private
396
+
397
+ def yaml_rule_matches_string(*args)
398
+ yaml_rule_matches_string_with_capture(*args).first
399
+ end
400
+
401
+ def yaml_rule_matches_date(*args)
402
+ yaml_rule_matches_string_with_capture(*args) { |date| date.strftime('%Y-%m-%d') }.first
403
+ end
404
+
405
+ def yaml_rule_matches_string_with_capture(rule_value, target_value, operation = :==, &block)
406
+ return [false, nil] unless rule_value
407
+
408
+ if (matcher = string_to_regex(rule_value.to_s))
409
+ target_value_as_s = block_given? ? block.call(target_value) : target_value.to_s
410
+
411
+ matches = matcher.match target_value_as_s
412
+
413
+ [(operation == :== && matches) || (operation == :!= && matches.nil?),
414
+ matches && matches.length > 1 ? matches.named_captures.dup : nil]
415
+ else
416
+ [rule_value.send(operation, target_value), nil]
417
+ end
418
+ end
419
+
420
+ # NOTE: We compare a little unintuitively, wrt to testing the code, before testing the equivalence.
421
+ # this is because we may be comparing a Commodity to a ComplexCommodity. And, in that case, we can
422
+ # offer some asserting based on the code, by doing so.
423
+ def yaml_rule_asserts_commodity(rule_value, target_value, operation = :==)
424
+ return false unless rule_value
425
+
426
+ rule_commodity = rule_value.to_s.to_commodity
427
+ target_commodity = target_value.to_s.to_commodity
428
+
429
+ target_commodity.alphabetic_code != rule_commodity.alphabetic_code ||
430
+ target_commodity.abs.send(operation, rule_commodity)
431
+ end
432
+ end
433
+ end
434
+ end