rvgp 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
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