rvgp 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +23 -0
  4. data/LICENSE +504 -0
  5. data/README.md +223 -0
  6. data/Rakefile +32 -0
  7. data/bin/rvgp +8 -0
  8. data/lib/rvgp/application/config.rb +159 -0
  9. data/lib/rvgp/application/descendant_registry.rb +122 -0
  10. data/lib/rvgp/application/status_output.rb +139 -0
  11. data/lib/rvgp/application.rb +170 -0
  12. data/lib/rvgp/base/command.rb +457 -0
  13. data/lib/rvgp/base/grid.rb +531 -0
  14. data/lib/rvgp/base/reader.rb +29 -0
  15. data/lib/rvgp/base/reconciler.rb +434 -0
  16. data/lib/rvgp/base/validation.rb +261 -0
  17. data/lib/rvgp/commands/cashflow.rb +160 -0
  18. data/lib/rvgp/commands/grid.rb +70 -0
  19. data/lib/rvgp/commands/ireconcile.rb +95 -0
  20. data/lib/rvgp/commands/new_project.rb +296 -0
  21. data/lib/rvgp/commands/plot.rb +41 -0
  22. data/lib/rvgp/commands/publish_gsheets.rb +83 -0
  23. data/lib/rvgp/commands/reconcile.rb +58 -0
  24. data/lib/rvgp/commands/rotate_year.rb +202 -0
  25. data/lib/rvgp/commands/validate_journal.rb +59 -0
  26. data/lib/rvgp/commands/validate_system.rb +44 -0
  27. data/lib/rvgp/commands.rb +160 -0
  28. data/lib/rvgp/dashboard.rb +252 -0
  29. data/lib/rvgp/fakers/fake_feed.rb +245 -0
  30. data/lib/rvgp/fakers/fake_journal.rb +57 -0
  31. data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
  32. data/lib/rvgp/fakers/faker_helpers.rb +25 -0
  33. data/lib/rvgp/gem.rb +80 -0
  34. data/lib/rvgp/journal/commodity.rb +453 -0
  35. data/lib/rvgp/journal/complex_commodity.rb +214 -0
  36. data/lib/rvgp/journal/currency.rb +101 -0
  37. data/lib/rvgp/journal/journal.rb +141 -0
  38. data/lib/rvgp/journal/posting.rb +156 -0
  39. data/lib/rvgp/journal/pricer.rb +267 -0
  40. data/lib/rvgp/journal.rb +24 -0
  41. data/lib/rvgp/plot/gnuplot.rb +478 -0
  42. data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
  43. data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
  44. data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
  45. data/lib/rvgp/plot.rb +293 -0
  46. data/lib/rvgp/pta/hledger.rb +237 -0
  47. data/lib/rvgp/pta/ledger.rb +308 -0
  48. data/lib/rvgp/pta.rb +311 -0
  49. data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
  50. data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
  51. data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
  52. data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
  53. data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
  54. data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
  55. data/lib/rvgp/utilities/grid_query.rb +190 -0
  56. data/lib/rvgp/utilities/yaml.rb +131 -0
  57. data/lib/rvgp/utilities.rb +44 -0
  58. data/lib/rvgp/validations/balance_validation.rb +68 -0
  59. data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
  60. data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
  61. data/lib/rvgp.rb +66 -0
  62. data/resources/README.MD/2022-cashflow-google.png +0 -0
  63. data/resources/README.MD/2022-cashflow.png +0 -0
  64. data/resources/README.MD/all-wealth-growth-google.png +0 -0
  65. data/resources/README.MD/all-wealth-growth.png +0 -0
  66. data/resources/gnuplot/default.yml +80 -0
  67. data/resources/i18n/en.yml +192 -0
  68. data/resources/iso-4217-currencies.json +171 -0
  69. data/resources/skel/Rakefile +5 -0
  70. data/resources/skel/app/grids/cashflow_grid.rb +27 -0
  71. data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
  72. data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
  73. data/resources/skel/app/plots/cashflow.yml +33 -0
  74. data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
  75. data/resources/skel/app/plots/wealth-growth.yml +20 -0
  76. data/resources/skel/config/csv-format-acme-checking.yml +9 -0
  77. data/resources/skel/config/google-secrets.yml +5 -0
  78. data/resources/skel/config/rvgp.yml +0 -0
  79. data/resources/skel/journals/prices.db +0 -0
  80. data/rvgp.gemspec +6 -0
  81. data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
  82. data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
  83. data/test/test_command_base.rb +61 -0
  84. data/test/test_commodity.rb +270 -0
  85. data/test/test_csv_reconciler.rb +60 -0
  86. data/test/test_currency.rb +24 -0
  87. data/test/test_fake_feed.rb +228 -0
  88. data/test/test_fake_journal.rb +98 -0
  89. data/test/test_fake_reconciler.rb +60 -0
  90. data/test/test_journal_parse.rb +545 -0
  91. data/test/test_ledger.rb +102 -0
  92. data/test/test_plot.rb +133 -0
  93. data/test/test_posting.rb +50 -0
  94. data/test/test_pricer.rb +139 -0
  95. data/test/test_pta_adapter.rb +575 -0
  96. data/test/test_utilities.rb +45 -0
  97. metadata +268 -0
@@ -0,0 +1,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