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