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.
- 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,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../application/descendant_registry'
|
|
4
|
+
|
|
5
|
+
module RVGP
|
|
6
|
+
module Base
|
|
7
|
+
# This class contains methods shared by both {RVGP::Base::JournalValidation} and {RVGP::Base::SystemValidation}.
|
|
8
|
+
# Validations are run during a project build, after the reconcile tasks.
|
|
9
|
+
#
|
|
10
|
+
# Validations are typically defined inside a .rb in your project's app/validations folder, and should inherit from
|
|
11
|
+
# JournalValidation or SystemValidation (though not this class itself). Your validations can be customized
|
|
12
|
+
# in ruby, to add warnings or errors to your build. Warnings are non-fatal, and merely output a notice on the
|
|
13
|
+
# command line. Errors are fatal, and halt a build.
|
|
14
|
+
#
|
|
15
|
+
# This Base class contains some common helpers for use in your validations, regardless of whether its a system or
|
|
16
|
+
# journal validation. Here are the differences between these two validation classes:
|
|
17
|
+
#
|
|
18
|
+
# ##Journal Validations
|
|
19
|
+
# Validate the output of one reconciler at a time<br>
|
|
20
|
+
#
|
|
21
|
+
# These validations are run immediately after the reconcile task, and before system validations
|
|
22
|
+
# are run. Each instance of these validations is applied to a reconcilers output file (typically located in
|
|
23
|
+
# build/journal). And by default, any journal validations that are defined in a project's app/validations are
|
|
24
|
+
# instantiated against every reconciler's output, in the project. This behavior can be overwritten, by defining a
|
|
25
|
+
# 'disable_checks' array in the root of the reconciler's yaml, containing the name(s) of validations to disable
|
|
26
|
+
# for that journal. These names are expected to be the classname of the validation, underscorized, lowercase, and
|
|
27
|
+
# with the 'Validation' suffix removed from the class. For example, to disable the
|
|
28
|
+
# {RVGP::Validations::BalanceValidation} in one of the reconcilers of your project, add the following lines to its
|
|
29
|
+
# yaml:
|
|
30
|
+
# ```
|
|
31
|
+
# disable_checks:
|
|
32
|
+
# - balance
|
|
33
|
+
# ```
|
|
34
|
+
# A JournalValidation is passed the reconciler corresponding to it's instance in its initialize method. For further
|
|
35
|
+
# details on how these validations work, see the documentation for this class here {RVGP::Base::JournalValidation}
|
|
36
|
+
# or check out an example implementation. Here's the BalanceValidation itself, which is a relatively easy example to
|
|
37
|
+
# {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/validations/balance_validation.rb balance_validation.rb}
|
|
38
|
+
# follow.
|
|
39
|
+
#
|
|
40
|
+
# ##System Validations
|
|
41
|
+
# Validate the entire, finished, journal output for the project <br>
|
|
42
|
+
#
|
|
43
|
+
# Unlike Journal validations, these Validations are run without a target, and are expected to generate warnings and
|
|
44
|
+
# errors based on the state of queries spanning multiple journals.
|
|
45
|
+
#
|
|
46
|
+
# There are no example SystemValidations included in the distribution of rvgp. However, here's an easy one, to serve
|
|
47
|
+
# as reference. This validation ensures Transfers between accounts are always credited and
|
|
48
|
+
# debited on both sides:
|
|
49
|
+
# ```
|
|
50
|
+
# class TransferAccountValidation < RVGP::Base::SystemValidation
|
|
51
|
+
# STATUS_LABEL = 'Unbalanced inter-account transfers'
|
|
52
|
+
# DESCRIPTION = "Ensure that debits and credits through Transfer accounts, complete without remainders"
|
|
53
|
+
#
|
|
54
|
+
# def validate
|
|
55
|
+
# warnings = pta.balance('Transfers').accounts.map do |account|
|
|
56
|
+
# account.amounts.map do |amount|
|
|
57
|
+
# [ account.fullname, RVGP.pastel.yellow('━'), amount.to_s(commatize: true) ].join(' ')
|
|
58
|
+
# end
|
|
59
|
+
# end.compact.flatten
|
|
60
|
+
#
|
|
61
|
+
# warning! 'Unbalanced Transfer Encountered', warnings if warnings.length > 0
|
|
62
|
+
# end
|
|
63
|
+
# end
|
|
64
|
+
# ```
|
|
65
|
+
#
|
|
66
|
+
# The above validation works, if you assign transfers between accounts like so:
|
|
67
|
+
# ```
|
|
68
|
+
# ; This is how a Credit Card Payment looks in my Checking account, the source of funds:
|
|
69
|
+
# 2023-01-25 Payment to American Express card ending in 1234
|
|
70
|
+
# Transfers:PersonalChecking_PersonalAmex $ 10000.00
|
|
71
|
+
# Personal:Assets:AcmeBank:Checking
|
|
72
|
+
#
|
|
73
|
+
# ; This is how a Credit Card Payment looks in my Amex account, the destination of funds:
|
|
74
|
+
# 2023-01-25 Payment Thank You - Web
|
|
75
|
+
# Transfers:PersonalChecking_PersonalAmex $ -10000.00
|
|
76
|
+
# Personal:Liabilities:AmericanExpress
|
|
77
|
+
# ```
|
|
78
|
+
#
|
|
79
|
+
# In this format of transfering money, if either the first or second transfer was omitted, the
|
|
80
|
+
# TransferAccountValidation will alert you that money has gone missing somewhere at the bank, and/or is taking
|
|
81
|
+
# longer to complete, than you expected.
|
|
82
|
+
#
|
|
83
|
+
# ##Summary of Differences
|
|
84
|
+
# SystemValidations are largely identical to JournalValidations, with, the following exceptions:
|
|
85
|
+
#
|
|
86
|
+
# **Priority**
|
|
87
|
+
# Journal validations are run sooner in the rake process. Just after reconciliations have completed. System
|
|
88
|
+
# validations run immediately after all Journal validations have completed.
|
|
89
|
+
#
|
|
90
|
+
# **Input**
|
|
91
|
+
# Journal validations have one input, accessible via its {RVGP::Base::JournalValidation#reconciler}. System
|
|
92
|
+
# validations have no preconfigured inputs at all. Journal Validations support a disable_checks attribute in the
|
|
93
|
+
# reconciler yaml, and system validations have no such directive.
|
|
94
|
+
#
|
|
95
|
+
# **Labeling**
|
|
96
|
+
# With Journal validations, tasks are labeled automatically by rvgp, based on their class name. System validations
|
|
97
|
+
# are expected to define a STATUS_LABEL and DESCRIPTION constant, in order to arrive at these labels.
|
|
98
|
+
#
|
|
99
|
+
# Note that for either type of validation, most/all of the integration functionality is provided by way of the
|
|
100
|
+
# {RVGP::Base::Validation#error!} and {RVGP::Base::Validation#warning!} methods, instigated in the class'
|
|
101
|
+
# validate method.
|
|
102
|
+
#
|
|
103
|
+
# ##Error and Warning formatting
|
|
104
|
+
# The format of errors and warnings are a bit peculiar, and probably need a bit more polish to the interface.
|
|
105
|
+
# Nonetheless, it's not complicated. Here's the way it works, for both collections:
|
|
106
|
+
# - The formatting of :errors and :warnings is identical. These collections contain a hierarchy of errors, which
|
|
107
|
+
# is used to display fatal and non-fatal output to the console.
|
|
108
|
+
# - Every element of these collections are expected to be a two element Array. The first element of which is to
|
|
109
|
+
# be a string containing the topmost error/warning. The second element of this Array is optional. If present,
|
|
110
|
+
# this second element is expected to be an Array of Strings, which are subordinate to the message in the first
|
|
111
|
+
# element.
|
|
112
|
+
#
|
|
113
|
+
# @attr_reader [Array<String,Array<String>>] errors Errors encountered by this validation. See the above note on
|
|
114
|
+
# 'Error and Warning formatting'
|
|
115
|
+
# @attr_reader [Array<String,Array<String>>] warnings Warnings encountered by this validation. See the above note on
|
|
116
|
+
# 'Error and Warning formatting'
|
|
117
|
+
class Validation
|
|
118
|
+
include RVGP::Pta::AvailabilityHelper
|
|
119
|
+
|
|
120
|
+
# @!visibility private
|
|
121
|
+
NAME_CAPTURE = /([^:]+)Validation\Z/.freeze
|
|
122
|
+
|
|
123
|
+
attr_reader :errors, :warnings
|
|
124
|
+
|
|
125
|
+
# Create a new Validation
|
|
126
|
+
def initialize
|
|
127
|
+
@errors = []
|
|
128
|
+
@warnings = []
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Returns true if there are no warnings or errors present in this validation's instance. Otherwise, returns false.
|
|
132
|
+
# @return [TrueClass, FalseClass] whether this validation has passed
|
|
133
|
+
def valid?
|
|
134
|
+
@errors = []
|
|
135
|
+
@warnings = []
|
|
136
|
+
validate
|
|
137
|
+
(@errors.length + @warnings.length).zero?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def format_error_or_warning(msg, citations = nil)
|
|
143
|
+
[msg, citations || []]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @!visibility public
|
|
147
|
+
# Add an error to our {RVGP::Base::Validation#errors} collection. The format of this error is expected to match
|
|
148
|
+
# the formatting indicated in the 'Error and Warning formatting' above.
|
|
149
|
+
# @param msg [String] A description of the error.
|
|
150
|
+
# @param citations [Array<String>] Supporting details, subordinate error citations, denotated 'below' the :msg
|
|
151
|
+
def error!(msg, citations = nil)
|
|
152
|
+
@errors << format_error_or_warning(msg, citations)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @!visibility public
|
|
156
|
+
# Add a warning to our {RVGP::Base::Validation#warnings} collection. The format of this warning is expected to
|
|
157
|
+
# match the formatting indicated in the 'Error and Warning formatting' above.
|
|
158
|
+
# @param msg [String] A description of the warning.
|
|
159
|
+
# @param citations [Array<String>] Supporting details, subordinate warning citations, denotated 'below' the :msg
|
|
160
|
+
def warning!(msg, citations = nil)
|
|
161
|
+
@warnings << format_error_or_warning(msg, citations)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A base class, from which your journal validations should inherit. For more information on validations, and your
|
|
166
|
+
# options, see the documentation notes on {RVGP::Base::JournalValidation}.
|
|
167
|
+
# @attr_reader [RVGP::Reconcilers::CsvReconciler,RVGP::Reconcilers::JournalReconciler] reconciler
|
|
168
|
+
# The reconciler whose output will be inspected by this journal validation instance.
|
|
169
|
+
class JournalValidation < Validation
|
|
170
|
+
include RVGP::Application::DescendantRegistry
|
|
171
|
+
|
|
172
|
+
register_descendants RVGP, :journal_validations, name_capture: NAME_CAPTURE
|
|
173
|
+
|
|
174
|
+
attr_reader :reconciler
|
|
175
|
+
|
|
176
|
+
# Create a new Journal Validation
|
|
177
|
+
# @param [RVGP::Reconcilers::CsvReconciler,RVGP::Reconcilers::JournalReconciler] reconciler
|
|
178
|
+
# see {RVGP::Base::JournalValidation#reconciler}
|
|
179
|
+
def initialize(reconciler)
|
|
180
|
+
super()
|
|
181
|
+
@reconciler = reconciler
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# This helper method will supply the provided arguments to pta.register. And if there are any transactions
|
|
185
|
+
# returned, the supplied error message will be added to our :errors colection, citing the transactions
|
|
186
|
+
# that were encountered.
|
|
187
|
+
# @param [String] with_error_msg A description of the error that corresponds to the returned transactions.
|
|
188
|
+
# @param [Array<Object>] args These arguments are supplied directly to {RVGP::Pta::AvailabilityHelper#pta}'s
|
|
189
|
+
# #register method
|
|
190
|
+
def validate_no_transactions(with_error_msg, *args)
|
|
191
|
+
ledger_opts = args.last.is_a?(Hash) ? args.pop : {}
|
|
192
|
+
|
|
193
|
+
results = pta.register(*args, { file: reconciler.output_file }.merge(ledger_opts))
|
|
194
|
+
|
|
195
|
+
transactions = block_given? ? yield(results.transactions) : results.transactions
|
|
196
|
+
|
|
197
|
+
error_citations = transactions.map do |posting|
|
|
198
|
+
format '%<date>s: %<payee>s', date: posting.date.to_s, payee: posting.payee
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
error! with_error_msg, error_citations unless error_citations.empty?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# This helper method will supply the provided account to pta.balance. And if there is a balance returned,
|
|
205
|
+
# the supplied error message will be added to our :errors colection, citing the balance that was encountered.
|
|
206
|
+
# @param [String] with_error_msg A description of the error that corresponds to the returned balances.
|
|
207
|
+
# @param [Array<String>] account This arguments is supplied directly to {RVGP::Pta::AvailabilityHelper#pta}'s
|
|
208
|
+
# #balance method
|
|
209
|
+
def validate_no_balance(with_error_msg, account)
|
|
210
|
+
results = pta.balance account, file: reconciler.output_file
|
|
211
|
+
|
|
212
|
+
error_citations = results.accounts.map do |ra|
|
|
213
|
+
ra.amounts.map { |commodity| [ra.fullname, RVGP.pastel.red('━'), commodity.to_s].join(' ') }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
error_citations.flatten!
|
|
217
|
+
|
|
218
|
+
error! with_error_msg, error_citations unless error_citations.empty?
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A base class, from which your system validations should inherit. For more information on validations, and your
|
|
223
|
+
# options, see the documentation notes on {RVGP::Base::JournalValidation}.
|
|
224
|
+
class SystemValidation < Validation
|
|
225
|
+
include RVGP::Application::DescendantRegistry
|
|
226
|
+
|
|
227
|
+
task_names = ->(registry) { registry.names.map { |name| format('validate_system:%s', name) } }
|
|
228
|
+
register_descendants RVGP, :system_validations,
|
|
229
|
+
name_capture: NAME_CAPTURE,
|
|
230
|
+
accessors: { task_names: task_names }
|
|
231
|
+
|
|
232
|
+
# @!visibility private
|
|
233
|
+
def mark_validated!
|
|
234
|
+
FileUtils.touch self.class.build_validation_file_path
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @!visibility private
|
|
238
|
+
def self.validated?
|
|
239
|
+
FileUtils.uptodate? build_validation_file_path, [
|
|
240
|
+
RVGP.app.config.build_path('journals/*.journal'),
|
|
241
|
+
RVGP.app.config.project_path('journals/*.journal')
|
|
242
|
+
].map { |glob| Dir.glob glob }.flatten
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @!visibility private
|
|
246
|
+
def self.status_label
|
|
247
|
+
const_get :STATUS_LABEL
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# @!visibility private
|
|
251
|
+
def self.description
|
|
252
|
+
const_get :DESCRIPTION
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# @!visibility private
|
|
256
|
+
def self.build_validation_file_path
|
|
257
|
+
RVGP.app.config.build_path(format('journals/system-validation-%s.valid', name.to_s))
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../utilities/grid_query'
|
|
4
|
+
require_relative '../dashboard'
|
|
5
|
+
|
|
6
|
+
module RVGP
|
|
7
|
+
module Commands
|
|
8
|
+
# @!visibility private
|
|
9
|
+
# This class contains the logic necessary to display the 'cashflow' dashboard
|
|
10
|
+
class Cashflow < RVGP::Base::Command
|
|
11
|
+
accepts_options OPTION_ALL, OPTION_LIST, [:date, :d, { has_value: 'DATE' }]
|
|
12
|
+
|
|
13
|
+
# @!visibility private
|
|
14
|
+
# This class handles the target argument, passed on the cli
|
|
15
|
+
class Target < RVGP::Base::Command::Target
|
|
16
|
+
# @!visibility private
|
|
17
|
+
def self.all
|
|
18
|
+
RVGP::Commands::Cashflow.grids_by_targetname.keys.map { |s| new s }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @!visibility private
|
|
23
|
+
def initialize(*args)
|
|
24
|
+
super(*args)
|
|
25
|
+
|
|
26
|
+
options[:date] = Date.strptime options[:date] if options.key? :date
|
|
27
|
+
|
|
28
|
+
minimum_width = RVGP::Dashboard.table_width_given_column_widths(column_widths[0..1])
|
|
29
|
+
|
|
30
|
+
unless TTY::Screen.width > minimum_width
|
|
31
|
+
@errors << I18n.t(
|
|
32
|
+
'commands.cashflow.errors.screen_too_small',
|
|
33
|
+
screen_width: TTY::Screen.width,
|
|
34
|
+
minimum_width: minimum_width
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @!visibility private
|
|
40
|
+
def execute!
|
|
41
|
+
puts dashboards.map { |dashboard|
|
|
42
|
+
dashboard.to_s(
|
|
43
|
+
column_widths: column_widths[0...show_columns],
|
|
44
|
+
rows_ordered_by: lambda { |row|
|
|
45
|
+
series = row[0]
|
|
46
|
+
data = row[1..]
|
|
47
|
+
# Sort by the series type [Expenses/Income/etc], then by 'consistency',
|
|
48
|
+
# then by total amount
|
|
49
|
+
[series.split(':')[1], data.count(&:nil?), data.compact.sum * -1]
|
|
50
|
+
},
|
|
51
|
+
# Hide rows without any data
|
|
52
|
+
show_row: ->(row) { !row[1..].all?(&:nil?) }
|
|
53
|
+
)
|
|
54
|
+
}.join("\n\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @!visibility private
|
|
58
|
+
def self.cashflow_grid_files
|
|
59
|
+
Dir.glob RVGP.app.config.build_path('grids/*-cashflow-*.csv')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @!visibility private
|
|
63
|
+
def self.grids_by_targetname
|
|
64
|
+
@grids_by_targetname ||= cashflow_grid_files.each_with_object({}) do |file, sum|
|
|
65
|
+
unless /([^-]+)\.csv\Z/.match file
|
|
66
|
+
raise StandardError, I18n.t('commands.cashflow.errors.unrecognized_path', file: file)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
tablename = ::Regexp.last_match(1).capitalize
|
|
70
|
+
|
|
71
|
+
sum[tablename] ||= []
|
|
72
|
+
sum[tablename] << file
|
|
73
|
+
|
|
74
|
+
sum
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def dashboards
|
|
81
|
+
@dashboards ||= targets.map do |target|
|
|
82
|
+
RVGP::Dashboard.new(
|
|
83
|
+
target.name,
|
|
84
|
+
RVGP::Utilities::GridQuery.new(
|
|
85
|
+
self.class.grids_by_targetname[target.name],
|
|
86
|
+
store_cell: lambda { |cell|
|
|
87
|
+
cell ? RVGP::Journal::Commodity.from_symbol_and_amount('$', cell) : cell
|
|
88
|
+
},
|
|
89
|
+
select_columns: lambda { |col, column|
|
|
90
|
+
if options.key?(:date)
|
|
91
|
+
Date.strptime(col, '%m-%y') <= options[:date]
|
|
92
|
+
else
|
|
93
|
+
column.any? { |cell| !cell.nil? }
|
|
94
|
+
end
|
|
95
|
+
}
|
|
96
|
+
),
|
|
97
|
+
{ pastel: RVGP.pastel,
|
|
98
|
+
series_column_label: I18n.t('commands.cashflow.account'),
|
|
99
|
+
format_data_cell: ->(cell) { cell&.to_s commatize: true, precision: 2 },
|
|
100
|
+
columns_ordered_by: ->(a, b) { [b, a].map { |d| Date.strptime d, '%m-%y' }.reduce :<=> },
|
|
101
|
+
summaries: [
|
|
102
|
+
{
|
|
103
|
+
label: I18n.t('commands.cashflow.expenses'),
|
|
104
|
+
prettify: ->(row) { [RVGP.pastel.bold(row[0])] + row[1..].map { |s| RVGP.pastel.red(s) } },
|
|
105
|
+
contents: ->(series, data) { sum_column 'Expenses', series, data }
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
label: I18n.t('commands.cashflow.income'),
|
|
109
|
+
prettify: ->(row) { [RVGP.pastel.bold(row[0])] + row[1..].map { |s| RVGP.pastel.green(s) } },
|
|
110
|
+
contents: ->(series, data) { sum_column 'Income', series, data }
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
label: I18n.t('commands.cashflow.cash_flow'),
|
|
114
|
+
prettify: lambda { |row|
|
|
115
|
+
[RVGP.pastel.bold(row[0])] + row[1..].map do |cell|
|
|
116
|
+
/\$ *-/.match(cell) ? RVGP.pastel.red(cell) : RVGP.pastel.green(cell)
|
|
117
|
+
end
|
|
118
|
+
},
|
|
119
|
+
contents: lambda { |series, data|
|
|
120
|
+
%w[Expenses Income].map { |s| sum_column s, series, data }.sum.invert!
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
] }
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def column_widths
|
|
129
|
+
# We want every table being displayed, to have the same column widths.
|
|
130
|
+
# probably we can move most of this code into a Dashboard class method. But, no
|
|
131
|
+
# rush on that.
|
|
132
|
+
@column_widths ||= dashboards.map(&:column_data_widths)
|
|
133
|
+
.inject([]) do |sum, widths|
|
|
134
|
+
widths.map.with_index { |w, i| sum[i].nil? || sum[i] < w ? w : sum[i] }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def show_columns
|
|
139
|
+
return @show_columns if @show_columns
|
|
140
|
+
|
|
141
|
+
# Now let's calculate how many columns fit on screen:
|
|
142
|
+
@show_columns = 0
|
|
143
|
+
0.upto(column_widths.length - 1) do |i|
|
|
144
|
+
break if RVGP::Dashboard.table_width_given_column_widths(column_widths[0..i]) > TTY::Screen.width
|
|
145
|
+
|
|
146
|
+
@show_columns += 1
|
|
147
|
+
end
|
|
148
|
+
@show_columns
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def sum_column(for_series, series, data)
|
|
152
|
+
# NOTE: This for_series determination is a bit 'magic' and specific to our
|
|
153
|
+
# current accounting categorization taxonomy
|
|
154
|
+
0.upto(series.length - 1).map do |i|
|
|
155
|
+
series[i].split(':')[1] == for_series && data[i] ? data[i] : '$0.00'.to_commodity
|
|
156
|
+
end.compact.sum
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RVGP
|
|
4
|
+
module Commands
|
|
5
|
+
# @!visibility private
|
|
6
|
+
# This class contains the handling of the 'grid' command and task. This
|
|
7
|
+
# code provides the list of grids that are available in the application, and
|
|
8
|
+
# dispatches requests to build these grids.
|
|
9
|
+
class Grid < RVGP::Base::Command
|
|
10
|
+
accepts_options OPTION_ALL, OPTION_LIST
|
|
11
|
+
|
|
12
|
+
include RakeTask
|
|
13
|
+
rake_tasks :grid
|
|
14
|
+
|
|
15
|
+
# @!visibility private
|
|
16
|
+
def execute!(&block)
|
|
17
|
+
RVGP.app.ensure_build_dir! 'grids'
|
|
18
|
+
super(&block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @!visibility private
|
|
22
|
+
# This class represents a grid, available for building. In addition, the #.all
|
|
23
|
+
# method, returns the list of available targets.
|
|
24
|
+
class Target < RVGP::Base::Command::Target
|
|
25
|
+
# @!visibility private
|
|
26
|
+
def initialize(grid_klass, starting_at, ending_at)
|
|
27
|
+
@starting_at = starting_at
|
|
28
|
+
@ending_at = ending_at
|
|
29
|
+
@grid_klass = grid_klass
|
|
30
|
+
super [year, grid_klass.name.tr('_', '-')].join('-'), grid_klass.status_name(year)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @!visibility private
|
|
34
|
+
def description
|
|
35
|
+
I18n.t 'commands.grid.target_description', description: @grid_klass.description, year: year
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @!visibility private
|
|
39
|
+
def uptodate?
|
|
40
|
+
@grid_klass.uptodate? year
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @!visibility private
|
|
44
|
+
def execute(_options)
|
|
45
|
+
@grid_klass.new(@starting_at, @ending_at).to_file!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @!visibility private
|
|
49
|
+
def self.all
|
|
50
|
+
starting_at = RVGP.app.config.grid_starting_at
|
|
51
|
+
ending_at = RVGP.app.config.grid_ending_at
|
|
52
|
+
|
|
53
|
+
starting_at.year.upto(ending_at.year).map do |y|
|
|
54
|
+
RVGP.grids.classes.map do |klass|
|
|
55
|
+
new klass,
|
|
56
|
+
y == starting_at.year ? starting_at : Date.new(y, 1, 1),
|
|
57
|
+
y == ending_at.year ? ending_at : Date.new(y, 12, 31)
|
|
58
|
+
end
|
|
59
|
+
end.flatten
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def year
|
|
65
|
+
@starting_at.year
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module RVGP
|
|
6
|
+
module Commands
|
|
7
|
+
# @!visibility private
|
|
8
|
+
# This class contains the handling of the 'ireconcile' command. Note that
|
|
9
|
+
# there is no rake integration in this command, as that function is irrelevent
|
|
10
|
+
# to the notion of an 'export'.
|
|
11
|
+
class Ireconcile < RVGP::Base::Command
|
|
12
|
+
accepts_options OPTION_ALL, OPTION_LIST, %i[vsplit v]
|
|
13
|
+
|
|
14
|
+
# There's a bug here where we scroll to the top of the file sometimes, on
|
|
15
|
+
# reload. Not sure what to do about that...
|
|
16
|
+
# @!visibility private
|
|
17
|
+
VIMSCRIPT_HEADER = <<-VIMSCRIPT
|
|
18
|
+
let $LANG='en_US.utf-8'
|
|
19
|
+
|
|
20
|
+
function ReloadIfChanged(timer)
|
|
21
|
+
checktime
|
|
22
|
+
endfunction
|
|
23
|
+
|
|
24
|
+
function ExecuteReconcile()
|
|
25
|
+
let reconcile_path = expand("%%")
|
|
26
|
+
let output_path = tempname()
|
|
27
|
+
let pager = '/bin/less'
|
|
28
|
+
if len($PAGER) > 0
|
|
29
|
+
pager = $PAGER
|
|
30
|
+
endif
|
|
31
|
+
|
|
32
|
+
execute('!%<rvgp_path>s reconcile --concise ' .
|
|
33
|
+
\\ shellescape(reconcile_path, 1) .
|
|
34
|
+
\\ ' 2>' . shellescape(output_path, 1) .
|
|
35
|
+
\\ ' > ' . shellescape(output_path, 1))
|
|
36
|
+
if v:shell_error
|
|
37
|
+
echoerr "The following error(s) occurred during reconciliation:"
|
|
38
|
+
execute '!' . pager . ' -r ' . shellescape(output_path, 1)
|
|
39
|
+
redraw!
|
|
40
|
+
endif
|
|
41
|
+
silent execute('!rm '. shellescape(output_path,1))
|
|
42
|
+
endfunction
|
|
43
|
+
VIMSCRIPT
|
|
44
|
+
|
|
45
|
+
# @!visibility private
|
|
46
|
+
def initialize(*args)
|
|
47
|
+
super(*args)
|
|
48
|
+
|
|
49
|
+
unless /vim?\Z/.match ENV.fetch('EDITOR')
|
|
50
|
+
@errors << I18n.t('commands.ireconcile.errors.unsupported_editor', editor: ENV['EDITOR'].inspect)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @!visibility private
|
|
55
|
+
def execute!
|
|
56
|
+
Tempfile.create 'ireconcile.vim' do |file|
|
|
57
|
+
file.write [format(VIMSCRIPT_HEADER, rvgp_path: $PROGRAM_NAME),
|
|
58
|
+
targets.map { |target| target.to_vimscript options[:vsplit] }.join("\ntabnew\n")].join
|
|
59
|
+
|
|
60
|
+
file.close
|
|
61
|
+
|
|
62
|
+
system [ENV.fetch('EDITOR'), '-S', file.path].join(' ')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @!visibility private
|
|
67
|
+
# This class represents a reconciler. See RVGP::Base::Command::ReconcilerTarget, for
|
|
68
|
+
# most of the logic that this class inherits. Typically, these targets take the form
|
|
69
|
+
# of "#\\{year}-#\\{reconciler_name}"
|
|
70
|
+
class Target < RVGP::Base::Command::ReconcilerTarget
|
|
71
|
+
# @!visibility private
|
|
72
|
+
VIMSCRIPT_TEMPLATE = <<-VIMSCRIPT
|
|
73
|
+
edit %<output_file>s
|
|
74
|
+
setl autoread
|
|
75
|
+
autocmd VimEnter * let timer=timer_start(1000,'ReloadIfChanged', {'repeat': -1} )
|
|
76
|
+
call feedkeys("lh")
|
|
77
|
+
setl nomodifiable
|
|
78
|
+
%<split>s
|
|
79
|
+
edit %<input_file>s
|
|
80
|
+
autocmd BufWritePost * silent call ExecuteReconcile()
|
|
81
|
+
VIMSCRIPT
|
|
82
|
+
|
|
83
|
+
# @!visibility private
|
|
84
|
+
def to_vimscript(is_vsplit)
|
|
85
|
+
# NOTE: I guess we don't need to escape these paths, so long as there arent
|
|
86
|
+
# any \n's in the path name... I guess
|
|
87
|
+
format(VIMSCRIPT_TEMPLATE,
|
|
88
|
+
output_file: @reconciler.output_file,
|
|
89
|
+
input_file: @reconciler.file,
|
|
90
|
+
split: is_vsplit ? 'vsplit' : 'split')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|