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,575 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'csv'
5
+ require 'minitest/autorun'
6
+
7
+ require_relative '../lib/rvgp'
8
+
9
+ [RVGP::Pta::Ledger, RVGP::Pta::HLedger].each do |pta_klass|
10
+ describe pta_klass do
11
+ subject { pta_klass.new }
12
+
13
+ describe "#{pta_klass}#adapter_name" do
14
+ it 'should return the appropriate symbol' do
15
+ value(subject.adapter_name).must_equal subject.is_a?(RVGP::Pta::HLedger) ? :hledger : :ledger
16
+ end
17
+ end
18
+
19
+ describe "#{pta_klass} - #oldest_transaction, #newest_transaction_date, and #{pta_klass}#newest_transaction" do
20
+ let(:journal) do
21
+ <<~JOURNAL
22
+ 1990-01-01 Wendy's
23
+ Personal:Expenses:Food:Restaurant $8.44
24
+ Personal:Assets:Cash
25
+
26
+ 1982-01-01 McDonald's
27
+ Personal:Expenses:Food:Restaurant $10.15
28
+ Personal:Assets:Cash
29
+
30
+ 2002-01-01 Burger King
31
+ Personal:Expenses:Food:Restaurant $3.94
32
+ Personal:Assets:Cash
33
+
34
+ 2000-01-01 Arby's
35
+ Personal:Expenses:Food:Restaurant $12.87
36
+ Personal:Assets:Cash
37
+ JOURNAL
38
+ end
39
+
40
+ let(:oldest_tx) { subject.oldest_transaction from_s: journal }
41
+ let(:newest_tx) { subject.newest_transaction from_s: journal }
42
+
43
+ it 'returns the oldest transaction in the file' do
44
+ value(oldest_tx.payee).must_equal "McDonald's"
45
+ value(oldest_tx.date).must_equal Date.new(1982, 1, 1)
46
+ value(oldest_tx.postings.length).must_equal 2
47
+ value(oldest_tx.postings[0].account).must_equal 'Personal:Expenses:Food:Restaurant'
48
+ value(oldest_tx.postings[0].amounts.map(&:to_s)).must_equal ['$ 10.15']
49
+ value(oldest_tx.postings[1].account).must_equal 'Personal:Assets:Cash'
50
+ value(oldest_tx.postings[1].amounts.map(&:to_s)).must_equal ['$ -10.15']
51
+ end
52
+
53
+ it 'returns the newest transaction in the file' do
54
+ value(newest_tx.payee).must_equal 'Burger King'
55
+ value(newest_tx.date).must_equal Date.new(2002, 1, 1)
56
+ value(newest_tx.postings.length).must_equal 2
57
+ value(newest_tx.postings[0].account).must_equal 'Personal:Expenses:Food:Restaurant'
58
+ value(newest_tx.postings[0].amounts.map(&:to_s)).must_equal ['$ 3.94']
59
+ value(newest_tx.postings[1].account).must_equal 'Personal:Assets:Cash'
60
+ value(newest_tx.postings[1].amounts.map(&:to_s)).must_equal ['$ -3.94']
61
+ end
62
+
63
+ it 'returns the newest transaction date in the file' do
64
+ # This is a specific optimization, that enables us to use hledger stats in the rvgp/config.rb
65
+ value(subject.newest_transaction_date(from_s: journal)).must_equal Date.new(2002, 1, 1)
66
+ end
67
+ end
68
+
69
+ describe "#{pta_klass}#files" do
70
+ it 'returns all the files referenced in a journal' do
71
+ journals = 5.times.map { Tempfile.open %w[rvgp_test .journal] }
72
+
73
+ journals[1...].each do |journal|
74
+ journal.write(<<~JOURNAL)
75
+ 1990-01-01 Wendy's
76
+ Personal:Expenses:Food:Restaurant $8.44
77
+ Personal:Assets:Cash
78
+ JOURNAL
79
+ journal.close
80
+ end
81
+
82
+ journals[0].write(journals[1...].map do |journal|
83
+ format 'include %s', journal.path
84
+ end.zip(["\n"] * journals.length).flatten.join)
85
+
86
+ journals[0].close
87
+
88
+ value(subject.files(file: journals[0].path).sort).must_equal journals.map(&:path).sort
89
+
90
+ ensure
91
+ journals.each(&:unlink)
92
+ end
93
+ end
94
+
95
+ describe "#{pta_klass}#balance" do
96
+ it 'must parse a simple balance query' do
97
+ balance = subject.balance 'Transfers', from_s: <<~JOURNAL
98
+ 2023-01-01 Transaction 1
99
+ Transfers:PersonalCredit_PersonalChecking $ 1234.00
100
+ Personal:Assets:AcmeBank:Checking
101
+
102
+ 2023-01-02 Transaction 2
103
+ Transfers:PersonalSavings_PersonalChecking $ 5678.90
104
+ Personal:Assets:AcmeBank:Checking
105
+ JOURNAL
106
+
107
+ value(balance.accounts.map(&:fullname)).must_equal ['Transfers:PersonalCredit_PersonalChecking',
108
+ 'Transfers:PersonalSavings_PersonalChecking']
109
+ value(balance.accounts[0].amounts.map(&:to_s)).must_equal ['$ 1234.00']
110
+ value(balance.accounts[1].amounts.map(&:to_s)).must_equal ['$ 5678.90']
111
+
112
+ # Maybe I should just remove this feature entirely?....
113
+ value(balance.summary_amounts.map(&:to_s)).must_equal ['$ 6912.90'] if subject.hledger?
114
+ end
115
+
116
+ it 'must parse a negative balances query' do
117
+ balance = subject.balance 'Unknown', from_s: <<~JOURNAL
118
+ 2023-01-01 Transaction 1
119
+ Personal:Expenses:Unknown $ 700.00
120
+ Personal:Assets:AcmeBank:Checking
121
+
122
+ 2023-01-02 Transaction 2
123
+ Personal:Income:Unknown $ -500.00
124
+ Personal:Assets:AcmeBank:Checking
125
+
126
+ 2023-01-03 Transaction 3
127
+ Personal:Expenses:Unknown $ 50.00
128
+ Personal:Assets:AcmeBank:Checking
129
+
130
+ 2023-01-04 Transaction 4
131
+ Personal:Income:Unknown $ -40.00
132
+ Personal:Assets:AcmeBank:Checking
133
+ JOURNAL
134
+
135
+ value(balance.accounts.length).must_equal 2
136
+ value(balance.accounts[0].fullname).must_equal 'Personal:Expenses:Unknown'
137
+ value(balance.accounts[0].amounts.length).must_equal 1
138
+ value(balance.accounts[0].amounts[0].to_s).must_equal '$ 750.00'
139
+
140
+ value(balance.accounts[1].fullname).must_equal 'Personal:Income:Unknown'
141
+ value(balance.accounts[1].amounts.length).must_equal 1
142
+ value(balance.accounts[1].amounts[0].to_s).must_equal '$ -540.00'
143
+
144
+ # Summary line.
145
+ if subject.hledger?
146
+ value(balance.summary_amounts.length).must_equal 1
147
+ value(balance.summary_amounts[0].to_s).must_equal '$ 210.00'
148
+ end
149
+ end
150
+
151
+ it 'must parse balances in multiple currencies' do
152
+ balance = subject.balance 'Unknown', from_s: <<~JOURNAL
153
+ 2023-01-01 Transaction 1
154
+ Personal:Expenses:Unknown $ 41.00
155
+ Personal:Assets:AcmeBank:Checking
156
+
157
+ 2023-01-02 Transaction 2
158
+ Personal:Expenses:Unknown 1847.00 GTQ
159
+ Personal:Assets:AcmeBank:Checking
160
+ JOURNAL
161
+
162
+ assert_equal 1, balance.accounts.length
163
+ assert_equal 'Personal:Expenses:Unknown', balance.accounts[0].fullname
164
+ assert_equal 2, balance.accounts[0].amounts.length
165
+ assert_equal ['$ 41.00', '1847.00 GTQ'], balance.accounts[0].amounts.map(&:to_s).sort
166
+
167
+ # Summary line
168
+ if subject.hledger?
169
+ assert_equal 2, balance.summary_amounts.length
170
+ assert_equal '$ 41.00', balance.summary_amounts[0].to_s
171
+ assert_equal '1847.00 GTQ', balance.summary_amounts[1].to_s
172
+ end
173
+ end
174
+
175
+ it 'converts commodities, given a pricer' do
176
+ # TODO: Put the TestLedger#test_balance_multiple_with_empty here. Refactored
177
+ skip 'TODO: Put the TestLedger#test_balance_multiple_with_empty here. Refactored'
178
+ end
179
+
180
+ it "parses depth #{pta_klass}" do
181
+ balance = pta_klass.new.balance 'Personal:Assets:AcmeBank:Checking',
182
+ depth: 1,
183
+ from_s: <<~JOURNAL
184
+ 1996-02-03 Publix
185
+ Personal:Expenses:Food:Groceries $ 123.45
186
+ Personal:Assets:AcmeBank:Checking
187
+ JOURNAL
188
+
189
+ value(balance.accounts.length).must_equal 1
190
+ value(balance.accounts[0].fullname).must_equal 'Personal'
191
+ value(balance.accounts[0].amounts.length).must_equal 1
192
+ value(balance.accounts[0].amounts[0].to_s).must_equal '$ -123.45'
193
+ end
194
+ end
195
+
196
+ describe "#{pta_klass}#register" do
197
+ it 'matches the output of the csv command, on longer queries' do
198
+ # This is just your basic activity, on a mostly unused savings account
199
+ journal = <<~JOURNAL
200
+ 2021/01/26 INTEREST PAYMENT
201
+ Personal:Income:AcmeBank:Interest $ -7.89
202
+ Personal:Assets:AcmeBank:Savings
203
+
204
+ 2021/01/26 MONTHLY SERVICE FEE
205
+ Personal:Expenses:Banking:Fees:AcmeBank $ 5.00
206
+ Personal:Assets:AcmeBank:Savings
207
+
208
+ 2021/02/23 INTEREST PAYMENT
209
+ Personal:Income:AcmeBank:Interest $ -7.89
210
+ Personal:Assets:AcmeBank:Savings
211
+
212
+ 2021/02/23 MONTHLY SERVICE FEE
213
+ Personal:Expenses:Banking:Fees:AcmeBank $ 5.00
214
+ Personal:Assets:AcmeBank:Savings
215
+
216
+ 2021/03/15 ONLINE TRANSFER FROM PERSONAL CHECKING ON 03/13/21
217
+ Transfers:PersonalSavings_PersonalChecking $ -100.00
218
+ Personal:Assets:AcmeBank:Savings
219
+
220
+ 2021/03/22 INTEREST PAYMENT
221
+ Personal:Income:AcmeBank:Interest $ -7.90
222
+ Personal:Assets:AcmeBank:Savings
223
+
224
+ 2021/03/22 MONTHLY SERVICE FEE
225
+ Personal:Expenses:Banking:Fees:AcmeBank $ 5.00
226
+ Personal:Assets:AcmeBank:Savings
227
+
228
+ 2021/04/22 INTEREST PAYMENT
229
+ Personal:Income:AcmeBank:Interest $ -7.89
230
+ Personal:Assets:AcmeBank:Savings
231
+
232
+ 2021/04/22 MONTHLY SERVICE FEE
233
+ Personal:Expenses:Banking:Fees:AcmeBank $ 5.00
234
+ Personal:Assets:AcmeBank:Savings
235
+
236
+ 2021/05/24 INTEREST PAYMENT
237
+ Personal:Income:AcmeBank:Interest $ -7.89
238
+ Personal:Assets:AcmeBank:Savings
239
+
240
+ 2021/05/24 MONTHLY SERVICE FEE
241
+ Personal:Expenses:Banking:Fees:AcmeBank $ 5.00
242
+ Personal:Assets:AcmeBank:Savings
243
+
244
+ 2021/06/22 INTEREST PAYMENT
245
+ Personal:Income:AcmeBank:Interest $ -7.89
246
+ Personal:Assets:AcmeBank:Savings
247
+
248
+ 2021/06/22 MONTHLY SERVICE FEE
249
+ Personal:Expenses:Banking:Fees:AcmeBank $ 5.00
250
+ Personal:Assets:AcmeBank:Savings
251
+
252
+ 2021/07/23 INTEREST PAYMENT
253
+ Personal:Income:AcmeBank:Interest $ -7.90
254
+ Personal:Assets:AcmeBank:Savings
255
+
256
+ 2021/07/23 MONTHLY SERVICE FEE
257
+ Personal:Expenses:Banking:Fees:AcmeBank $ 5.00
258
+ Personal:Assets:AcmeBank:Savings
259
+ JOURNAL
260
+
261
+ register = subject.register 'Personal:Assets:AcmeBank:Savings', related: true, from_s: journal
262
+
263
+ # We just use hledger here, rather than maintain two versions of our csv_rows truth table:
264
+ csv_rows = CSV.parse(RVGP::Pta::HLedger.new.command('register',
265
+ 'Personal:Assets:AcmeBank:Savings',
266
+ from_s: journal, related: true, 'output-format': 'csv'),
267
+ headers: true)
268
+
269
+ assert_equal csv_rows.length, register.transactions.length
270
+
271
+ csv_rows.each_with_index do |csv_row, i|
272
+ value(register.transactions[i].postings.length).must_equal 1
273
+ value(register.transactions[i].postings[0].amounts.length).must_equal 1
274
+ value(register.transactions[i].postings[0].totals.length).must_equal 1
275
+ value(register.transactions[i].date.to_s).must_equal csv_row['date']
276
+ value(register.transactions[i].payee).must_equal csv_row['description']
277
+ value(register.transactions[i].postings[0].account).must_equal csv_row['account']
278
+ value(register.transactions[i].postings[0].amounts[0].to_s).must_equal csv_row['amount']
279
+ value(register.transactions[i].postings[0].totals[0].to_s).must_equal csv_row['total']
280
+ end
281
+ end
282
+
283
+ it 'parses multiple commodities and tags' do
284
+ register = subject.register 'Personal:Expenses', from_s: <<~JOURNAL
285
+ 2023-02-14 Food Lion
286
+ Personal:Expenses:Food:Groceries $26.18 ; intention: Personal
287
+ Personal:Expenses:Vices:Alcohol $18.26
288
+ ; Dating:
289
+ ; ValentinesDay:
290
+ ; intention: Personal
291
+ Personal:Assets:Cash
292
+
293
+ 2023-02-16 2x Lotto tickets
294
+ Personal:Expenses:Vices:Gambling $ 2.00
295
+ ; Loss:
296
+ ; intention: Personal
297
+ Personal:Assets:Cash
298
+
299
+ 2023-02-19 Agua con Gas
300
+ Personal:Expenses:Food:Water 4000.00 COP
301
+ ; intention: Personal
302
+ Personal:Assets:Cash
303
+
304
+ 2023-02-20 Carulla
305
+ Personal:Expenses:Food:Groceries 56123.00 COP
306
+ ; intention: Personal
307
+ Personal:Expenses:Food:Water 4000.00 COP
308
+ ; intention: Personal
309
+ Personal:Assets:Cash
310
+ JOURNAL
311
+
312
+ value(register.transactions.length).must_equal 4
313
+
314
+ # Dates:
315
+ value(register.transactions.map(&:date).map(&:to_s)).must_equal %w[2023-02-14 2023-02-16 2023-02-19 2023-02-20]
316
+
317
+ # Payees
318
+ value(register.transactions.map(&:payee).map(&:to_s)).must_equal(
319
+ ['Food Lion', '2x Lotto tickets', 'Agua con Gas', 'Carulla']
320
+ )
321
+
322
+ # Transaction 1:
323
+ value(register.transactions[0].postings.map(&:account)).must_equal ['Personal:Expenses:Food:Groceries',
324
+ 'Personal:Expenses:Vices:Alcohol']
325
+ value(register.transactions[0].postings.map(&:amounts).flatten.map(&:to_s)).must_equal ['$ 26.18', '$ 18.26']
326
+ value(register.transactions[0].postings.map(&:totals).flatten.map(&:to_s)).must_equal ['$ 26.18', '$ 44.44']
327
+ value(register.transactions[0].postings.map(&:tags)).must_equal(
328
+ [{ 'intention' => 'Personal' },
329
+ if subject.hledger?
330
+ { 'Dating' => true, 'ValentinesDay' => true, 'intention' => 'Personal' }
331
+ else
332
+ { 'intention' => 'Personal' }
333
+ end]
334
+ )
335
+
336
+ # Transaction 2:
337
+ value(register.transactions[1].postings.map(&:account)).must_equal ['Personal:Expenses:Vices:Gambling']
338
+ value(register.transactions[1].postings.map(&:amounts).flatten.map(&:to_s)).must_equal ['$ 2.00']
339
+ value(register.transactions[1].postings.map(&:totals).flatten.map(&:to_s)).must_equal ['$ 46.44']
340
+ value(register.transactions[1].postings.map(&:tags)).must_equal [if subject.hledger?
341
+ { 'intention' => 'Personal', 'Loss' => true }
342
+ else
343
+ { 'intention' => 'Personal' }
344
+ end]
345
+
346
+ # Transaction 3:
347
+ value(register.transactions[2].postings.map(&:account)).must_equal ['Personal:Expenses:Food:Water']
348
+ value(register.transactions[2].postings.map(&:amounts).flatten.map(&:to_s)).must_equal ['4000.00 COP']
349
+ value(register.transactions[2].postings.map(&:totals).flatten.map(&:to_s).sort).must_equal ['$ 46.44',
350
+ '4000.00 COP']
351
+ value(register.transactions[2].postings.map(&:tags)).must_equal [{ 'intention' => 'Personal' }]
352
+
353
+ # Transaction 4:
354
+ value(register.transactions[3].postings.map(&:account)).must_equal ['Personal:Expenses:Food:Groceries',
355
+ 'Personal:Expenses:Food:Water']
356
+ value(register.transactions[3].postings.map(&:amounts).flatten.map(&:to_s)).must_equal ['56123.00 COP',
357
+ '4000.00 COP']
358
+ value(register.transactions[3].postings.map(&:totals).flatten.map(&:to_s).sort).must_equal ['$ 46.44',
359
+ '$ 46.44',
360
+ '60123.00 COP',
361
+ '64123.00 COP']
362
+ value(register.transactions[3].postings.map(&:tags)).must_equal [{ 'intention' => 'Personal' }] * 2
363
+ end
364
+
365
+ it 'supports price conversion, through :pricer' do
366
+ transactions = subject.register(
367
+ 'Personal:Assets:Cash',
368
+ monthly: true,
369
+ pricer: RVGP::Journal::Pricer.new(<<~PRICES),
370
+ P 2023-05-01 HNL $0.040504
371
+ P 2023-06-01 CLP $0.0012
372
+ PRICES
373
+ from_s: <<~JOURNAL
374
+ 2023-05-02 Flight to Honduras
375
+ Personal:Expenses:Transportation:Airline $ 252.78
376
+ Personal:Assets:Cash
377
+
378
+ 2023-05-14 Roatan Dive Center
379
+ Personal:Expenses:Hobbies:SCUBA 876.54 HNL
380
+ Personal:Assets:Cash
381
+
382
+ 2023-05-15 Eldon's Supermarket
383
+ Personal:Expenses:Food:Groceries 543.21 HNL
384
+ Personal:Assets:Cash
385
+
386
+ 2023-05-30 Flight to Chile
387
+ Personal:Expenses:Transportation:Airline $ 432.10
388
+ Personal:Assets:Cash
389
+
390
+ 2023-06-02 Nevados de Chillan
391
+ Personal:Expenses:Hobbies:Snowboarding 33143.60 CLP
392
+ Personal:Assets:Cash
393
+
394
+ 2023-06-03 La Cabrera Chile Isidora
395
+ Personal:Expenses:Food:Restaurants 24856.20 CLP
396
+ Personal:Assets:Cash
397
+
398
+ 2023-06-14 Flight Home
399
+ Personal:Expenses:Transportation:Airline $ 651.09
400
+ Personal:Assets:Cash
401
+ JOURNAL
402
+ ).transactions
403
+
404
+ value(transactions.map(&:date).map(&:to_s)).must_equal %w[2023-05-01 2023-06-01]
405
+ value(transactions.map(&:payee).compact).must_equal(subject.hledger? ? [] : ['- 23-May-31', '- 23-Jun-30'])
406
+ value(transactions.map(&:postings).flatten.map(&:account)).must_equal ['Personal:Assets:Cash'] * 2
407
+ value(transactions.map(&:postings).flatten.map(&:tags)).must_equal [{}] * 2
408
+
409
+ # Month 1:
410
+ value(transactions[0].postings[0].amounts.map(&:to_s).sort).must_equal ['$ -684.88', '-1419.75 HNL']
411
+ value(transactions[0].postings[0].totals.map(&:to_s).sort).must_equal ['$ -684.88', '-1419.75 HNL']
412
+
413
+ value(transactions[0].postings[0].amount_in('$').to_s).must_equal '$ -742.385554'
414
+ value(transactions[0].postings[0].total_in('$').to_s).must_equal '$ -742.385554'
415
+
416
+ # Month 2:
417
+
418
+ # NOTE: This is a bug in ledger. Seemingly, the register command shows the .80. The correct amount.
419
+ # However, the xml output, shows '.8'. I don't think there are any great solutions to this, so, for
420
+ # now, I'm doing this in the tests:
421
+ expected_clp = subject.hledger? ? '-57999.80 CLP' : '-57999.8 CLP'
422
+
423
+ value(transactions[1].postings[0].amounts.map(&:to_s)).must_equal ['$ -651.09', expected_clp]
424
+ value(transactions[1].postings[0].totals.map(&:to_s).sort).must_equal ['$ -1335.97',
425
+ '-1419.75 HNL',
426
+ expected_clp]
427
+
428
+ value(transactions[1].postings[0].amount_in('$').to_s).must_equal '$ -720.68976'
429
+ value(transactions[1].postings[0].total_in('$').to_s).must_equal '$ -1463.075314'
430
+ end
431
+ end
432
+
433
+ describe "#{pta_klass}#tags" do
434
+ let(:journal) do
435
+ <<~JOURNAL
436
+ 2023-01-01 Transaction 1
437
+ Personal:Expenses:TaggedExpense $ 1.00
438
+ ; color: red
439
+ ; vacation: Hawaii
440
+ Personal:Assets:AcmeBank:Checking
441
+
442
+ 2023-01-02 Transaction 2
443
+ Personal:Expenses:TaggedExpense $ 2.00
444
+ ; color: orange
445
+ ; :business:
446
+ Personal:Assets:AcmeBank:Checking
447
+
448
+ 2023-01-03 Transaction 3
449
+ Personal:Expenses:TaggedExpense $ 3.00
450
+ ; color: yellow
451
+ ; :medical:
452
+ Personal:Assets:AcmeBank:Checking
453
+
454
+ 2023-01-04 Transaction 4
455
+ Personal:Expenses:TaggedExpense $ 4.00
456
+ ; color: green
457
+ Personal:Assets:AcmeBank:Checking
458
+
459
+ 2023-01-05 Transaction 5
460
+ Personal:Expenses:TaggedExpense $ 5.00
461
+ ; color: blue
462
+ Personal:Assets:AcmeBank:Checking
463
+
464
+ 2023-01-06 Transaction 6
465
+ Personal:Expenses:TaggedExpense $ 6.00
466
+ ; color: indigo
467
+ Personal:Assets:AcmeBank:Checking
468
+
469
+ 2023-01-07 Transaction 7
470
+ Personal:Expenses:TaggedExpense $ 7.00
471
+ ; color: violet
472
+ Personal:Assets:AcmeBank:Checking
473
+
474
+ 2023-01-08 Transaction 8
475
+ Personal:Expenses:TaggedExpense $ 8.00
476
+ ; vacation: Argentina
477
+ Personal:Assets:AcmeBank:Checking
478
+
479
+ 2023-01-09 Transaction 9
480
+ Personal:Expenses:TaggedExpense $ 9.00
481
+ ; vacation: Germany
482
+ Personal:Assets:AcmeBank:Checking
483
+
484
+ 2023-01-10 Transaction 10
485
+ Personal:Expenses:TaggedExpense $ 10.00
486
+ ; vacation: Japan
487
+ Personal:Assets:AcmeBank:Checking
488
+ JOURNAL
489
+ end
490
+
491
+ it 'parses tags' do
492
+ value(subject.tags(from_s: journal)).must_equal %w[business color medical vacation]
493
+ value(subject.tags('color', from_s: journal, values: true)).must_equal(
494
+ %w[blue green indigo orange red violet yellow]
495
+ )
496
+ value(subject.tags('vacation', from_s: journal, values: true)).must_equal %w[Argentina Germany Hawaii Japan]
497
+
498
+ value(subject.tags(values: true, from_s: journal)).must_equal(
499
+ # See the note on #tags to understand the ethos here, and why we can't/won't conform the output between pta
500
+ # adapter implementations on this query
501
+ if subject.ledger?
502
+ ['business', 'color: blue', 'color: green', 'color: indigo', 'color: orange', 'color: red', 'color: violet',
503
+ 'color: yellow', 'medical', 'vacation: Argentina', 'vacation: Germany', 'vacation: Hawaii',
504
+ 'vacation: Japan']
505
+ else
506
+ %w[Argentina Germany Hawaii Japan blue green indigo orange red violet yellow]
507
+ end
508
+ )
509
+ end
510
+ end
511
+ end
512
+ end
513
+
514
+ describe 'pta adapter errata' do
515
+ # This is a bug in ledger. The register output works fine. The xml output, does not. Possibly... we should
516
+ # warn here, so that when the problem is solved, we can ... know it?
517
+ # alternatively, we can just fix this in our code, I think. By adjusting the ledger adapter, for the case
518
+ # when 0 is returned....
519
+ describe '--empty bug' do
520
+ let(:args) do
521
+ # NOTE: This errata only triggers, if there's another tx in the listings, for that month. Hence the Reading
522
+ ['Personal:Expenses',
523
+ { monthly: true, begin: Date.new(2023, 7, 1), end: Date.new(2023, 8, 1), from_s: <<~JOURNAL }]
524
+ 2023/07/11 Shakespeare and Company
525
+ Personal:Expenses:Hobbies:Reading $ 12.34
526
+ Personal:Assets:AcmeBank:Checking
527
+
528
+ 2023/07/13 GAP Clothing
529
+ Personal:Expenses:Clothes $ 50.01
530
+ Personal:Assets:AcmeBank:Checking
531
+
532
+ 2023/07/24 GAP Clothing (RMA)
533
+ Personal:Expenses:Clothes $ -50.01
534
+ Personal:Assets:AcmeBank:Checking
535
+ JOURNAL
536
+ end
537
+
538
+ let(:args_with_fix) do
539
+ [args[0], args[1].merge(empty: false)]
540
+ end
541
+
542
+ it 'returns nil for the case of a query whose net change is 0' do
543
+ # This was a very specific bug that crept up, mostly because ledger handles this a bit differently than
544
+ # hledger. If two transactions 'cancel each other out' in a given month, hledger reports nil, and ledger
545
+ # reports '0'. This may be a case where all 0's, without a currency code, should return nil. And, I just
546
+ # happened to find that case exhibited in this circumstance.
547
+ #
548
+ # This test also ensures that if there's only one such transaction - we receive an empty transactions list.
549
+
550
+ # Hledger just works, and these tests are here for posterity. Asserting that hledger works as expected
551
+ [args, args_with_fix].each do |a|
552
+ transactions = RVGP::Pta::HLedger.new.register(*a).transactions
553
+ value(transactions.length).must_equal 1
554
+ value(transactions[0].payee).must_be_nil
555
+ value(transactions[0].postings.length).must_equal 1
556
+ value(transactions[0].postings[0].amounts.map(&:to_s)).must_equal ['$ 12.34']
557
+ end
558
+
559
+ # These specs just define the error, through our expectations of how it manefests:
560
+ transactions = RVGP::Pta::Ledger.new.register(*args).transactions
561
+ value(transactions.length).must_equal 1
562
+ value(transactions[0].payee).must_equal '- 23-Jul-31'
563
+ value(transactions[0].postings.length).must_equal 2
564
+ value(transactions[0].postings[0].amounts.map(&:to_s)).must_equal ['$ 0.00']
565
+ value(transactions[0].postings[1].amounts.map(&:to_s)).must_equal ['$ 12.34']
566
+
567
+ # This is the fix:
568
+ transactions = RVGP::Pta::Ledger.new.register(*args_with_fix).transactions
569
+ value(transactions.length).must_equal 1
570
+ value(transactions[0].payee).must_equal '- 23-Jul-31'
571
+ value(transactions[0].postings.length).must_equal 1
572
+ value(transactions[0].postings[0].amounts.map(&:to_s)).must_equal ['$ 12.34']
573
+ end
574
+ end
575
+ end
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'minitest/autorun'
5
+
6
+ require_relative '../lib/rvgp'
7
+ require_relative '../lib/rvgp/utilities'
8
+
9
+ # RVGP::Utilities tests
10
+ class TestUtilities < Minitest::Test
11
+ include RVGP::Utilities
12
+
13
+ # This is kind of a weird function... just sayin...
14
+ def test_months_through
15
+ string_to_date = ->(s) { Date.strptime s }
16
+
17
+ assert_equal %w[2019-01-01 2019-02-01 2019-03-01 2019-04-01 2019-05-01 2019-06-01
18
+ 2019-07-01 2019-08-01 2019-09-01 2019-10-01 2019-11-01 2019-12-01].collect(&string_to_date),
19
+ months_through(
20
+ *%w[2019-01-01 2019-02-01 2019-03-01 2019-04-01 2019-05-01 2019-06-01
21
+ 2019-07-01 2019-08-01 2019-09-01 2019-10-01 2019-11-01 2019-12-01
22
+ 2019-01-01 2019-02-01 2019-03-01 2019-04-01 2019-05-01 2019-06-01
23
+ 2019-07-01 2019-08-01 2019-09-01 2019-10-01 2019-11-01 2019-12-01].collect(&string_to_date)
24
+ )
25
+
26
+ assert_equal %w[2021-01-01 2021-02-01 2021-03-01 2021-04-01 2021-05-01
27
+ 2021-06-01 2021-07-01 2021-08-01 2021-09-01 2021-10-01
28
+ 2021-11-01].collect(&string_to_date),
29
+ months_through(Date.new(2021, 1, 1), Date.new(2021, 11, 29))
30
+
31
+ assert_equal %w[2021-01-01 2021-02-01 2021-03-01 2021-04-01 2021-05-01
32
+ 2021-06-01 2021-07-01 2021-08-01 2021-09-01 2021-10-01
33
+ 2021-11-01 2021-12-01 2022-01-01].collect(&string_to_date),
34
+ months_through(Date.new(2021, 1, 1), Date.new(2022, 1, 1))
35
+
36
+ assert_equal %w[2018-01-01 2018-02-01 2018-03-01 2018-04-01 2018-05-01
37
+ 2018-06-01 2018-07-01 2018-08-01 2018-09-01 2018-10-01
38
+ 2018-11-01 2018-12-01].collect(&string_to_date),
39
+ months_through(Date.new(2018, 1, 1), Date.new(2018, 12, 31))
40
+
41
+ # I think this is the behavior we want....
42
+ assert_equal %w[2021-01-01 2021-02-01].collect(&string_to_date),
43
+ months_through(Date.new(2021, 1, 10), Date.new(2021, 2, 10))
44
+ end
45
+ end