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