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