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,424 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
require_relative '../journal'
|
5
|
+
|
6
|
+
module RVGP
|
7
|
+
# Reconcilers are a cornerstone of the RVGP build, and an integral part of your project. Reconcilers, take an input
|
8
|
+
# file (Either a csv file or a journal file), and reconcile them into a reconciled pta journal. This class
|
9
|
+
# implements most of the functionality needed to make that happen.
|
10
|
+
#
|
11
|
+
# Reconcilers take two files as input. Firstly, it takes an aforementioned input file. But, secondly, it takes a
|
12
|
+
# yaml file with reconciliation directives. What follows is a guide on those directives.
|
13
|
+
#
|
14
|
+
# Most of your time spent in these files, will be spent adding rules to the income and expense sections (see
|
15
|
+
# the 'Defining income and expense sections'). However, in order to get the reconciler far enough into the parsing
|
16
|
+
# logic to get to that section, you'll need to understand the general structure of these files.
|
17
|
+
#
|
18
|
+
# ## The General Structure of Reconciler Yaml's
|
19
|
+
# Reconciler yaml files are expected to be found in the app/reconciler directory. Typically with a four-digit year
|
20
|
+
# as the start of its filename, and a yml extension. Here's a simple example reconciler directory:
|
21
|
+
# ```
|
22
|
+
# ~/ledger> lsd -1 app/reconcilers/
|
23
|
+
# 2022-business-checking.yml
|
24
|
+
# 2023-business-checking.yml
|
25
|
+
# 2022-personal-amex.yml
|
26
|
+
# 2023-personal-amex.yml
|
27
|
+
# 2022-personal-checking.yml
|
28
|
+
# 2023-personal-checking.yml
|
29
|
+
# 2022-personal-saving.yml
|
30
|
+
# 2023-personal-saving.yml
|
31
|
+
# ```
|
32
|
+
# In this example directory, we can see eight reconcilers defined, on each of the years 2022 and 2023, for each of
|
33
|
+
# the accounts: business-checking, personal-checking, personal-saving, and personal-amex. Each of these files will
|
34
|
+
# reference a separate input. Each of this files will produce a journal, with a corresponding name in
|
35
|
+
# build/journals.
|
36
|
+
#
|
37
|
+
# All reconciler files, are required to have a :label, :output, :input, :from, :income, and :expense key defined.
|
38
|
+
# Here's an example of a reconciler, with all of these sections present. Let's take a look at the
|
39
|
+
# '2023-personal-checking.yml' reconciler, from above:
|
40
|
+
# ```
|
41
|
+
# from: "Personal:Assets:AcmeBank:Checking"
|
42
|
+
# label: "Personal AcmeBank:Checking (2023)"
|
43
|
+
# input: 2023-personal-basic-checking.csv
|
44
|
+
# output: 2023-personal-basic-checking.journal
|
45
|
+
# format:
|
46
|
+
# csv_headers: true
|
47
|
+
# fields:
|
48
|
+
# date: !!proc Date.strptime(row['Date'], '%m/%d/%Y')
|
49
|
+
# amount: !!proc row['Amount']
|
50
|
+
# description: !!proc row['Description']
|
51
|
+
# income:
|
52
|
+
# - match: /.*/
|
53
|
+
# to: Personal:Income:Unknown
|
54
|
+
# expense:
|
55
|
+
# - match: /.*/
|
56
|
+
# to: Personal:Expenses:Unknown
|
57
|
+
# ```
|
58
|
+
#
|
59
|
+
# This file has a number of fairly obvious fields, and some not-so obvious fields. Let's take a look at these fields
|
60
|
+
# one by one:
|
61
|
+
#
|
62
|
+
# - **from** [String] - This is the pta account, that the reconciler will ascribe as it's default source of funds.
|
63
|
+
# - **label** [String] - This a label for the reconciler, that is mostly just used for status output on the cli.
|
64
|
+
# - **input** [String] - The filename/path to the input to this file. Typically this is a csv file, located in the
|
65
|
+
# project's 'feeds' directory.
|
66
|
+
# - **output** [String] - The filename to output in the project's 'build/journals' directory.
|
67
|
+
# - **starts_on** [String] - A cut-off date, before which, transactions in the input file are ignored. Date is
|
68
|
+
# expected to be provided in YYYY-MM-DD format.
|
69
|
+
# - **format** [Hash] - This section defines the logic used to decode a csv into fields. Typically, this section is
|
70
|
+
# shared between multiple reconcilers by way of an 'include' directive, to a file in your
|
71
|
+
# config/ directory. More on this below.<br><br>
|
72
|
+
# Note the use of the !!proc directive. These values are explained in the 'Special yaml features'
|
73
|
+
# section.
|
74
|
+
# - **income** [Array<Hash>] - This collection matches one or more income entries in the input file, and reconciles
|
75
|
+
# them to an output entry.
|
76
|
+
# - **expense** [Array<Hash>] - This collection matches one or more expense entries in the input file, and reconciles
|
77
|
+
# them to an output entry.
|
78
|
+
#
|
79
|
+
# Income and expenses are nearly identical in their rules and features, and are further explained in the 'Defining
|
80
|
+
# income and expense sections' below.
|
81
|
+
#
|
82
|
+
# In addition to these basic parameters, the following parameters are also supported in the root of your reconciler
|
83
|
+
# file:
|
84
|
+
# - **transform_commodities** [Hash] - This directive can be used to convert commodities in the format specified by
|
85
|
+
# its keys, to the commodity specified in its values. For example, the following will ensure that all USD values
|
86
|
+
# encountered in the input file, are transcribed as '$' in the output files:
|
87
|
+
#
|
88
|
+
# ...
|
89
|
+
# transform_commodities:
|
90
|
+
# USD: '$'
|
91
|
+
# ...
|
92
|
+
#
|
93
|
+
# - **balances** [Hash] - This feature raises an error, if the balance of the :from account on a given date(key)
|
94
|
+
# doesn't match the provided value. Here's an example of what this looks like in a reconciler:
|
95
|
+
#
|
96
|
+
# ...
|
97
|
+
# balances:
|
98
|
+
# '2023-01-15': $ 2345.67
|
99
|
+
# '2023-06-15': $ 3456,78
|
100
|
+
# ...
|
101
|
+
# This feature is mostly implemented by the {RVGP::Validations::BalanceValidation}, and is provided as a
|
102
|
+
# fail-safe, in which you can input the balance reported by the statements from your financial institution,
|
103
|
+
# and ensure your build is consistent with the expectation of that institution.
|
104
|
+
# - **disable_checks** [Array<String>] - This declaration can be used to disable one or more of your journal
|
105
|
+
# validations. This is described in greater depth in the {RVGP::Base::Validation} documentation. Here's a sample
|
106
|
+
# of this feature, which can be used to disable the balances section that was explained above:
|
107
|
+
#
|
108
|
+
# ...
|
109
|
+
# disable_checks:
|
110
|
+
# - balance
|
111
|
+
# ...
|
112
|
+
# - **tag_accounts** [Array<Hash>] - This feature is preliminary, and subject to change. The gist of this feature, is
|
113
|
+
# that it offers a second pass, after the income/expense rules have applied. This pass enables additional tags to
|
114
|
+
# be applied to a posting, based on how that posting was reconciled in the first pass. I'm not sure I like how
|
115
|
+
# this feature came out, so, I'm disinclined to document it for now. If there's an interest in this feature, I can
|
116
|
+
# stabilize it's support, and better document it.
|
117
|
+
#
|
118
|
+
# ## Understanding 'format' parameters
|
119
|
+
# The format section applies rules to the parsing of the input file. Some of these parameters are
|
120
|
+
# specific to the format of the input file. These rules are typically specific to a financial instution's specific
|
121
|
+
# output formatting. And are typically shared between multiple reconciler files in the form of an
|
122
|
+
# !!include directive (see below).
|
123
|
+
#
|
124
|
+
# ### CSV specific format parameters
|
125
|
+
# The parameters are specific to .csv input files.
|
126
|
+
# - **fields** [Hash<String, Proc>] - This field is required for csv's. This hash contains a map of field names, to
|
127
|
+
# !!proc's. The supported (required) field keys are: date, amount, and description. The values for each of these
|
128
|
+
# keys is evaluated (in ruby), and provided a single parameter, 'row' which contains a row as returned from ruby's
|
129
|
+
# CSV.parse method. The example project, supplied by the new_project command, contains an easy implementation
|
130
|
+
# of this feature in action.
|
131
|
+
# - **invert_amount** [bool] (default: false) - Whether to call the {RVGP::Journal::Commodity#invert!} on every
|
132
|
+
# amount that's encountered in the input file
|
133
|
+
# - **encoding** [String] - This parameter is passed to the :encoding parameter of File.read, during the parsing of
|
134
|
+
# the supplied input file. This can be used to prevent CSV::MalformedCSVError in cases such as a bom encoded
|
135
|
+
# input file.
|
136
|
+
# - **csv_headers** [bool] (default: false) - Whether or not the first row of the input file, contains column headers
|
137
|
+
# for the rows that follow.
|
138
|
+
# - **skip_lines** [Integer, String] - This option will direct the reconciler to skip over lines at the beginning of
|
139
|
+
# the input file. This can be specified either as a number, which indicates the number of lines to ignore. Or,
|
140
|
+
# alternatively, this can be specified as a RegExp (provided in the form of a yaml string). In which case, the
|
141
|
+
# reconciler will begin to parse one character after the end of the regular expression match.
|
142
|
+
# - **trim_lines** [Integer, String] - This option will direct the reconciler to skip over lines at the end of
|
143
|
+
# the input file. This can be specified either as a number, which indicates the number of lines to trim. Or,
|
144
|
+
# alternatively, this can be specified as a RegExp (provided in the form of a yaml string). In which case, the
|
145
|
+
# reconciler will trim the file up to one character to the left of the regular expression match.
|
146
|
+
#
|
147
|
+
# ### CSV and Journal file format parameters
|
148
|
+
# These parameters are available to both .journal as well as .csv files.
|
149
|
+
# - **default_currency** [String] (default: '$') - A currency to default amount's to, if a currency isn't specified
|
150
|
+
# - **reverse_order** [bool] (default: false) - Whether to output transactions in the opposite order of how they were
|
151
|
+
# encoded in the input file.
|
152
|
+
# - **cash_back** [Hash] - This feature enables you to match transaction descriptions for a cash back indication and
|
153
|
+
# amount, and to break that component of the charge into a separate account. The valid keys in this hash are
|
154
|
+
# :match and :to . The matched captures of the regex are assumed to be symbol (1) and amount (2), which are used
|
155
|
+
# to construct a commodity that's assigned to the :to value. Here's an easy exmple
|
156
|
+
#
|
157
|
+
# ...
|
158
|
+
# cash_back:
|
159
|
+
# match: '/\(CASH BACK: ([^ ]) ([\d]+\.[\d]{2})\)\Z/'
|
160
|
+
# to: Personal:Assets:Cash
|
161
|
+
# ...
|
162
|
+
#
|
163
|
+
# ## Defining income and expense sections
|
164
|
+
# This is where you'll spend most of your time reconciling. Once the basic csv structure is parsing, these sections
|
165
|
+
# are how you'll match entries in your input file, and turn them into reconciled output entries. The income_rules
|
166
|
+
# and expense_rules are governed by the same logic. Let's breakout some of their rules, that you should understand:
|
167
|
+
# - The *_rules section of the yaml is a array of hashes
|
168
|
+
# - These hashes contain 'match' directives, and 'assignment' directives
|
169
|
+
# - All transactions in the input file, are sent to either income_rules, or expense_rules, depending on whether
|
170
|
+
# their amount is a credit(income), or a debit(expense).
|
171
|
+
# - Each transaction in the input file is sent down the chain of rules (either income or expense) from the top of
|
172
|
+
# the list, towards the bottom - until a matching rule is found. At that time, traversal will stop. And, all
|
173
|
+
# directives in this rule will apply to the input transaction.
|
174
|
+
# - If you've ever managed a firewall, this matching and directive process works very similarly to how packets are
|
175
|
+
# managed by a firewall ruleset.
|
176
|
+
# - If no matches were found, an error is raised. Typically, you'll want a catch-all at the end of the chain, like
|
177
|
+
# so:
|
178
|
+
#
|
179
|
+
# ...
|
180
|
+
# - match: /.*/
|
181
|
+
# to: Personal:Expenses:Unknown
|
182
|
+
#
|
183
|
+
# For every hash in an array of income and expense rules, you can specify one or more of the following yaml
|
184
|
+
# directives. Note that these directives all serve to provide two function: matching input transactions, indicating
|
185
|
+
# how to reconcile any matches it captures.
|
186
|
+
#
|
187
|
+
# ### Income & Expense Rules: Matching
|
188
|
+
# The following directives are matching rules. If more than one of these directives are encountered in a rule,
|
189
|
+
# they're and'd together. Meaning: all of the conditions that are listed, need to apply to subject, in order for a
|
190
|
+
# match to execute.
|
191
|
+
# - **match** [Regexp,String] - If a string is provided, compares the :description of the feed transaction against the
|
192
|
+
# value provided, and matches if they're equal. If a regex is provided, matches the
|
193
|
+
# :description of the feed transaction against the regex provided.
|
194
|
+
# If a regex is provided, captures are supported. (see the note below)
|
195
|
+
# - **account** [Regexp,String] - This matcher is useful for reconcilers that support the :to field.
|
196
|
+
# (see {RVGP::Reconcilers::JournalReconciler}). If a string is provided, compares the
|
197
|
+
# account :to which a transaction was assigned, to the value provided. And matches if
|
198
|
+
# they're equal. If a regex is provided, matches the account :to which a transaction
|
199
|
+
# was assigned, against the regex provided.
|
200
|
+
# If a regex is provided, captures are supported. (see the note below)
|
201
|
+
# - **account_is_not** [String] - This matcher is useful for reconcilers that support the :to field.
|
202
|
+
# (see {RVGP::Reconcilers::JournalReconciler}). This field matches any transaction
|
203
|
+
# whose account :to, does not equal the provided string.
|
204
|
+
# - **amount_less_than** [Commodity] - This field compares it's value to the transaction :amount , and matches if
|
205
|
+
# that amount is less than the provided amount.
|
206
|
+
# - **amount_greater_than** [Commodity] - This field compares it's value to the transaction :amount , and matches
|
207
|
+
# if that amount is greater than the provided amount.
|
208
|
+
# - **amount_equals** [Commodity] - This field compares it's value to the transaction :amount , and matches if
|
209
|
+
# that amount is equal to the provided amount.
|
210
|
+
# - **on_date** [Regexp,Date] - If a date is provided, compares the :date of the feed transaction against the value
|
211
|
+
# provided, and matches if they're equal. If a regex is provided, matches the
|
212
|
+
# :date of the feed, converted to a string in the format 'YYYY-MM-DD', against the regex
|
213
|
+
# provided.
|
214
|
+
# If a regex is provided, captures are supported. (see the note below)
|
215
|
+
# - **before_date** [Date] - This field compares it's value to the feed transaction :date, and matches if the feed's
|
216
|
+
# :date occurred before the provided :date.
|
217
|
+
# - **after_date** [Date] - This field compares it's value to the feed transaction :date, and matches if the feed's
|
218
|
+
# :date occurred after the provided :date.
|
219
|
+
# - **from_is_not** [String] - This field matches any transaction whose account :from, does not equal the provided
|
220
|
+
# string.
|
221
|
+
#
|
222
|
+
# **NOTE** Some matchers which support captures: This is a powerful feature that allows regexp captured values, to
|
223
|
+
# substitute in the :to field of the reconciled transaction. Here's an example of how this feature works:
|
224
|
+
#
|
225
|
+
# - match: '/^Reservation\: (?<unit>[^ ]+)/'
|
226
|
+
# to: AirBNB:Income:$unit
|
227
|
+
# In this example, the text that existed in the "(?<unit>[^ ]+)" section of the 'match' field, is substituted in
|
228
|
+
# place of "$unit" in the output journal.
|
229
|
+
#
|
230
|
+
# ### Income & Expense Rules: Reconciliation
|
231
|
+
# The following directives are reconciliation rules. These rules have nothing to do with matching, and instead
|
232
|
+
# apply to the outputted transaction for the rule in which they're declared. If more than one of these rules are
|
233
|
+
# present - they all apply.
|
234
|
+
# - **to** [String] - This field will provide the :to account to reconcile an input transaction against. Be aware
|
235
|
+
# aware of the above note on captures, as this field supports capture variable substitution.
|
236
|
+
# - **from** [String] - This field can be used to change the reconciled :from account, to a different account than
|
237
|
+
# the default :from, that was specified in the root of the reconciler yaml.
|
238
|
+
# - **tag** [String] - Tag(s) to apply to the reconciled posting.
|
239
|
+
# - **to_tag** [String] - Tag(s) to apply to the :to transfer, the first transfer, in the posting
|
240
|
+
# - **targets** [Array<Hash>] - For some transactions, multiple transfers need to expand from a single input
|
241
|
+
# transaction. In those cases, :targets is the reconciliation rule you'll want to use.
|
242
|
+
# This field is expected to be an array of Hashes. With, each hash supporting the
|
243
|
+
# following fields:
|
244
|
+
# - **to** [String] - As with the above :to, this field will provide the account to reconcile the transfer to.
|
245
|
+
# - **amount** [Commodity] - The amount to ascribe to this transfer. While the sum of the targets 'should' equal the
|
246
|
+
# input transaction amount, there is no validation performed by RVGP to do so. So, excercize discretion when
|
247
|
+
# manually breaking out input transactions into multiple transfers.
|
248
|
+
# - **complex_commodity** [ComplexCommodity] - A complex commodity to ascribe to this transfer, instead of an
|
249
|
+
# :amount. See {RVGP::Journal::ComplexCommodity.from_s} for more details on this feature.
|
250
|
+
# - **tags** [Array<String>] - An array of tags to assign to this transfer. See {RVGP::Journal::Posting::Tag.from_s}
|
251
|
+
# for more details on tag formatting.
|
252
|
+
# - **to_shorthand** [String] - Reconciler Shorthand is a powerful feature that can reduce duplication, and manual
|
253
|
+
# calculations from your reconciler yaml. The value provided here, must correlate with an available reconciler
|
254
|
+
# shorthand, and if so, sends this rule to that shorthand for reconciliation. See the below section for further
|
255
|
+
# details on this feature.
|
256
|
+
# - **shorthand_params** [Hash] - This section is specific to the reconciler shorthand that was specified in the
|
257
|
+
# :to_shorthand field. Any of the key/value pairs specified here, are sent to the reconciler shorthand, along
|
258
|
+
# with the rest of the input transaction. And, presumably, these fields will futher direct the reconciliation
|
259
|
+
# of the input transaction.
|
260
|
+
#
|
261
|
+
# ## Shorthand
|
262
|
+
# Additional time-saving syntax is available in the form of 'Shorthand'. This feature is reliable, though,
|
263
|
+
# experimental. The point of 'Shorthand' is to provide ruby modules that takes a matched transaction, and
|
264
|
+
# automatically expands this transaction in the form of a ruby-defined macro. Currently, there are a handful
|
265
|
+
# of such shorthand macros shipped with RVGP. If there's an interest, user-defined shorthand can be supported
|
266
|
+
# in the future. The following shorthand classes, are currently provided in RVGP:
|
267
|
+
# - {RVGP::Reconcilers::Shorthand::InternationalAtm} - This Shorthand is useful for unrolling a complex International
|
268
|
+
# ATM withdrawal. This shorthand will automatically calculate and allocate fees around the amounnt withdrawn.
|
269
|
+
# - {RVGP::Reconcilers::Shorthand::Investment} - Allocate capital gains or losses, given a symbol, amount, and price.
|
270
|
+
# - {RVGP::Reconcilers::Shorthand::Mortgage} - This shorthand will automatically allocate the the escrow, principal,
|
271
|
+
# and interest components of a mortage payment, into constituent accounts.
|
272
|
+
# See the documentation in each of these classes, for details on what **:shorthand_params** each of these modules
|
273
|
+
# supports.
|
274
|
+
#
|
275
|
+
# ## Special yaml features
|
276
|
+
# All of these pysch extensions, are prefixed with two exclamation points, and can be placed in lieu of a value, for
|
277
|
+
# some of the fields outlined above.
|
278
|
+
# - <b>!!include</b> [String] - Include another yaml file, in place of this directive. The file is expected to be
|
279
|
+
# provided, immediately followed by this declaration (separated by a space). It's common to see this directive
|
280
|
+
# used as a shortcut to shared :format sections. But, these can be used almost anywhere. Here's an example:
|
281
|
+
#
|
282
|
+
# ...
|
283
|
+
# from: "Personal:Assets:AcmeBank:Checking"
|
284
|
+
# label: "Personal AcmeBank:Checking (2023)"
|
285
|
+
# format: !!include config/csv-format-acmebank.yml
|
286
|
+
# ...
|
287
|
+
# - <b>!!proc</b> [String] - Convert the contents of the text following this directive, into a Proc object. It'
|
288
|
+
# common to see this directive used in the format section of a reconciler yaml. Here's an example:
|
289
|
+
#
|
290
|
+
# ...
|
291
|
+
# fields:
|
292
|
+
# date: !!proc >
|
293
|
+
# date = Date.strptime(row[0], '%m/%d/%Y');
|
294
|
+
# date - 1
|
295
|
+
# amount: !!proc row[1]
|
296
|
+
# description: !!proc row[2]
|
297
|
+
# ...
|
298
|
+
# Note that the use of '>' is a yaml feature, that allows multiline strings to compose by way of an indent in
|
299
|
+
# the lines that follow. For one-line '!!proc' declarations, this character is not needed. Additionally, this
|
300
|
+
# means that in most cases, carriage returns are not parsed. As such, you'll want to terminate lines in these
|
301
|
+
# segments, with a semicolon, to achieve the same end.
|
302
|
+
#
|
303
|
+
# ## Available Implementations
|
304
|
+
# Currently, the following reconciller implementations are available. These implementations support all of the
|
305
|
+
# above features, and, may implement additional features.
|
306
|
+
# - {RVGP::Reconcilers::CsvReconciler} - this reconciler handles input files of type csv
|
307
|
+
# - {RVGP::Reconcilers::JournalReconciler} - this reconciler handles input files of type .journal (Pta accounting
|
308
|
+
# files)
|
309
|
+
module Reconcilers
|
310
|
+
# This reconciler is instantiated for input files of type csv. Additional parameters are supported in the
|
311
|
+
# :format section of this reconciler, which are documented in {RVGP::Reconcilers} under the
|
312
|
+
# 'CSV specific format parameters' section.
|
313
|
+
# @attr_reader [Hash<String, <Proc,String,Integer>>] fields_format A hash of field names, to their location in
|
314
|
+
# the input file. Supported key names include: date, amount, description. These keys can map to either a
|
315
|
+
# 'string' type (indicating which column of the input file contains the key's value). An Integer (indicating
|
316
|
+
# which column offset contains the key's value). Or, a Proc (which executes for every row in the input file,
|
317
|
+
# and whose return value will be used)
|
318
|
+
# @attr_reader [Hash] csv_format This hash is sent to the options parameter of CSV.parse
|
319
|
+
# @attr_reader [Boolean] invert_amount Whether or not to multiple the :amount field by negative one.
|
320
|
+
# @attr_reader [<Regexp, Integer>] skip_lines Given a regex, the input file will discard the match for the
|
321
|
+
# provided regex from the start of the input file. Given an integer, the provided number of lines will be
|
322
|
+
# removed from the start of the input file.
|
323
|
+
# @attr_reader [<Regexp, Integer>] trim_lines Given a regex, the input file will discard the match for the
|
324
|
+
# provided regex from the end of the input file. Given an integer, the provided number of lines will be
|
325
|
+
# removed from the end of the input file.
|
326
|
+
class CsvReconciler < RVGP::Base::Reconciler
|
327
|
+
attr_reader :fields_format, :csv_format, :invert_amount, :skip_lines, :trim_lines
|
328
|
+
|
329
|
+
def initialize(yaml)
|
330
|
+
super yaml
|
331
|
+
|
332
|
+
missing_fields = if yaml.key? :format
|
333
|
+
if yaml[:format].key?(:fields)
|
334
|
+
%i[date amount description].map do |attr|
|
335
|
+
format('format/fields/%s', attr) unless yaml[:format][:fields].key?(attr)
|
336
|
+
end.compact
|
337
|
+
else
|
338
|
+
['format/fields']
|
339
|
+
end
|
340
|
+
else
|
341
|
+
['format']
|
342
|
+
end
|
343
|
+
|
344
|
+
raise MissingFields.new(*missing_fields) unless missing_fields.empty?
|
345
|
+
|
346
|
+
@fields_format = yaml[:format][:fields] if yaml[:format].key? :fields
|
347
|
+
@encoding_format = yaml[:format][:encoding] if yaml[:format].key? :encoding
|
348
|
+
@invert_amount = yaml[:format][:invert_amount] || false if yaml[:format].key? :invert_amount
|
349
|
+
@skip_lines = yaml[:format][:skip_lines]
|
350
|
+
@trim_lines = yaml[:format][:trim_lines]
|
351
|
+
@csv_format = { headers: yaml[:format][:csv_headers] } if yaml[:format].key? :csv_headers
|
352
|
+
end
|
353
|
+
|
354
|
+
class << self
|
355
|
+
include RVGP::Utilities
|
356
|
+
|
357
|
+
# Mostly this is a class mathed, to make testing easier
|
358
|
+
def input_file_contents(contents, skip_lines = nil, trim_lines = nil)
|
359
|
+
start_offset = 0
|
360
|
+
end_offset = contents.length
|
361
|
+
|
362
|
+
if trim_lines
|
363
|
+
trim_lines_regex = string_to_regex trim_lines.to_s
|
364
|
+
trim_lines_regex ||= /(?:[^\n]*\n?){0,#{trim_lines}}\Z/m
|
365
|
+
match = trim_lines_regex.match contents
|
366
|
+
end_offset = match.begin 0 if match
|
367
|
+
return String.new if end_offset.zero?
|
368
|
+
end
|
369
|
+
|
370
|
+
if skip_lines
|
371
|
+
skip_lines_regex = string_to_regex skip_lines.to_s
|
372
|
+
skip_lines_regex ||= /(?:[^\n]*\n){0,#{skip_lines}}/m
|
373
|
+
match = skip_lines_regex.match contents
|
374
|
+
start_offset = match.end 0 if match
|
375
|
+
end
|
376
|
+
|
377
|
+
# If our cursors overlapped, that means we're just returning an empty string
|
378
|
+
return String.new if end_offset < start_offset
|
379
|
+
|
380
|
+
contents[start_offset..(end_offset - 1)]
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
private
|
385
|
+
|
386
|
+
def input_file_contents
|
387
|
+
open_args = {}
|
388
|
+
open_args[:encoding] = @encoding_format if @encoding_format
|
389
|
+
self.class.input_file_contents File.read(input_file, **open_args), skip_lines, trim_lines
|
390
|
+
end
|
391
|
+
|
392
|
+
# We actually returned semi-reconciled transactions here. That lets us do
|
393
|
+
# some remedial parsing before rule application, as well as reversing the order
|
394
|
+
# which, is needed for the to_shorthand to run in sequence.
|
395
|
+
def source_postings
|
396
|
+
@source_postings ||= begin
|
397
|
+
rows = CSV.parse input_file_contents, **csv_format
|
398
|
+
rows.collect.with_index do |csv_row, i|
|
399
|
+
# Set the object values, return the reconciled row:
|
400
|
+
tx = fields_format.collect do |field, formatter|
|
401
|
+
# TODO: I think we can stick formatter as a key, if it's a string, or int
|
402
|
+
[field.to_sym, formatter.respond_to?(:call) ? formatter.call(row: csv_row) : csv_row[field]]
|
403
|
+
end.compact.to_h
|
404
|
+
|
405
|
+
# Amount is a special case, which, we have now converted into
|
406
|
+
# commodity
|
407
|
+
if [RVGP::Journal::ComplexCommodity, RVGP::Journal::Commodity].any? { |klass| tx[:amount].is_a? klass }
|
408
|
+
commodity = tx[:amount]
|
409
|
+
end
|
410
|
+
commodity ||= RVGP::Journal::Commodity.from_symbol_and_amount(default_currency, tx[:amount])
|
411
|
+
|
412
|
+
commodity.invert! if invert_amount
|
413
|
+
|
414
|
+
RVGP::Base::Reconciler::Posting.new i + 1,
|
415
|
+
date: tx[:date],
|
416
|
+
description: tx[:description],
|
417
|
+
commodity: transform_commodity(commodity),
|
418
|
+
from: from
|
419
|
+
end
|
420
|
+
end
|
421
|
+
end
|
422
|
+
end
|
423
|
+
end
|
424
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../journal'
|
4
|
+
|
5
|
+
module RVGP
|
6
|
+
module Reconcilers
|
7
|
+
# This reconciler is instantiated for input files of type journal (Plain Text Accounting text files).
|
8
|
+
# There are no parameters to this Reconciler, that aren't already documented in {RVGP::Reconcilers}
|
9
|
+
class JournalReconciler < RVGP::Base::Reconciler
|
10
|
+
private
|
11
|
+
|
12
|
+
def journal
|
13
|
+
RVGP::Journal.parse File.read(input_file)
|
14
|
+
end
|
15
|
+
|
16
|
+
def source_postings
|
17
|
+
@source_postings ||= journal.postings.map do |posting|
|
18
|
+
unless posting.transfers.first.commodity && posting.transfers.last.commodity.nil?
|
19
|
+
raise StandardError, format('Unimplemented posting on: %<file>s:%<line_no>d',
|
20
|
+
file: input_file, line_no: posting.line_number)
|
21
|
+
end
|
22
|
+
|
23
|
+
# For Journal:Posting's with multiple account transfer lines, we break it into
|
24
|
+
# multiple RVGP::Base::Reconciler::Posting postings.
|
25
|
+
posting.transfers[0...-1].map do |transfer|
|
26
|
+
# NOTE: The tags.dup appears to be needed, because otherwise the
|
27
|
+
# tags array ends up shared between the two entries, and
|
28
|
+
# operations on one, appear in the other's contents
|
29
|
+
RVGP::Base::Reconciler::Posting.new posting.line_number,
|
30
|
+
date: posting.date,
|
31
|
+
tags: posting.tags.dup,
|
32
|
+
from: from,
|
33
|
+
description: posting.description,
|
34
|
+
commodity: transform_commodity(transfer.commodity),
|
35
|
+
to: transfer.account
|
36
|
+
end
|
37
|
+
end.flatten
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Finance
|
4
|
+
# The default functionality in this class, specified in the finance gem, is
|
5
|
+
# overwritten, to support the additional_payments feature of RVGP::Reconcilers::Shorthand::Mortgage
|
6
|
+
class Amortization
|
7
|
+
def balance=(val)
|
8
|
+
@balance = DecNum.new val
|
9
|
+
end
|
10
|
+
|
11
|
+
# This was copied out of :
|
12
|
+
# https://github.com/marksweston/finance/blob/master/lib/finance/amortization.rb
|
13
|
+
# Because bank of america doesn't round the same way...
|
14
|
+
def amortize(rate)
|
15
|
+
# For the purposes of calculating a payment, the relevant time
|
16
|
+
# period is the remaining number of periods in the loan, not
|
17
|
+
# necessarily the duration of the rate itself.
|
18
|
+
periods = @periods - @period
|
19
|
+
amount = Finance::Amortization.payment @balance, rate.monthly, periods
|
20
|
+
|
21
|
+
pmt = Finance::Payment.new amount, period: @period
|
22
|
+
|
23
|
+
rate.duration.to_i.times do
|
24
|
+
# NOTE: This is the only change I made:
|
25
|
+
# (well, I also removed the pmt based block.call above)
|
26
|
+
@block&.call(@period, self)
|
27
|
+
|
28
|
+
# Do this first in case the balance is zero already.
|
29
|
+
break if @balance.zero?
|
30
|
+
|
31
|
+
# Compute and record interest on the outstanding balance.
|
32
|
+
int = (@balance * rate.monthly).round(2)
|
33
|
+
|
34
|
+
interest = Finance::Interest.new int, period: @period
|
35
|
+
|
36
|
+
@balance += interest.amount
|
37
|
+
@transactions << interest.dup
|
38
|
+
|
39
|
+
# Record payment. Don't pay more than the outstanding balance.
|
40
|
+
pmt.amount = -@balance if pmt.amount.abs > @balance
|
41
|
+
@transactions << pmt.dup
|
42
|
+
@balance += pmt.amount
|
43
|
+
|
44
|
+
@period += 1
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RVGP
|
4
|
+
module Reconcilers
|
5
|
+
module Shorthand
|
6
|
+
# This reconciler module will automatically allocate ATM components of a transaction, to constituent
|
7
|
+
# accounts. This module is useful for tracking the myriad expenses that banks impose on your atm
|
8
|
+
# withdrawals internationally. This module takes the total withdrawal, as reported in the input file
|
9
|
+
# and deducts conversion_markup and operation_costs from that total. It then takes the remainder balance
|
10
|
+
# and constructs a {RVGP::Journal::ComplexCommodity} with the provided :amount as the :left side of that
|
11
|
+
# balance, and the remainder after fees on the right side. This seems to be how all ATM's (that I've
|
12
|
+
# encountered) work. Note that not all atm, use all of the fees listed below. Some will use them all,
|
13
|
+
# some will use a subset.
|
14
|
+
#
|
15
|
+
# The module parameters we support are:
|
16
|
+
# - **amount** [Commodity] - The amount you withdrew on the ATM screen. This is paper amount, that you received.
|
17
|
+
# This amount should be denoted in the commodity you received.
|
18
|
+
# - **operation_cost** [Commodity] - This amount is denominated in the same currency you received in paper, and
|
19
|
+
# is typically listed in a summary screen, and on your printed receipt.
|
20
|
+
# - **conversion_markup** [String] - This is a percentage, expressed as a string. So, "7.5%" would be expected
|
21
|
+
# to be written as "7.5", here. This amount is typically listed on a summary screen, and in your printed
|
22
|
+
# receipt.
|
23
|
+
# - **conversion_markup_to** [String] - The account that :conversion_markup fees should be transferred to
|
24
|
+
# - **operation_cost_to** [String] - The account that :operation_cost fees should be transferred to
|
25
|
+
#
|
26
|
+
# # Example
|
27
|
+
# Here's how this module might be used in your reconciler:
|
28
|
+
# ```
|
29
|
+
# ...
|
30
|
+
# - match: /BANCOLOMBIA/
|
31
|
+
# to: Personal:Assets:Cash
|
32
|
+
# to_shorthand: InternationalAtm
|
33
|
+
# shorthand_params:
|
34
|
+
# amount: "600000 COP"
|
35
|
+
# operation_cost: "24290.00 COP"
|
36
|
+
# operation_cost_to: Personal:Expenses:Banking:Fees:RandomAtmOperator
|
37
|
+
# conversion_markup: "7.5"
|
38
|
+
# conversion_markup_to: Personal:Expenses:Banking:Fees:RandomAtmOperator
|
39
|
+
# ...
|
40
|
+
# ```
|
41
|
+
# And how one of these above uses will reconcile, in your build:
|
42
|
+
# ```
|
43
|
+
# ...
|
44
|
+
# 2023-02-18 BANCOLOMBIA AERO_JMC4 antioquia
|
45
|
+
# Personal:Assets:Cash 600000.00 COP @@ $ 123.26
|
46
|
+
# Personal:Expenses:Banking:Fees:RandomAtmOperator 24290.00 COP @@ $ 4.99
|
47
|
+
# Personal:Expenses:Banking:Fees:RandomAtmOperator $ 9.62
|
48
|
+
# Personal:Assets:AcmeBank:Checking
|
49
|
+
# ...
|
50
|
+
# ```
|
51
|
+
# Note that the reconciler line above, could match more than one transaction in the input file, and if it
|
52
|
+
# does, each of them will be expanded similarly to the expansion below. Though, with international exchange
|
53
|
+
# rates changing on a daily basis, the numbers may be different, depending on the debit amount encountered
|
54
|
+
# in the input file.
|
55
|
+
class InternationalAtm
|
56
|
+
# @!visibility private
|
57
|
+
MSG_MISSING_REQUIRED_FIELDS = "'International Atm' module at line:%s missing required field %s"
|
58
|
+
# @!visibility private
|
59
|
+
MSG_OPERATION_COST_AND_AMOUNT_MUST_HAVE_SAME_COMMODITY = "'International Atm' module at line:%s requires " \
|
60
|
+
'that the operation cost currency matches the ' \
|
61
|
+
'amount withdrawn'
|
62
|
+
# @!visibility private
|
63
|
+
MSG_FIELD_REQUIRED_IF_FIELD_EXISTS = "'International Atm' module at line:%s. Field %s is required if field " \
|
64
|
+
'%s is provided.'
|
65
|
+
|
66
|
+
# @!visibility private
|
67
|
+
attr_reader :tag, :targets, :to, :amount, :operation_cost, :conversion_markup,
|
68
|
+
:conversion_markup_to, :operation_cost_to
|
69
|
+
|
70
|
+
# @!visibility private
|
71
|
+
def initialize(rule)
|
72
|
+
@tag = rule[:tag]
|
73
|
+
@targets = rule[:targets]
|
74
|
+
@to = rule[:to] || 'Personal:Assets'
|
75
|
+
|
76
|
+
if rule.key? :shorthand_params
|
77
|
+
shorthand_params = rule[:shorthand_params]
|
78
|
+
@amount = shorthand_params[:amount].to_commodity if shorthand_params.key? :amount
|
79
|
+
@operation_cost = shorthand_params[:operation_cost].to_commodity if shorthand_params.key? :operation_cost
|
80
|
+
if shorthand_params.key? :conversion_markup
|
81
|
+
@conversion_markup = (BigDecimal(shorthand_params[:conversion_markup]) / 100) + 1
|
82
|
+
end
|
83
|
+
if shorthand_params.key? :conversion_markup_to
|
84
|
+
@conversion_markup_to = shorthand_params[:conversion_markup_to]
|
85
|
+
end
|
86
|
+
@operation_cost_to = shorthand_params[:operation_cost_to] if shorthand_params.key? :operation_cost_to
|
87
|
+
end
|
88
|
+
|
89
|
+
raise StandardError, format(MSG_MISSING_REQUIRED_FIELDS, rule[:line].inspect, 'amount') unless amount
|
90
|
+
|
91
|
+
if conversion_markup && conversion_markup_to.nil?
|
92
|
+
raise StandardError, format(MSG_MISSING_REQUIRED_FIELDS, rule[:line].inspect, 'conversion_markup_to',
|
93
|
+
'conversion_markup')
|
94
|
+
end
|
95
|
+
|
96
|
+
if operation_cost && operation_cost_to.nil?
|
97
|
+
raise StandardError, format(MSG_MISSING_REQUIRED_FIELDS, rule[:line].inspect, 'operation_cost_to',
|
98
|
+
'operation_cost')
|
99
|
+
end
|
100
|
+
|
101
|
+
if operation_cost && operation_cost.alphabetic_code != amount.alphabetic_code
|
102
|
+
raise StandardError, format(MSG_OPERATION_COST_AND_AMOUNT_MUST_HAVE_SAME_COMMODITY, rule[:line].inspect)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# @!visibility private
|
107
|
+
def to_tx(from_posting)
|
108
|
+
reported_amount = from_posting.commodity
|
109
|
+
targets = []
|
110
|
+
|
111
|
+
if conversion_markup
|
112
|
+
conversion_markup_fees = (reported_amount - (reported_amount / conversion_markup)).round(
|
113
|
+
RVGP::Journal::Currency.from_code_or_symbol(reported_amount.code).minor_unit
|
114
|
+
)
|
115
|
+
targets << { to: conversion_markup_to, commodity: conversion_markup_fees }
|
116
|
+
end
|
117
|
+
|
118
|
+
if operation_cost
|
119
|
+
amount_with_operation_cost = amount + operation_cost
|
120
|
+
operation_cost_fraction = (
|
121
|
+
operation_cost.quantity_as_bigdecimal / amount_with_operation_cost.quantity_as_bigdecimal
|
122
|
+
)
|
123
|
+
|
124
|
+
amount_after_conversion_fees = [reported_amount, conversion_markup_fees].compact.reduce(:-)
|
125
|
+
|
126
|
+
operation_cost_fees = (amount_after_conversion_fees * operation_cost_fraction).round(
|
127
|
+
RVGP::Journal::Currency.from_code_or_symbol(amount_after_conversion_fees.code).minor_unit
|
128
|
+
)
|
129
|
+
targets << { to: operation_cost_to,
|
130
|
+
complex_commodity: RVGP::Journal::ComplexCommodity.new(left: operation_cost,
|
131
|
+
operation: :per_lot,
|
132
|
+
right: operation_cost_fees) }
|
133
|
+
end
|
134
|
+
|
135
|
+
remitted = [reported_amount, conversion_markup_fees, operation_cost_fees].compact.reduce(:-)
|
136
|
+
|
137
|
+
targets << { to: to,
|
138
|
+
complex_commodity: RVGP::Journal::ComplexCommodity.new(left: amount,
|
139
|
+
operation: :per_lot,
|
140
|
+
right: remitted) }
|
141
|
+
|
142
|
+
RVGP::Base::Reconciler::Posting.new from_posting.line_number,
|
143
|
+
date: from_posting.date,
|
144
|
+
description: from_posting.description,
|
145
|
+
from: from_posting.from,
|
146
|
+
tags: from_posting.tags,
|
147
|
+
targets: targets.reverse
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|