rvgp 0.3.2

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.
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