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,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../fakers/fake_feed'
|
|
4
|
+
require_relative '../fakers/fake_reconciler'
|
|
5
|
+
|
|
6
|
+
module RVGP
|
|
7
|
+
module Commands
|
|
8
|
+
# @!visibility private
|
|
9
|
+
# This class handles the request to create a new RVGP project.
|
|
10
|
+
class NewProject < RVGP::Base::Command
|
|
11
|
+
# @!visibility private
|
|
12
|
+
PROJECT_FILE = <<~END_OF_PROJECT_FILE
|
|
13
|
+
# vim:filetype=ledger
|
|
14
|
+
|
|
15
|
+
# Unautomated journals:
|
|
16
|
+
include journals/*.journal
|
|
17
|
+
|
|
18
|
+
# Reconciled journals:
|
|
19
|
+
include build/journals/*.journal
|
|
20
|
+
|
|
21
|
+
# Local Variables:
|
|
22
|
+
# project_name: "%<project_name>s"
|
|
23
|
+
# End:
|
|
24
|
+
END_OF_PROJECT_FILE
|
|
25
|
+
|
|
26
|
+
# @!visibility private
|
|
27
|
+
OPENING_BALANCES_FILE = <<~END_OF_BALANCES_FILE
|
|
28
|
+
2017/12/31 Opening Balances
|
|
29
|
+
Personal:Liabilities:AmericanExpress %<liabilities>s
|
|
30
|
+
Personal:Equity:Opening Balances:AmericanExpress
|
|
31
|
+
|
|
32
|
+
2017/12/31 Opening Balances
|
|
33
|
+
Personal:Assets:AcmeBank:Checking %<assets>s
|
|
34
|
+
Personal:Equity:Opening Balances:AcmeBank
|
|
35
|
+
END_OF_BALANCES_FILE
|
|
36
|
+
|
|
37
|
+
# @!visibility private
|
|
38
|
+
YEARS_IN_NEW_PROJECT = 5
|
|
39
|
+
|
|
40
|
+
attr_reader :errors, :app_dir, :project_name
|
|
41
|
+
|
|
42
|
+
# @!visibility private
|
|
43
|
+
# We don't call super, mostly because this command needs to run in absence
|
|
44
|
+
# of an initialized project directory. This makes this command unique
|
|
45
|
+
# amongst the rest of the commands....
|
|
46
|
+
def initialize(*args) # rubocop:disable Lint/MissingSuper
|
|
47
|
+
@errors = []
|
|
48
|
+
@app_dir = args.first
|
|
49
|
+
@errors << I18n.t('commands.new_project.errors.missing_app_dir') unless @app_dir && !@app_dir.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @!visibility private
|
|
53
|
+
def execute!
|
|
54
|
+
confirm_operation = I18n.t('commands.new_project.confirm_operation')
|
|
55
|
+
# Let's make sure we don't accidently overwrite anything
|
|
56
|
+
if File.directory? app_dir
|
|
57
|
+
print [RVGP.pastel.yellow(I18n.t('error.warning')),
|
|
58
|
+
I18n.t('commands.new_project.directory_exists_prompt', dir: app_dir)].join(' : ')
|
|
59
|
+
if $stdin.gets.chomp != confirm_operation
|
|
60
|
+
puts [RVGP.pastel.red(I18n.t('error.error')),
|
|
61
|
+
I18n.t('commands.new_project.operation_aborted')].join(' : ')
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Let's get the project name
|
|
67
|
+
@project_name = nil
|
|
68
|
+
loop do
|
|
69
|
+
print I18n.t('commands.new_project.project_name_prompt')
|
|
70
|
+
@project_name = $stdin.gets.chomp
|
|
71
|
+
unless @project_name.empty?
|
|
72
|
+
print I18n.t('commands.new_project.project_name_confirmation', project_name: @project_name)
|
|
73
|
+
break if $stdin.gets.chomp == confirm_operation
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
logger = RVGP::Application::StatusOutputRake.new pastel: RVGP.pastel
|
|
78
|
+
%i[project_directory bank_feeds reconcilers].each do |step|
|
|
79
|
+
logger.info self.class.name, I18n.t(format('commands.new_project.initialize.%s', step)) do
|
|
80
|
+
send format('initialize_%s', step).to_sym
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
puts I18n.t('commands.new_project.completed_banner', journal_path: project_journal_path)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def initialize_project_directory
|
|
90
|
+
@warnings = []
|
|
91
|
+
# Create the directory:
|
|
92
|
+
if Dir.exist? app_dir
|
|
93
|
+
@warnings << [I18n.t('commands.new_project.errors.directory_exists', dir: app_dir)]
|
|
94
|
+
else
|
|
95
|
+
Dir.mkdir app_dir
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Create the sub directories:
|
|
99
|
+
%w[build feeds].each do |dir|
|
|
100
|
+
full_dir = [app_dir, dir].join('/')
|
|
101
|
+
if Dir.exist? full_dir
|
|
102
|
+
@warnings << [I18n.t('commands.new_project.errors.directory_exists', dir: full_dir)]
|
|
103
|
+
else
|
|
104
|
+
Dir.mkdir full_dir
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Dir.glob(RVGP::Gem.root('resources/skel/*')) do |filename|
|
|
109
|
+
FileUtils.cp_r filename, app_dir
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# These are the app subdirectories...
|
|
113
|
+
%w[commands grids plots reconcilers validations].each do |dir|
|
|
114
|
+
full_dir = [app_dir, 'app', dir].join('/')
|
|
115
|
+
next if Dir.exist? full_dir
|
|
116
|
+
|
|
117
|
+
Dir.mkdir full_dir
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Main project journal:
|
|
121
|
+
File.write project_journal_path,
|
|
122
|
+
format(PROJECT_FILE, project_name: @project_name.gsub('"', '\\"'))
|
|
123
|
+
|
|
124
|
+
# Opening Balances journal:
|
|
125
|
+
File.write destination_path('%<app_dir>s/journals/opening-balances.journal'),
|
|
126
|
+
format(OPENING_BALANCES_FILE,
|
|
127
|
+
liabilities: liabilities_at_month(-1).invert!.to_s(precision: 2),
|
|
128
|
+
assets: assets_at_month(-1).to_s(precision: 2))
|
|
129
|
+
|
|
130
|
+
{ warnings: @warnings, errors: [] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def initialize_bank_feeds
|
|
134
|
+
each_year_in_project do |year|
|
|
135
|
+
File.write destination_path('%<app_dir>s/feeds/%<year>d-personal-basic-checking.csv', year: year),
|
|
136
|
+
bank_feed(year)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
{ warnings: [], errors: [] }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def initialize_reconcilers
|
|
143
|
+
each_year_in_project do |year|
|
|
144
|
+
File.write destination_path('%<app_dir>s/app/reconcilers/%<year>d-personal-basic-checking.yml',
|
|
145
|
+
year: year),
|
|
146
|
+
RVGP::Fakers::FakeReconciler.basic_checking(
|
|
147
|
+
label: format('Personal AcmeBank:Checking (%<year>s)', year: year),
|
|
148
|
+
input_path: format('%<year>d-personal-basic-checking.csv', year: year),
|
|
149
|
+
output_path: format('%<year>d-personal-basic-checking.journal', year: year),
|
|
150
|
+
format_path: 'config/csv-format-acme-checking.yml',
|
|
151
|
+
income: [{ match: '/\AAmerican Express/', to: 'Personal:Liabilities:AmericanExpress' }] +
|
|
152
|
+
income_companies.map do |company|
|
|
153
|
+
{ 'match' => format('/%s/', company),
|
|
154
|
+
'to' => format('Personal:Income:%s', company.tr('^a-zA-Z0-9', '')) }
|
|
155
|
+
end,
|
|
156
|
+
expense: [{ match: '/\AAmerican Express/', to: 'Personal:Liabilities:AmericanExpress' }] +
|
|
157
|
+
expense_companies.map do |company|
|
|
158
|
+
# I don't know how else to explain these asset mitigating events. lol
|
|
159
|
+
{ match: format('/%s/', company), to: 'Personal:Expenses:Vices:Gambling' }
|
|
160
|
+
end +
|
|
161
|
+
monthly_expenses.keys.map do |category|
|
|
162
|
+
{ match: format('/%s/', company_for(category)), to: category }
|
|
163
|
+
end
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
{ warnings: [], errors: [] }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def income_companies
|
|
171
|
+
@income_companies ||= [Faker::Company.name]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def expense_companies
|
|
175
|
+
@expense_companies ||= [Faker::Company.name]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def company_for(category)
|
|
179
|
+
@company_for ||= {}
|
|
180
|
+
@company_for[category] ||= Faker::Company.name
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def monthly_expenses
|
|
184
|
+
@monthly_expenses ||= {}.merge(
|
|
185
|
+
# Rents go up every year:
|
|
186
|
+
{
|
|
187
|
+
'Personal:Expenses:Rent' => (0...num_months_in_project).map do |i|
|
|
188
|
+
marginal_rent = RVGP::Journal::Commodity.from_symbol_and_amount '$', (50 * (i / 12).floor)
|
|
189
|
+
'$ 1800.00'.to_commodity + marginal_rent
|
|
190
|
+
end
|
|
191
|
+
},
|
|
192
|
+
# Fixed monthly costs:
|
|
193
|
+
{
|
|
194
|
+
'Personal:Expenses:Gym': '$ 102.00',
|
|
195
|
+
'Personal:Expenses:Phone': '$ 86.00'
|
|
196
|
+
}.to_h do |cat, amnt|
|
|
197
|
+
[cat.to_s, [amnt.to_commodity] * num_months_in_project]
|
|
198
|
+
end,
|
|
199
|
+
# Random-ish monthly Costs:
|
|
200
|
+
{
|
|
201
|
+
'Personal:Expenses:Food:Restaurants': { mean: 450, standard_deviation: 100 },
|
|
202
|
+
'Personal:Expenses:Food:Groceries': { mean: 750, standard_deviation: 150 },
|
|
203
|
+
'Personal:Expenses:DrugStores': { mean: 70, standard_deviation: 30 },
|
|
204
|
+
'Personal:Expenses:Department Stores': { mean: 100, standard_deviation: 80 },
|
|
205
|
+
'Personal:Expenses:Entertainment': { mean: 150, standard_deviation: 30 },
|
|
206
|
+
'Personal:Expenses:Dating': { mean: 250, standard_deviation: 100 },
|
|
207
|
+
'Personal:Expenses:Hobbies': { mean: 400, standard_deviation: 150 }
|
|
208
|
+
}.to_h do |cat, num_opts|
|
|
209
|
+
[cat.to_s,
|
|
210
|
+
num_months_in_project.times.map do
|
|
211
|
+
RVGP::Journal::Commodity.from_symbol_and_amount '$', Faker::Number.normal(**num_opts).abs
|
|
212
|
+
end]
|
|
213
|
+
end,
|
|
214
|
+
# 'Some months' Have these expenses.
|
|
215
|
+
{
|
|
216
|
+
'Personal:Expenses:Barber': { true_ratio: 0.75, mean: 50, standard_deviation: 10 },
|
|
217
|
+
'Personal:Expenses:Charity': { true_ratio: 0.5, mean: 200, standard_deviation: 100 },
|
|
218
|
+
'Personal:Expenses:Clothes': { true_ratio: 0.25, mean: 200, standard_deviation: 50 },
|
|
219
|
+
'Personal:Expenses:Cooking Supplies': { true_ratio: 0.25, mean: 100, standard_deviation: 50 },
|
|
220
|
+
'Personal:Expenses:Books': { true_ratio: 0.75, mean: 60, standard_deviation: 20 },
|
|
221
|
+
'Personal:Expenses:Health:Dental': { true_ratio: 0.125, mean: 300, standard_deviation: 50 },
|
|
222
|
+
'Personal:Expenses:Health:Doctor': { true_ratio: 0.0833, mean: 200, standard_deviation: 100 },
|
|
223
|
+
'Personal:Expenses:Health:Medications': { true_ratio: 0.0833, mean: 40, standard_deviation: 50 },
|
|
224
|
+
'Personal:Expenses:Home:Improvement': { true_ratio: 0.125, mean: 200, standard_deviation: 50 }
|
|
225
|
+
}.map do |cat, opts|
|
|
226
|
+
next unless Faker::Boolean.boolean true_ratio: opts.delete(:true_ratio)
|
|
227
|
+
|
|
228
|
+
[cat.to_s,
|
|
229
|
+
num_months_in_project.times.map do
|
|
230
|
+
RVGP::Journal::Commodity.from_symbol_and_amount '$', Faker::Number.normal(**opts).abs
|
|
231
|
+
end]
|
|
232
|
+
end.compact.to_h
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def bank_feed(year)
|
|
237
|
+
@bank_feed ||= CSV.parse RVGP::Fakers::FakeFeed.personal_checking(
|
|
238
|
+
from: project_starts_on,
|
|
239
|
+
to: today,
|
|
240
|
+
expense_sources: expense_companies,
|
|
241
|
+
income_sources: income_companies,
|
|
242
|
+
opening_liability_balance: liabilities_at_month(-1),
|
|
243
|
+
opening_asset_balance: assets_at_month(-1),
|
|
244
|
+
monthly_expenses: monthly_expenses.transform_keys { |cat| company_for cat },
|
|
245
|
+
liability_sources: ['American Express'],
|
|
246
|
+
liabilities_by_month: (0...num_months_in_project).map { |i| liabilities_at_month i },
|
|
247
|
+
assets_by_month: (0...num_months_in_project).map { |i| assets_at_month i }
|
|
248
|
+
), headers: true
|
|
249
|
+
|
|
250
|
+
CSV.generate headers: @bank_feed.headers, write_headers: true do |csv|
|
|
251
|
+
@bank_feed.each { |row| csv << row if Date.strptime(row['Date'], '%m/%d/%Y').year == year }
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def today
|
|
256
|
+
@today ||= Date.today
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def project_starts_on
|
|
260
|
+
@project_starts_on ||= Date.new(today.year - YEARS_IN_NEW_PROJECT, 1, 1)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def num_months_in_project
|
|
264
|
+
@num_months_in_project ||= ((today.year * 12) + today.month) -
|
|
265
|
+
((project_starts_on.year * 12) +
|
|
266
|
+
project_starts_on.month) + 1
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def each_year_in_project(&block)
|
|
270
|
+
today.year.downto(today.year - YEARS_IN_NEW_PROJECT).each(&block)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def destination_path(path, params = {})
|
|
274
|
+
params[:app_dir] ||= app_dir
|
|
275
|
+
format path, params
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def liabilities_at_month(num)
|
|
279
|
+
# I played with this until it offered a nice contrast with the assets curve
|
|
280
|
+
RVGP::Journal::Commodity.from_symbol_and_amount('$',
|
|
281
|
+
(Math.sin((num.to_f + 40) / 24) * 30_000) + 30_000)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def assets_at_month(num)
|
|
285
|
+
# This just happened to be an interesting graph... to me:
|
|
286
|
+
RVGP::Journal::Commodity.from_symbol_and_amount('$',
|
|
287
|
+
(Math.sin((num.to_f - 32) / 20) * 40_000) + 50_000)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def project_journal_path
|
|
291
|
+
destination_path '%<app_dir>s/%<project_name>s.journal',
|
|
292
|
+
project_name: project_name.downcase.tr(' ', '-')
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../plot'
|
|
4
|
+
|
|
5
|
+
module RVGP
|
|
6
|
+
module Commands
|
|
7
|
+
# @!visibility private
|
|
8
|
+
# This class contains the handling of the 'plot' command and task.
|
|
9
|
+
class Plot < RVGP::Base::Command
|
|
10
|
+
accepts_options OPTION_ALL, OPTION_LIST, %i[stdout s]
|
|
11
|
+
|
|
12
|
+
include RakeTask
|
|
13
|
+
rake_tasks :plot
|
|
14
|
+
|
|
15
|
+
# @!visibility private
|
|
16
|
+
def execute!
|
|
17
|
+
RVGP.app.ensure_build_dir! 'plots' unless options[:stdout]
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# This class represents a plot, available for building. And dispatches a build request.
|
|
22
|
+
# Typically, the name of a plot takes the form of "#\\{year}-#\\{plotname}". See
|
|
23
|
+
# RVGP::Base::Command::PlotTarget, from which this class inherits, for a better
|
|
24
|
+
# representation of how this class works.
|
|
25
|
+
# @!visibility private
|
|
26
|
+
class Target < RVGP::Base::Command::PlotTarget
|
|
27
|
+
# @!visibility private
|
|
28
|
+
def execute(options)
|
|
29
|
+
if options[:stdout]
|
|
30
|
+
puts plot.script(name)
|
|
31
|
+
else
|
|
32
|
+
RVGP.app.ensure_build_dir! 'plots'
|
|
33
|
+
plot.write!(name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../plot'
|
|
4
|
+
require_relative '../plot/google-drive/sheet'
|
|
5
|
+
require_relative '../plot/google-drive/output_google_sheets'
|
|
6
|
+
require_relative '../plot/google-drive/output_csv'
|
|
7
|
+
|
|
8
|
+
module RVGP
|
|
9
|
+
module Commands
|
|
10
|
+
# @!visibility private
|
|
11
|
+
# This class contains the handling of the 'publish_gsheets' command. This class
|
|
12
|
+
# works very similar to the RVGP::Commands:Plot command. Note that there is no
|
|
13
|
+
# rake integration in this command, as that function is irrelevent to the notion
|
|
14
|
+
# of an 'export'.
|
|
15
|
+
class PublishGsheets < RVGP::Base::Command
|
|
16
|
+
# @!visibility private
|
|
17
|
+
DEFAULT_SLEEP_BETWEEN_SHEETS = 5
|
|
18
|
+
|
|
19
|
+
accepts_options OPTION_ALL,
|
|
20
|
+
OPTION_LIST,
|
|
21
|
+
[:csvdir, :c, { has_value: 'DIRECTORY' }],
|
|
22
|
+
[:title, :t, { has_value: 'TITLE' }],
|
|
23
|
+
[:sleep, :s, { has_value: 'N' }]
|
|
24
|
+
|
|
25
|
+
# @!visibility private
|
|
26
|
+
# This class represents a Google 'sheet', built from a Plot, available for
|
|
27
|
+
# export to google. And dispatches a build request. Typically, the name of
|
|
28
|
+
# a sheet is identical to the name of its corresponding plot. And, takes
|
|
29
|
+
# the form of "#\\{year}-#\\{plotname}". See RVGP::Base::Command::PlotTarget, from
|
|
30
|
+
# which this class inherits, for a better representation of how this class
|
|
31
|
+
# works.
|
|
32
|
+
class Target < RVGP::Base::Command::PlotTarget
|
|
33
|
+
# @!visibility private
|
|
34
|
+
def to_sheet
|
|
35
|
+
RVGP::Plot::GoogleDrive::Sheet.new plot.title(name), plot.grid(name), { google: plot.google_options || {} }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @!visibility private
|
|
40
|
+
def initialize(*args)
|
|
41
|
+
super(*args)
|
|
42
|
+
|
|
43
|
+
options[:title] ||= 'RVGP Finance Report %m/%d/%y %H:%M'
|
|
44
|
+
options[:sleep] = options.key?(:sleep) ? options[:sleep].to_i : DEFAULT_SLEEP_BETWEEN_SHEETS
|
|
45
|
+
|
|
46
|
+
if options.key? :csvdir
|
|
47
|
+
unless File.writable? options[:csvdir]
|
|
48
|
+
@errors << I18n.t('commands.publish_gsheets.errors.unable_to_write_to_csvdir', csvdir: options[:csvdir])
|
|
49
|
+
end
|
|
50
|
+
else
|
|
51
|
+
@secrets_path = RVGP.app.config.project_path('config/google-secrets.yml')
|
|
52
|
+
|
|
53
|
+
unless File.readable?(@secrets_path)
|
|
54
|
+
@errors << I18n.t('commands.publish_gsheets.errors.missing_google_secrets')
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @!visibility private
|
|
60
|
+
def execute!
|
|
61
|
+
output = if options.key?(:csvdir)
|
|
62
|
+
RVGP::Plot::GoogleDrive::ExportLocalCsvs.new(destination: options[:csvdir], format: 'csv')
|
|
63
|
+
else
|
|
64
|
+
RVGP::Plot::GoogleDrive::ExportSheets.new(format: 'google_sheets',
|
|
65
|
+
title: options[:title],
|
|
66
|
+
secrets_file: @secrets_path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
targets.each do |target|
|
|
70
|
+
RVGP.app.logger.info self.class.name, target.name do
|
|
71
|
+
output.sheet target.to_sheet
|
|
72
|
+
|
|
73
|
+
# NOTE: This should fix the complaints that google issues, from too many
|
|
74
|
+
# requests per second.
|
|
75
|
+
sleep options[:sleep] if output.is_a? RVGP::Plot::GoogleDrive::ExportSheets
|
|
76
|
+
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RVGP
|
|
4
|
+
module Commands
|
|
5
|
+
# @!visibility private
|
|
6
|
+
# This class contains the dispatch logic of the 'reconcile' command and task.
|
|
7
|
+
class Reconcile < RVGP::Base::Command
|
|
8
|
+
accepts_options OPTION_ALL, OPTION_LIST, %i[stdout s], %i[concise c]
|
|
9
|
+
|
|
10
|
+
include RakeTask
|
|
11
|
+
rake_tasks :reconcile
|
|
12
|
+
|
|
13
|
+
# @!visibility private
|
|
14
|
+
def initialize(*args)
|
|
15
|
+
super(*args)
|
|
16
|
+
|
|
17
|
+
if %i[stdout concise].all? { |output| options[output] }
|
|
18
|
+
@errors << I18n.t('commands.reconcile.errors.either_concise_or_stdout')
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @!visibility private
|
|
23
|
+
def execute!
|
|
24
|
+
RVGP.app.ensure_build_dir! 'journals' unless options[:stdout]
|
|
25
|
+
options[:stdout] || options[:concise] ? execute_each_target : super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @!visibility private
|
|
29
|
+
# This class represents a reconciler. See RVGP::Base::Command::ReconcilerTarget, for
|
|
30
|
+
# most of the logic that this class inherits. Typically, these targets take the form
|
|
31
|
+
# of "#\\{year}-#\\{reconciler_name}"
|
|
32
|
+
class Target < RVGP::Base::Command::ReconcilerTarget
|
|
33
|
+
for_command :reconcile
|
|
34
|
+
|
|
35
|
+
# @!visibility private
|
|
36
|
+
def description
|
|
37
|
+
I18n.t 'commands.reconcile.target_description', input_file: @reconciler.input_file
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @!visibility private
|
|
41
|
+
def uptodate?
|
|
42
|
+
@reconciler.uptodate?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @!visibility private
|
|
46
|
+
def execute(options)
|
|
47
|
+
if options[:stdout]
|
|
48
|
+
puts @reconciler.to_ledger
|
|
49
|
+
else
|
|
50
|
+
@reconciler.to_ledger!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
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 'rotate_year' 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 RotateYear < RVGP::Base::Command
|
|
12
|
+
accepts_options OPTION_ALL, OPTION_LIST
|
|
13
|
+
|
|
14
|
+
# @!visibility private
|
|
15
|
+
def execute!
|
|
16
|
+
puts I18n.t('commands.rotate_year.operations_header')
|
|
17
|
+
|
|
18
|
+
operations = []
|
|
19
|
+
|
|
20
|
+
unless File.directory? RotateYear.historical_path
|
|
21
|
+
operations << I18n.t('commands.rotate_year.operation_mkdir', path: RotateYear.historical_path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
operations += targets.map(&:operation_descriptions).flatten
|
|
25
|
+
|
|
26
|
+
operations.each do |operation|
|
|
27
|
+
puts I18n.t('commands.rotate_year.operation_element', operation: operation)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
print I18n.t('commands.rotate_year.confirm_operation_prompt')
|
|
31
|
+
|
|
32
|
+
if $stdin.gets.chomp != I18n.t('commands.rotate_year.confirm_operation')
|
|
33
|
+
puts [RVGP.pastel.red(I18n.t('error.error')), I18n.t('commands.rotate_year.operation_aborted')].join(' : ')
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
RVGP.app.ensure_build_dir! 'feeds/historical'
|
|
38
|
+
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @!visibility private
|
|
43
|
+
# This method returns the full path to the historical feed directory
|
|
44
|
+
def self.historical_path
|
|
45
|
+
RVGP.app.config.project_path('feeds/historical')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @!visibility private
|
|
49
|
+
# This class represents a reconciler that's not 'historical'. Which, makes it different from the
|
|
50
|
+
# ReconcilerTarget. 'historical' is determined by whether its input_file is located in a '/historical/' basedir.
|
|
51
|
+
class Target < RVGP::Base::Command::Target
|
|
52
|
+
attr_reader :reconciler
|
|
53
|
+
|
|
54
|
+
# @!visibility private
|
|
55
|
+
# This is used as a catch in the mv! method
|
|
56
|
+
class GitError < StandardError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Create a new RotateYear::Target
|
|
60
|
+
# @param [RVGP::Base::Reconciler] reconciler An instance of either {RVGP::Reconcilers::CsvReconciler}, or
|
|
61
|
+
# {RVGP::Reconcilers::JournalReconciler}, to use as the basis
|
|
62
|
+
# for this target.
|
|
63
|
+
def initialize(reconciler)
|
|
64
|
+
super reconciler.as_taskname, reconciler.label
|
|
65
|
+
@reconciler = reconciler
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @!visibility private
|
|
69
|
+
def operation_descriptions
|
|
70
|
+
[I18n.t('commands.rotate_year.operation_rotate', name: File.basename(reconciler.file))]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @!visibility private
|
|
74
|
+
def description
|
|
75
|
+
I18n.t 'commands.rotate_year.target_description', basename: File.basename(reconciler.file)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @!visibility private
|
|
79
|
+
def name_parts
|
|
80
|
+
raise StandardError, format('Unable to determine year from %s', name) unless /^(\d+)(.+)/.match name
|
|
81
|
+
|
|
82
|
+
[Regexp.last_match(1).to_i, Regexp.last_match(2)]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @!visibility private
|
|
86
|
+
def year
|
|
87
|
+
name_parts.first
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def git_repo?
|
|
91
|
+
@is_git_repo = begin
|
|
92
|
+
git! 'branch'
|
|
93
|
+
true
|
|
94
|
+
rescue GitError
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @!visibility private
|
|
100
|
+
def git!(*args)
|
|
101
|
+
@git_prefix ||= begin
|
|
102
|
+
output, exit_code = Open3.capture2 'which git'
|
|
103
|
+
raise GitError, output unless exit_code.to_i.zero?
|
|
104
|
+
|
|
105
|
+
[output.chomp, '-C', Shellwords.escape(RVGP.app.project_path)]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
output, exit_code = Open3.capture2((@git_prefix + args.map { |a| Shellwords.escape a }).join(' '))
|
|
109
|
+
|
|
110
|
+
raise GitError, output unless exit_code.to_i.zero?
|
|
111
|
+
|
|
112
|
+
output
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @!visibility private
|
|
116
|
+
# This move will use git, if the source file is in a repo. If not, it'll use the system mv
|
|
117
|
+
def mv!(source, dest)
|
|
118
|
+
raise GitError if !git_repo? || !%r{^#{RVGP.app.project_path}/(.+)}.match(source)
|
|
119
|
+
|
|
120
|
+
project_relative_source = Regexp.last_match 1
|
|
121
|
+
|
|
122
|
+
raise GitError unless /^#{project_relative_source}$/.match(git!('ls-files'))
|
|
123
|
+
|
|
124
|
+
git! 'mv', project_relative_source, dest
|
|
125
|
+
rescue GitError
|
|
126
|
+
FileUtils.mv source, dest
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @!visibility private
|
|
130
|
+
def execute(_)
|
|
131
|
+
historical_feed_path = [File.dirname(reconciler.input_file), 'historical'].join('/')
|
|
132
|
+
rotated_basename = name_parts.tap { |parts| parts[0] += 1 }.join
|
|
133
|
+
|
|
134
|
+
FileUtils.mkdir_p historical_feed_path
|
|
135
|
+
|
|
136
|
+
# TODO: Is any of this working? It's very close. Test.
|
|
137
|
+
mv! reconciler.input_file, historical_feed_path
|
|
138
|
+
|
|
139
|
+
rotated_input_path = format('%<dir>s/%<file>s.%<ext>s',
|
|
140
|
+
dir: File.dirname(reconciler.input_file), file: rotated_basename, ext: 'csv')
|
|
141
|
+
|
|
142
|
+
FileUtils.touch rotated_input_path
|
|
143
|
+
|
|
144
|
+
rotated_reconciler_path = format('%<dir>s/%<basename>s.%<ext>s',
|
|
145
|
+
dir: File.dirname(reconciler.file), basename: rotated_basename, ext: 'yml')
|
|
146
|
+
|
|
147
|
+
File.write rotated_reconciler_path, rotated_reconciler_contents
|
|
148
|
+
|
|
149
|
+
git! 'add', rotated_reconciler_path if git_repo?
|
|
150
|
+
|
|
151
|
+
git! 'add', rotated_input_path if git_repo?
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @!visibility private
|
|
157
|
+
# This method returns a rotated reconciler, based on the contents of the legacy reconciler.
|
|
158
|
+
# Root elements are preserved, but, child elements are not. Income and expense sections are
|
|
159
|
+
# pre-populated with catch-all rules.
|
|
160
|
+
def rotated_reconciler_contents
|
|
161
|
+
elements = File.read(reconciler.file).scan(/^[^\n ].+/)
|
|
162
|
+
from = elements.map { |r| ::Regexp.last_match(1) if /^from:[ \t]*(.+)/.match r }
|
|
163
|
+
.compact.first&.tr('"\'', '')&.split(':')&.first
|
|
164
|
+
|
|
165
|
+
elements.map do |line|
|
|
166
|
+
if /^(expense|income):/.match line
|
|
167
|
+
direction = ::Regexp.last_match(1)
|
|
168
|
+
format("%<direction>s:\n - match: /.*/\n to: %<to>s",
|
|
169
|
+
direction: direction,
|
|
170
|
+
to: [from, direction == 'expense' ? 'Expenses' : 'Income', 'Unknown'].compact.join(':'))
|
|
171
|
+
else
|
|
172
|
+
line
|
|
173
|
+
end
|
|
174
|
+
end.join("\n").tr(year.to_s, (year + 1).to_s)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# All possible Reconciler Targets that the project has defined.
|
|
178
|
+
# @return [Array<RVGP::Base::Command::ReconcilerTarget>] A collection of targets.
|
|
179
|
+
def self.all
|
|
180
|
+
RVGP.app.reconcilers.map do |reconciler|
|
|
181
|
+
new reconciler unless File.dirname(reconciler.input_file).split('/').last == 'historical'
|
|
182
|
+
end.compact
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def rotated_input_file_path
|
|
188
|
+
rotate_path reconciler.input_file
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def rotate_path(path)
|
|
192
|
+
parts = path.split('/')
|
|
193
|
+
filepart = parts.pop
|
|
194
|
+
|
|
195
|
+
return path unless /\A(\d+)(.*)\Z/.match(filepart)
|
|
196
|
+
|
|
197
|
+
(parts + [[::Regexp.last_match(1).to_i + 1, ::Regexp.last_match(2)].join]).join('/')
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|