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.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +23 -0
  4. data/LICENSE +504 -0
  5. data/README.md +223 -0
  6. data/Rakefile +32 -0
  7. data/bin/rvgp +8 -0
  8. data/lib/rvgp/application/config.rb +159 -0
  9. data/lib/rvgp/application/descendant_registry.rb +122 -0
  10. data/lib/rvgp/application/status_output.rb +139 -0
  11. data/lib/rvgp/application.rb +170 -0
  12. data/lib/rvgp/base/command.rb +457 -0
  13. data/lib/rvgp/base/grid.rb +531 -0
  14. data/lib/rvgp/base/reader.rb +29 -0
  15. data/lib/rvgp/base/reconciler.rb +434 -0
  16. data/lib/rvgp/base/validation.rb +261 -0
  17. data/lib/rvgp/commands/cashflow.rb +160 -0
  18. data/lib/rvgp/commands/grid.rb +70 -0
  19. data/lib/rvgp/commands/ireconcile.rb +95 -0
  20. data/lib/rvgp/commands/new_project.rb +296 -0
  21. data/lib/rvgp/commands/plot.rb +41 -0
  22. data/lib/rvgp/commands/publish_gsheets.rb +83 -0
  23. data/lib/rvgp/commands/reconcile.rb +58 -0
  24. data/lib/rvgp/commands/rotate_year.rb +202 -0
  25. data/lib/rvgp/commands/validate_journal.rb +59 -0
  26. data/lib/rvgp/commands/validate_system.rb +44 -0
  27. data/lib/rvgp/commands.rb +160 -0
  28. data/lib/rvgp/dashboard.rb +252 -0
  29. data/lib/rvgp/fakers/fake_feed.rb +245 -0
  30. data/lib/rvgp/fakers/fake_journal.rb +57 -0
  31. data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
  32. data/lib/rvgp/fakers/faker_helpers.rb +25 -0
  33. data/lib/rvgp/gem.rb +80 -0
  34. data/lib/rvgp/journal/commodity.rb +453 -0
  35. data/lib/rvgp/journal/complex_commodity.rb +214 -0
  36. data/lib/rvgp/journal/currency.rb +101 -0
  37. data/lib/rvgp/journal/journal.rb +141 -0
  38. data/lib/rvgp/journal/posting.rb +156 -0
  39. data/lib/rvgp/journal/pricer.rb +267 -0
  40. data/lib/rvgp/journal.rb +24 -0
  41. data/lib/rvgp/plot/gnuplot.rb +478 -0
  42. data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
  43. data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
  44. data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
  45. data/lib/rvgp/plot.rb +293 -0
  46. data/lib/rvgp/pta/hledger.rb +237 -0
  47. data/lib/rvgp/pta/ledger.rb +308 -0
  48. data/lib/rvgp/pta.rb +311 -0
  49. data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
  50. data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
  51. data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
  52. data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
  53. data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
  54. data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
  55. data/lib/rvgp/utilities/grid_query.rb +190 -0
  56. data/lib/rvgp/utilities/yaml.rb +131 -0
  57. data/lib/rvgp/utilities.rb +44 -0
  58. data/lib/rvgp/validations/balance_validation.rb +68 -0
  59. data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
  60. data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
  61. data/lib/rvgp.rb +66 -0
  62. data/resources/README.MD/2022-cashflow-google.png +0 -0
  63. data/resources/README.MD/2022-cashflow.png +0 -0
  64. data/resources/README.MD/all-wealth-growth-google.png +0 -0
  65. data/resources/README.MD/all-wealth-growth.png +0 -0
  66. data/resources/gnuplot/default.yml +80 -0
  67. data/resources/i18n/en.yml +192 -0
  68. data/resources/iso-4217-currencies.json +171 -0
  69. data/resources/skel/Rakefile +5 -0
  70. data/resources/skel/app/grids/cashflow_grid.rb +27 -0
  71. data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
  72. data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
  73. data/resources/skel/app/plots/cashflow.yml +33 -0
  74. data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
  75. data/resources/skel/app/plots/wealth-growth.yml +20 -0
  76. data/resources/skel/config/csv-format-acme-checking.yml +9 -0
  77. data/resources/skel/config/google-secrets.yml +5 -0
  78. data/resources/skel/config/rvgp.yml +0 -0
  79. data/resources/skel/journals/prices.db +0 -0
  80. data/rvgp.gemspec +6 -0
  81. data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
  82. data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
  83. data/test/test_command_base.rb +61 -0
  84. data/test/test_commodity.rb +270 -0
  85. data/test/test_csv_reconciler.rb +60 -0
  86. data/test/test_currency.rb +24 -0
  87. data/test/test_fake_feed.rb +228 -0
  88. data/test/test_fake_journal.rb +98 -0
  89. data/test/test_fake_reconciler.rb +60 -0
  90. data/test/test_journal_parse.rb +545 -0
  91. data/test/test_ledger.rb +102 -0
  92. data/test/test_plot.rb +133 -0
  93. data/test/test_posting.rb +50 -0
  94. data/test/test_pricer.rb +139 -0
  95. data/test/test_pta_adapter.rb +575 -0
  96. data/test/test_utilities.rb +45 -0
  97. metadata +268 -0
@@ -0,0 +1,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