rvgp 0.3.2

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