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