rvgp 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- metadata +268 -0
@@ -0,0 +1,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
|