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,531 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require_relative '../application/descendant_registry'
5
+ require_relative '../utilities'
6
+
7
+ module RVGP
8
+ # This module largely exists as a folder, in which to group Parent classes, that are used throughout the project.
9
+ # There's nothing else interesting happening here in this module, other than its use as as namespace.
10
+ module Base
11
+ # This is the base class implementation, for your application-defined grids. This class offers the bulk of
12
+ # functionality that your grids will use. The goal of a grid, is to compute csv files, in the project's build/grids
13
+ # directory. Sometimes, these grids will simply be an assemblage of pta queries. Other times, these grids won't
14
+ # involve any pta queries at all, and may instead contain projections and statistics computed elsewhere.
15
+ #
16
+ # Users are expected to inherit from this class, in their grid bulding implementations, inside ruby classes defined
17
+ # in your project's app/grids directory. This class offers helpers for working with pta_adapters (particularly for
18
+ # use with by-month queries). Additionally, this class offers code to detect and produce annual, and otherwise
19
+ # arbitary(see {RVGP::Base::Grid::HasMultipleSheets}) segmentation of grids.
20
+ #
21
+ # The function and purpose of grids, in your project, is as follows:
22
+ # - Store a state of our data in the project's build, and thus its git history.
23
+ # - Provide the data used by a subsequent {RVGP::Plot}.
24
+ # - Provide the data used by a subsequent {RVGP::Utilities::GridQuery}.
25
+ #
26
+ # Each instance of Grid, in your build is expected to represent a segment of the data. Typically this segment will
27
+ # be as simple as a date range (either a specific year, or 'all dates'). However, the included
28
+ # {RVGP::Base::Grid::HasMultipleSheets} module, allows you to add additional arbitrary segments (perhaps a segment
29
+ # for each value of a tag), that may be used to produce additional grids in your build, on top of the dated
30
+ # segments.
31
+ #
32
+ # ## Example
33
+ # Perhaps the easiest way to understand what this class does, is to look at one of the sample grids produced by
34
+ # the new_project command. Here's the contents of an app/grids/wealth_growth_grid.rb, that you can use in your
35
+ # projects:
36
+ # class WealthGrowthGrid < RVGP::Base::Grid
37
+ # grid 'wealth_growth', 'Generate Wealth Growth Grids', 'Wealth Growth by month (%s)',
38
+ # output_path_template: '%s-wealth-growth'
39
+ #
40
+ # def sheet_header
41
+ # %w[Date Assets Liabilities]
42
+ # end
43
+ #
44
+ # def sheet_body
45
+ # assets, liabilities = *%w[Assets Liabilities].map { |acct| monthly_totals acct, accrue_before_begin: true }
46
+ #
47
+ # months_through(starting_at, ending_at).map do |month|
48
+ # [month.strftime('%m-%y'), assets[month], liabilities[month]]
49
+ # end
50
+ # end
51
+ # end
52
+ #
53
+ # This WealthGrowthGrid, depending on your data, will output a series of grids in your build directory, such as the
54
+ # following:
55
+ # -  build/grids/2018-wealth-growth.csv
56
+ # -  build/grids/2019-wealth-growth.csv
57
+ # -  build/grids/2020-wealth-growth.csv
58
+ # -  build/grids/2021-wealth-growth.csv
59
+ # -  build/grids/2022-wealth-growth.csv
60
+ # -  build/grids/2023-wealth-growth.csv
61
+ #
62
+ # And, inside each of this files, will be a csv similar to:
63
+ # ```
64
+ # Date,Assets,Liabilities
65
+ # 01-23,89418.01,-4357.45
66
+ # 02-23,89708.53,-3731.10
67
+ # 03-23,89899.81,-3150.35
68
+ # 04-23,89991.36,-2616.21
69
+ # 05-23,89982.94,-2129.60
70
+ # 06-23,89874.60,-1691.37
71
+ # 07-23,89666.59,-1302.28
72
+ # 08-23,89359.43,-963.00
73
+ # 09-23,88953.92,-674.13
74
+ # 10-23,88451.01,-436.16
75
+ # ```
76
+ #
77
+ # @attr_reader [Date] starting_at The first day in this instance of the grid
78
+ # @attr_reader [Date] ending_at The last day in this instance of the grid
79
+ # @attr_reader [Integer] year The year segmentation, for an instance of this Grid. This value is pulled from the
80
+ # year of :ending_at.
81
+ class Grid
82
+ include RVGP::Application::DescendantRegistry
83
+ include RVGP::Pta::AvailabilityHelper
84
+ include RVGP::Utilities
85
+
86
+ register_descendants RVGP, :grids, accessors: {
87
+ task_names: lambda { |registry|
88
+ registry.names.map do |name|
89
+ RVGP.app.config.grid_years.map do |year|
90
+ format 'grid:%<year>d-%<name>s', year: year, name: name.tr('_', '-')
91
+ end
92
+ end.flatten
93
+ }
94
+ }
95
+
96
+ attr_reader :starting_at, :ending_at, :year
97
+
98
+ # Create a Grid, given the following date segment
99
+ # @param [Date] starting_at See {RVGP::Base::Grid#starting_at}.
100
+ # @param [Date] ending_at See {RVGP::Base::Grid#ending_at}.
101
+ def initialize(starting_at, ending_at)
102
+ # NOTE: It seems that with monthly queries, the ending date works a bit
103
+ # differently. It's not necessariy to add one to the day here. If you do,
104
+ # you get the whole month of January, in the next year added to the output.
105
+ @year = starting_at.year
106
+ @starting_at = starting_at
107
+ @ending_at = ending_at
108
+ end
109
+
110
+ # Write the computed grid, to its default build path
111
+ # @return [void]
112
+ def to_file!
113
+ write! self.class.output_path(year), to_table
114
+ nil
115
+ end
116
+
117
+ # Return the computed grid, in a parsed form, before it's serialized to a string.
118
+ # @return [Array[Array<String>]] Each row is an array, itself composed of an array of cells.
119
+ def to_table
120
+ [sheet_header] + sheet_body
121
+ end
122
+
123
+ private
124
+
125
+ # @!visibility public
126
+ # The provided args are passed to {RVGP::Pta::AvailabilityHelper#pta}'s '#register. The total amounts returned by
127
+ # this query are reduced by account, then month. This means that the return value is a Hash, whose keys correspond
128
+ # to each of the accounts that were encounted. The values for each of those keys, is itself a Hash indexed by
129
+ # month, whose value is the total amount returned, for that month.
130
+ #
131
+ # In addition to the options supported by pta.register, the following options are supported:
132
+ # - **accrue_before_begin** [Boolean] - This flag will create a pta-adapter independent query, to accrue balances
133
+ # before the start date of the returned set. This is useful if you want to (say) output the current year's
134
+ # total's, but, you want to start with the ending balance of the prior year, as opposed to '0'.
135
+ # - **initial** [Hash] - defaults to ({}). This is the value we begin to map values from. Typically we want to
136
+ # start that process from nil, this allows us to decorate the starting point.
137
+ # - **in_code** [String] - defaults to ('$') . This value, expected to be a commodity code, is ultimately passed
138
+ # to {RVGP::Pta::RegisterPosting#total_in}
139
+ # @param [Array<Object>] args See {RVGP::Pta::HLedger#register}, {RVGP::Pta::Ledger#register} for details
140
+ # @return [Hash<String,Hash<Date,RVGP::Journal::Commodity>>] all totals, indexed by month. Months are indexed by
141
+ # account.
142
+ def monthly_totals_by_account(*args)
143
+ reduce_monthly_by_account(*args, :total_in)
144
+ end
145
+
146
+ # @!visibility public
147
+ # The provided args are passed to {RVGP::Pta::AvailabilityHelper#pta}'s #register. The amounts returned by this
148
+ # query are reduced by account, then month. This means that the return value is a Hash, whose keys correspond to
149
+ # each of the accounts that were encounted. The values for each of those keys, is itself a Hash indexed by month,
150
+ # whose value is the amount amount returned, for that month.
151
+ #
152
+ # In addition to the options supported by pta.register, the following options are supported:
153
+ # - **accrue_before_begin** [Boolean] - This flag will create a pta-adapter independent query, to accrue balances
154
+ # before the start date of the returned set. This is useful if you want to (say) output the current year's
155
+ # amount's, but, you want to start with the ending balance of the prior year, as opposed to '0'.
156
+ # - **initial** [Hash] - defaults to ({}). This is the value we begin to map values from. Typically we want to
157
+ # start that process from nil, this allows us to decorate the starting point.
158
+ # - **in_code** [String] - defaults to ('$') . This value, expected to be a commodity code, is ultimately passed
159
+ # to {RVGP::Pta::RegisterPosting#amount_in}
160
+ # @param [Array<Object>] args See {RVGP::Pta::HLedger#register}, {RVGP::Pta::Ledger#register} for details
161
+ # @return [Hash<String,Hash<Date,RVGP::Journal::Commodity>>] all amounts, indexed by month. Months are indexed by
162
+ # account.
163
+ def monthly_amounts_by_account(*args)
164
+ reduce_monthly_by_account(*args, :amount_in)
165
+ end
166
+
167
+ # @!visibility public
168
+ # The provided args are passed to {RVGP::Pta::AvailabilityHelper#pta}'s #register. The total amounts returned by
169
+ # this query are reduced by month. This means that the return value is a Hash, indexed by month (in the form of a
170
+ # Date class) whose value is itself a Commodity, which indicates the total for that month.
171
+ #
172
+ # In addition to the options supported by pta.register, the following options are supported:
173
+ # - **accrue_before_begin** [Boolean] - This flag will create a pta-adapter independent query, to accrue balances
174
+ # before the start date of the returned set. This is useful if you want to (say) output the current year's
175
+ # amount's, but, you want to start with the ending balance of the prior year, as opposed to '0'.
176
+ # - **initial** [Hash] - defaults to ({}). This is the value we begin to map values from. Typically we want to
177
+ # start that process from nil, this allows us to decorate the starting point.
178
+ # - **in_code** [String] - defaults to ('$') . This value, expected to be a commodity code, is ultimately passed
179
+ # to {RVGP::Pta::RegisterPosting#total_in}
180
+ # @param [Array<Object>] args See {RVGP::Pta::HLedger#register}, {RVGP::Pta::Ledger#register} for details
181
+ # @return [Hash<Date,RVGP::Journal::Commodity>] all amounts, indexed by month. Months are indexed by
182
+ # account.
183
+ def monthly_totals(*args)
184
+ reduce_monthly(*args, :total_in)
185
+ end
186
+
187
+ # @!visibility public
188
+ # The provided args are passed to {RVGP::Pta::AvailabilityHelper#pta}'s #register. The amounts returned by this
189
+ # query are reduced by month. This means that the return value is a Hash, indexed by month (in the form of a Date
190
+ # class) whose value is itself a Commodity, which indicates the amount for that month.
191
+ #
192
+ # In addition to the options supported by pta.register, the following options are supported:
193
+ # - **accrue_before_begin** [Boolean] - This flag will create a pta-adapter independent query, to accrue balances
194
+ # before the start date of the returned set. This is useful if you want to (say) output the current year's
195
+ # amount's, but, you want to start with the ending balance of the prior year, as opposed to '0'.
196
+ # - **initial** [Hash] - defaults to ({}). This is the value we begin to map values from. Typically we want to
197
+ # start that process from nil, this allows us to decorate the starting point.
198
+ # - **in_code** [String] - defaults to ('$') . This value, expected to be a commodity code, is ultimately passed
199
+ # to {RVGP::Pta::RegisterPosting#amount_in}
200
+ # @param [Array<Object>] args See {RVGP::Pta::HLedger#register}, {RVGP::Pta::Ledger#register} for details
201
+ # @return [Hash<Date,RVGP::Journal::Commodity>] all amounts, indexed by month. Months are indexed by
202
+ # account.
203
+ def monthly_amounts(*args)
204
+ reduce_monthly(*args, :amount_in)
205
+ end
206
+
207
+ def reduce_monthly_by_account(*args, posting_method)
208
+ opts = args.last.is_a?(Hash) ? args.pop : {}
209
+
210
+ in_code = opts[:in_code] || '$'
211
+
212
+ reduce_postings_by_month(*args, opts) do |sum, date, posting|
213
+ next sum if posting.account.nil? || posting.account.is_a?(Symbol)
214
+
215
+ amount_in_code = posting.send posting_method, in_code
216
+ if amount_in_code
217
+ sum[posting.account] ||= {}
218
+ if sum[posting.account].key? date
219
+ sum[posting.account][date] += amount_in_code
220
+ else
221
+ sum[posting.account][date] = amount_in_code
222
+ end
223
+ end
224
+ sum
225
+ end
226
+ end
227
+
228
+ def reduce_monthly(*args, posting_method)
229
+ opts = args.last.is_a?(Hash) ? args.pop : {}
230
+
231
+ in_code = opts[:in_code] || '$'
232
+
233
+ opts[:ledger_opts] ||= {}
234
+ opts[:ledger_opts][:collapse] ||= true
235
+
236
+ opts[:hledger_args] ||= []
237
+ opts[:hledger_args] << 'depth:0' unless (args + opts[:hledger_args]).any? { |arg| /^depth:\d+$/.match arg }
238
+
239
+ reduce_postings_by_month(*args, opts) do |sum, date, posting|
240
+ amount_in_code = posting.send(posting_method, in_code)
241
+ if amount_in_code
242
+ if sum[date]
243
+ sum[date] += amount_in_code
244
+ else
245
+ sum[date] = amount_in_code
246
+ end
247
+ end
248
+ sum
249
+ end
250
+ end
251
+
252
+ # This method keeps our grids DRY. It accrues a sum for each posting, on a
253
+ # monthly query
254
+ def reduce_postings_by_month(*args, &block)
255
+ opts = args.last.is_a?(Hash) ? args.pop : {}
256
+
257
+ initial = opts.delete(:initial) || {}
258
+
259
+ # TODO: I've never been crazy about this name... maybe we can borrow terminology
260
+ # from the hledger help, on what historical is...
261
+ accrue_before_begin = opts.delete :accrue_before_begin
262
+
263
+ opts.merge!({ pricer: RVGP.app.pricer,
264
+ monthly: true,
265
+ empty: false, # This applies to Ledger, and ensures it's results match HLedger's exactly
266
+ # TODO: I don't think I need this file: here
267
+ file: RVGP.app.config.project_journal_path })
268
+
269
+ opts[:hledger_opts] ||= {}
270
+ opts[:ledger_opts] ||= {}
271
+ opts[:hledger_args] ||= []
272
+
273
+ if accrue_before_begin
274
+ opts[:ledger_opts][:display] = format('date>=[%<starting_at>s] and date <=[%<ending_at>s]',
275
+ starting_at: starting_at.strftime('%Y-%m-%d'),
276
+ ending_at: ending_at.strftime('%Y-%m-%d'))
277
+ # TODO: Can we maybe use opts on this?
278
+ opts[:hledger_args] += [format('date:%s-', starting_at.strftime('%Y/%m/%d')),
279
+ format('date:-%s', ending_at.strftime('%Y/%m/%d'))]
280
+ opts[:hledger_opts][:historical] = true
281
+ else
282
+ # NOTE: I'm not entirely sure we want this path. It may be that we should always use the
283
+ # display option....
284
+ opts[:begin] = (opts[:begin] || starting_at).strftime('%Y-%m-%d')
285
+ # It seems that ledger interprets the --end parameter as :<, and hledger
286
+ # interprets it as :<= . So, we add one here, and, this makes the output consistent with
287
+ # hledger, as well as our :display syntax above.
288
+ opts[:ledger_opts][:end] = (ending_at + 1).strftime('%Y-%m-%d')
289
+ opts[:hledger_opts][:end] = ending_at.strftime('%Y-%m-%d')
290
+ end
291
+
292
+ pta.register(*args, opts).transactions.inject(initial) do |ret, tx|
293
+ tx.postings.reduce(ret) do |sum, posting|
294
+ block.call sum, tx.date, posting
295
+ end
296
+ end
297
+ end
298
+
299
+ def write!(path, rows)
300
+ CSV.open(path, 'w') do |csv|
301
+ rows.each do |row|
302
+ csv << row.map { |val| val.is_a?(RVGP::Journal::Commodity) ? val.to_s(no_code: true, precision: 2) : val }
303
+ end
304
+ end
305
+ end
306
+
307
+ # @attr_reader [String] name The name of this grid. Typically, this would be an underscorized version of the class
308
+ # name, without the _grid suffix. This is used to compose the rake task names for each
309
+ # instance of this class.
310
+ # @attr_reader [String] description A description of this grid, for use in a rake task description.
311
+ # @attr_reader [String] output_path_template A string template, for use in building output files. A single '%s'
312
+ # formatter is expected, which, will be substituted with the year of
313
+ # a segment or the string 'all'.
314
+ class << self
315
+ include RVGP::Utilities
316
+ include RVGP::Pta::AvailabilityHelper
317
+
318
+ attr_reader :name, :description, :output_path_template
319
+
320
+ # This helper method is provided for child classes, to easily establish a definition of this grid, that
321
+ # can be used to produce it's instances, and their resulting output.
322
+ # @param [String] name See {RVGP::Base::Grid.name}.
323
+ # @param [String] description See {RVGP::Base::Grid.description}.
324
+ # @param [String] status_name_template A template to use, when composing the build status. A single '%s'
325
+ # formatter is expected, which, will be substituted with the year
326
+ # of a segment or the string 'all'.
327
+ # @param [String] options what options to configure this registry with
328
+ # @option options [String] :output_path_template See {RVGP::Base::Grid.output_path_template}.
329
+ # @return [void]
330
+ def grid(name, description, status_name_template, options = {})
331
+ @name = name
332
+ @description = description
333
+ @status_name_template = status_name_template
334
+ @output_path_template = options[:output_path_template]
335
+ end
336
+
337
+ # This method returns an array of paths, to the files it produces it's output from. This is used by rake
338
+ # to establish the freshness of our output. We assume that output is deterministic, and based on these
339
+ # inputs.
340
+ # @return [Array<String>] an array of relative paths, to our inputs.
341
+ def dependency_paths
342
+ # NOTE: This is only used right now, in the plot task. So, the cache is fine.
343
+ # But, if we start using this before the journals are built, we're going to
344
+ # need to clear this cache, thereafter. So, maybe we want to take a parameter
345
+ # here, or figure something out then, to prevent problems.
346
+ @dependency_paths ||= pta.files(file: RVGP.app.config.project_journal_path)
347
+ end
348
+
349
+ # Whether this grid's outputs are fresh. This is determined, by examing the mtime's of our #dependency_paths.
350
+ # @return [TrueClass, FalseClass] true, if we're fresh, false if we're stale.
351
+ def uptodate?(year)
352
+ FileUtils.uptodate? output_path(year), dependency_paths
353
+ end
354
+
355
+ # Given a year, compute the output path for an instance of this grid
356
+ # @param [String,Integer] year The year to which this output is specific. Or, alternatively 'all'.
357
+ # @return [String] relative path to an output file
358
+ def output_path(year)
359
+ raise StandardError, 'Missing output_path_template' unless output_path_template
360
+
361
+ [RVGP.app.config.build_path('grids'), '/', output_path_template % year, '.csv'].join
362
+ end
363
+
364
+ # Given a year, compute the status label for an instance of this grid
365
+ # @param [String,Integer] year The year to which this status is specific. Or, alternatively 'all'.
366
+ # @return [String] A friendly label, constructed from the :status_name_template
367
+ def status_name(year)
368
+ @status_name_template % year
369
+ end
370
+ end
371
+
372
+ # This module can be included into classes descending from RVGP::Base::Grid, in order to add support for multiple
373
+ # sheets, per year. These sheets can be declared using the provided 'has_sheets' class method, like so:
374
+ # ```
375
+ # has_sheets('cashflow') { %w(personal business) }
376
+ # ```
377
+ # This declaration will ensure the creation of "#\\{year}-cashflow-business.csv" and
378
+ # "#\\{year}-cashflow-personal.csv" grids, in the project's build/grids output. This is achieved by providing the
379
+ # sheet name as a parameter to your #sheet_header, and #sheet_body methods. (see the below example)
380
+ #
381
+ # ## Example
382
+ # Here's a simple example of a grid that's segmented both by year, as well as by "property". The property an
383
+ # expense correlates with, is determined by the value of it's property tag (should one exist).
384
+ # This grid will build a separate grid for every property that we've tagged expenses for, with the expenses for
385
+ # that tag, separated by year.
386
+ # ```
387
+ # class PropertyExpensesGrid < RVGP::Base::Grid
388
+ # include HasMultipleSheets
389
+ #
390
+ # grid 'expenses_by_property', 'Generate Property Expense Grids', 'Property Expenses by month (%s)'
391
+ #
392
+ # has_sheets('property-expenses') { |year| pta.tags 'property', values: true, begin: year, end: year + 1 }
393
+ #
394
+ # def sheet_header(property)
395
+ # ['Date'] + sheet_series(property)
396
+ # end
397
+ #
398
+ # def sheet_body(property)
399
+ # months = property_expenses(property).values.map(&:keys).flatten.uniq.sort
400
+ #
401
+ # months_through_dates(months.first, months.last).map do |month|
402
+ # [month.strftime('%m-%y')] + sheet_series(property).map { |col| property_expenses(property)[col][month] }
403
+ # end
404
+ # end
405
+ #
406
+ # private
407
+ #
408
+ # def sheet_series(property)
409
+ # property_expenses(property).keys.sort
410
+ # end
411
+ #
412
+ # def property_expenses(property)
413
+ # @property_expenses ||= {}
414
+ # @property_expenses[property] ||= monthly_amounts_by_account(
415
+ # ledger_args: [format('%%property=%s', property), 'and', 'Expense'],
416
+ # hledger_args: [format('tag:property=%s', property), 'Expense']
417
+ # )
418
+ # end
419
+ # end
420
+ # ```
421
+ #
422
+ # This PropertyExpensesGrid, depending on your data, will output a series of grids in your build directory, such
423
+ # as the following:
424
+ # -  build/grids/2018-property-expenses-181_yurakucho.csv
425
+ # -  build/grids/2018-property-expenses-101_0021tokyo.csv
426
+ # -  build/grids/2019-property-expenses-181_yurakucho.csv
427
+ # -  build/grids/2019-property-expenses-101_0021tokyo.csv
428
+ # -  build/grids/2020-property-expenses-181_yurakucho.csv
429
+ # -  build/grids/2020-property-expenses-101_0021tokyo.csv
430
+ # -  build/grids/2021-property-expenses-181_yurakucho.csv
431
+ # -  build/grids/2021-property-expenses-101_0021tokyo.csv
432
+ # -  build/grids/2022-property-expenses-181_yurakucho.csv
433
+ # -  build/grids/2022-property-expenses-101_0021tokyo.csv
434
+ # -  build/grids/2023-property-expenses-181_yurakucho.csv
435
+ # -  build/grids/2023-property-expenses-101_0021tokyo.csv
436
+ #
437
+ # And, inside each of this files, will be a csv similar to:
438
+ # ```
439
+ # Date,Business:Expenses:Banking:Interest:181Yurakucho,Business:Expenses:Home:Improvement:181Yurakucho[...]
440
+ # 01-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
441
+ # 02-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
442
+ # 03-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
443
+ # 04-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
444
+ # 05-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
445
+ # 06-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
446
+ # 07-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
447
+ # 07-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
448
+ # 08-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
449
+ # 09-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
450
+ # 10-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
451
+ # 11-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
452
+ # 12-23,123.45,678.90,123.45,678.90,123.45,678.90,123.45,678.90,123.45
453
+ # ```
454
+ module HasMultipleSheets
455
+ # Return the computed grid, in a parsed form, before it's serialized to a string.
456
+ # @return [Array[Array<String>]] Each row is an array, itself composed of an array of cells.
457
+ def to_table(sheet)
458
+ [sheet_header(sheet)] + sheet_body(sheet)
459
+ end
460
+
461
+ # Write the computed grid, to its default build path
462
+ # @return [void]
463
+ def to_file!
464
+ self.class.sheets(year).each do |sheet|
465
+ write! self.class.output_path(year, sheet.to_s.downcase), to_table(sheet)
466
+ end
467
+ nil
468
+ end
469
+
470
+ # see (RVGP::Base::Grid::HasMultipleSheets.sheets)
471
+ def sheets(year)
472
+ self.class.sheets year
473
+ end
474
+
475
+ # @!visibility private
476
+ def self.included(klass)
477
+ klass.extend ClassMethods
478
+ end
479
+
480
+ # This module contains the Class methods, that are automatically included,
481
+ # at the time RVGP::Base::Grid::HasMultipleSheets is included into a class.
482
+ module ClassMethods
483
+ # Define what additional sheets, this Grid will handle.
484
+ # @param [String] sheet_output_prefix This is used in constructing the output file, and is expected to be
485
+ # a friendly name, describing the container, under which our multiple
486
+ # sheets exist.
487
+ # @yield [year] Return the sheets, that are available in the given year
488
+ # @yieldparam [Integer] year The year being queried.
489
+ # @yieldreturn [Array<String>] The sheets (aka grids) that we can generate for this year
490
+ # @return [void]
491
+ def has_sheets(sheet_output_prefix, &block) # rubocop:disable Naming/PredicateName
492
+ @has_sheets = block
493
+ @sheet_output_prefix = sheet_output_prefix
494
+ end
495
+
496
+ # Returns the sheets that are available for the given year. This is calculated using the block provided in
497
+ # #has_sheets
498
+ # @param [Integer] year The year being queried.
499
+ # @return [Array<String>] What sheets (aka grids) are available this year
500
+ def sheets(year)
501
+ @sheets ||= {}
502
+ @sheets[year] ||= @has_sheets.call year
503
+ end
504
+
505
+ # Returns the sheet_output_prefix, that was set in #has_sheets
506
+ # @return [String] The label for our multiple sheet taxonomy
507
+ def sheet_output_prefix
508
+ @sheet_output_prefix
509
+ end
510
+
511
+ # @!visibility private
512
+ def output_path(year, sheet)
513
+ format '%<path>s/%<year>s-%<prefix>s-%<sheet>s.csv',
514
+ path: RVGP.app.config.build_path('grids'),
515
+ year: year,
516
+ prefix: sheet_output_prefix,
517
+ sheet: sheet.to_s.downcase
518
+ end
519
+
520
+ # Whether this grid's outputs are fresh. This is determined, by examing the mtime's of our #dependency_paths.
521
+ # @return [TrueClass, FalseClass] true, if we're fresh, false if we're stale.
522
+ def uptodate?(year)
523
+ sheets(year).all? do |sheet|
524
+ FileUtils.uptodate? output_path(year, sheet), dependency_paths
525
+ end
526
+ end
527
+ end
528
+ end
529
+ end
530
+ end
531
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RVGP
4
+ module Base
5
+ # This base class exists as a shorthand, for classes whose public readers are populated via
6
+ # #initialize(). Think of this as an lighter-weight alternative to OpenStruct.
7
+ # @attr_reader [Hash<Symbol,Object>] options This instance's reader attributes, and their values, as a Hash
8
+ class Reader
9
+ # Classes which inherit from this class, can declare their attr_reader in a shorthand format, by way of
10
+ # this method. Attributes declared in this method, will be able to be set in the options passed to
11
+ # their #initialize
12
+ # @param readers [Array<Symbol>] A list of the attr_readers, this class will provide
13
+ def self.readers(*readers)
14
+ attr_reader(*readers)
15
+ attr_reader :options
16
+
17
+ define_method :initialize do |*args|
18
+ readers.each_with_index do |r, i|
19
+ instance_variable_set format('@%s', r).to_sym, args[i]
20
+ end
21
+
22
+ # If there are more arguments than attr's the last argument is an options
23
+ # hash
24
+ instance_variable_set '@options', args[readers.length].is_a?(Hash) ? args[readers.length] : {}
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end