rvgp 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- 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
|