double_entry 0.1.0 → 0.2.0

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 (40) hide show
  1. data/README.md +16 -14
  2. data/lib/double_entry.rb +9 -62
  3. data/lib/double_entry/account.rb +5 -1
  4. data/lib/double_entry/configuration.rb +21 -0
  5. data/lib/double_entry/reporting.rb +51 -0
  6. data/lib/double_entry/{aggregate.rb → reporting/aggregate.rb} +9 -7
  7. data/lib/double_entry/{aggregate_array.rb → reporting/aggregate_array.rb} +3 -1
  8. data/lib/double_entry/{day_range.rb → reporting/day_range.rb} +2 -0
  9. data/lib/double_entry/{hour_range.rb → reporting/hour_range.rb} +2 -0
  10. data/lib/double_entry/{line_aggregate.rb → reporting/line_aggregate.rb} +4 -2
  11. data/lib/double_entry/{month_range.rb → reporting/month_range.rb} +4 -2
  12. data/lib/double_entry/{time_range.rb → reporting/time_range.rb} +7 -5
  13. data/lib/double_entry/{time_range_array.rb → reporting/time_range_array.rb} +2 -0
  14. data/lib/double_entry/{week_range.rb → reporting/week_range.rb} +3 -1
  15. data/lib/double_entry/{year_range.rb → reporting/year_range.rb} +4 -3
  16. data/lib/double_entry/transfer.rb +4 -0
  17. data/lib/double_entry/validation.rb +1 -0
  18. data/lib/double_entry/{line_check.rb → validation/line_check.rb} +2 -0
  19. data/lib/double_entry/version.rb +1 -1
  20. data/script/jack_hammer +21 -16
  21. data/spec/double_entry/account_spec.rb +9 -0
  22. data/spec/double_entry/configuration_spec.rb +23 -0
  23. data/spec/double_entry/locking_spec.rb +24 -13
  24. data/spec/double_entry/{aggregate_array_spec.rb → reporting/aggregate_array_spec.rb} +2 -2
  25. data/spec/double_entry/reporting/aggregate_spec.rb +171 -0
  26. data/spec/double_entry/reporting/line_aggregate_spec.rb +10 -0
  27. data/spec/double_entry/{month_range_spec.rb → reporting/month_range_spec.rb} +23 -21
  28. data/spec/double_entry/{time_range_array_spec.rb → reporting/time_range_array_spec.rb} +41 -39
  29. data/spec/double_entry/{time_range_spec.rb → reporting/time_range_spec.rb} +10 -9
  30. data/spec/double_entry/{week_range_spec.rb → reporting/week_range_spec.rb} +26 -25
  31. data/spec/double_entry/reporting_spec.rb +24 -0
  32. data/spec/double_entry/transfer_spec.rb +17 -0
  33. data/spec/double_entry/{line_check_spec.rb → validation/line_check_spec.rb} +17 -16
  34. data/spec/double_entry_spec.rb +409 -0
  35. data/spec/support/accounts.rb +16 -17
  36. metadata +70 -35
  37. checksums.yaml +0 -15
  38. data/spec/double_entry/aggregate_spec.rb +0 -168
  39. data/spec/double_entry/double_entry_spec.rb +0 -391
  40. data/spec/double_entry/line_aggregate_spec.rb +0 -8
@@ -0,0 +1,409 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+ describe DoubleEntry do
4
+
5
+ # these specs blat the DoubleEntry configuration, so take
6
+ # a copy and clean up after ourselves
7
+ before do
8
+ @config_accounts = DoubleEntry.configuration.accounts
9
+ @config_transfers = DoubleEntry.configuration.transfers
10
+ DoubleEntry.configuration.accounts = DoubleEntry::Account::Set.new
11
+ DoubleEntry.configuration.transfers = DoubleEntry::Transfer::Set.new
12
+ end
13
+
14
+ after do
15
+ DoubleEntry.configuration.accounts = @config_accounts
16
+ DoubleEntry.configuration.transfers = @config_transfers
17
+ end
18
+
19
+ describe 'configuration' do
20
+ it 'checks for duplicates of accounts' do
21
+ expect {
22
+ DoubleEntry.configure do |config|
23
+ config.define_accounts do |accounts|
24
+ accounts.define(:identifier => :gah!)
25
+ accounts.define(:identifier => :gah!)
26
+ end
27
+ end
28
+ }.to raise_error DoubleEntry::DuplicateAccount
29
+ end
30
+
31
+ it 'checks for duplicates of transfers' do
32
+ expect {
33
+ DoubleEntry.configure do |config|
34
+ config.define_transfers do |transfers|
35
+ transfers.define(:from => :savings, :to => :cash, :code => :xfer)
36
+ transfers.define(:from => :savings, :to => :cash, :code => :xfer)
37
+ end
38
+ end
39
+ }.to raise_error DoubleEntry::DuplicateTransfer
40
+ end
41
+ end
42
+
43
+ describe 'accounts' do
44
+ before do
45
+ DoubleEntry.configure do |config|
46
+ config.define_accounts do |accounts|
47
+ accounts.define(:identifier => :unscoped)
48
+ accounts.define(:identifier => :scoped, :scope_identifier => ->(u) { u.id })
49
+ end
50
+ end
51
+ end
52
+
53
+ let(:scope) { double('a scope', :id => 1) }
54
+
55
+ describe 'fetching' do
56
+ it 'can find an unscoped account by identifier' do
57
+ expect(DoubleEntry.account(:unscoped)).to_not be_nil
58
+ end
59
+
60
+ it 'can find a scoped account by identifier' do
61
+ expect(DoubleEntry.account(:scoped, :scope => scope)).to_not be_nil
62
+ end
63
+
64
+ it 'raises an exception when it cannot find an account' do
65
+ expect { DoubleEntry.account(:invalid) }.to raise_error(DoubleEntry::UnknownAccount)
66
+ end
67
+
68
+ it 'raises exception when you ask for an unscoped account w/ scope' do
69
+ expect { DoubleEntry.account(:unscoped, :scope => scope) }.to raise_error(DoubleEntry::UnknownAccount)
70
+ end
71
+
72
+ it 'raises exception when you ask for a scoped account w/ out scope' do
73
+ expect { DoubleEntry.account(:scoped) }.to raise_error(DoubleEntry::UnknownAccount)
74
+ end
75
+ end
76
+
77
+ context "an unscoped account" do
78
+ subject(:unscoped) { DoubleEntry.account(:unscoped) }
79
+
80
+ it "has an identifier" do
81
+ expect(unscoped.identifier).to eq :unscoped
82
+ end
83
+ end
84
+ context "a scoped account" do
85
+ subject(:scoped) { DoubleEntry.account(:scoped, :scope => scope) }
86
+
87
+ it "has an identifier" do
88
+ expect(scoped.identifier).to eq :scoped
89
+ end
90
+ end
91
+ end
92
+
93
+ describe 'transfers' do
94
+ before do
95
+ DoubleEntry.configure do |config|
96
+ config.define_accounts do |accounts|
97
+ accounts.define(:identifier => :savings)
98
+ accounts.define(:identifier => :cash)
99
+ accounts.define(:identifier => :trash)
100
+ end
101
+
102
+ config.define_transfers do |transfers|
103
+ transfers.define(:from => :savings, :to => :cash, :code => :xfer, :meta_requirement => [:ref])
104
+ end
105
+ end
106
+ end
107
+
108
+ let(:savings) { DoubleEntry.account(:savings) }
109
+ let(:cash) { DoubleEntry.account(:cash) }
110
+ let(:trash) { DoubleEntry.account(:trash) }
111
+
112
+ it 'can transfer from an account to an account, if the transfer is allowed' do
113
+ DoubleEntry.transfer(
114
+ Money.new(100_00),
115
+ :from => savings,
116
+ :to => cash,
117
+ :code => :xfer,
118
+ :meta => { :ref => 'shopping!' },
119
+ )
120
+ end
121
+
122
+ it 'raises an exception when the transfer is not allowed (wrong direction)' do
123
+ expect {
124
+ DoubleEntry.transfer(
125
+ Money.new(100_00),
126
+ :from => cash,
127
+ :to => savings,
128
+ :code => :xfer,
129
+ )
130
+ }.to raise_error DoubleEntry::TransferNotAllowed
131
+ end
132
+
133
+ it 'raises an exception when the transfer is not allowed (wrong code)' do
134
+ expect {
135
+ DoubleEntry.transfer(
136
+ Money.new(100_00),
137
+ :from => savings,
138
+ :to => cash,
139
+ :code => :yfer,
140
+ :meta => { :ref => 'shopping!' },
141
+ )
142
+ }.to raise_error DoubleEntry::TransferNotAllowed
143
+ end
144
+
145
+ it 'raises an exception when the transfer is not allowed (does not exist, at all)' do
146
+ expect {
147
+ DoubleEntry.transfer(
148
+ Money.new(100_00),
149
+ :from => cash,
150
+ :to => trash,
151
+ )
152
+ }.to raise_error DoubleEntry::TransferNotAllowed
153
+ end
154
+
155
+ it 'raises an exception when required meta data is omitted' do
156
+ expect {
157
+ DoubleEntry.transfer(
158
+ Money.new(100_00),
159
+ :from => savings,
160
+ :to => cash,
161
+ :code => :xfer,
162
+ :meta => {},
163
+ )
164
+ }.to raise_error DoubleEntry::RequiredMetaMissing
165
+ end
166
+ end
167
+
168
+ describe 'lines' do
169
+ before do
170
+ DoubleEntry.configure do |config|
171
+ config.define_accounts do |accounts|
172
+ accounts.define(:identifier => :a)
173
+ accounts.define(:identifier => :b)
174
+ end
175
+
176
+ description = ->(line) { "Money goes #{line.credit? ? 'out' : 'in'}: #{line.amount.format}" }
177
+ config.define_transfers do |transfers|
178
+ transfers.define(:code => :xfer, :from => :a, :to => :b, :description => description)
179
+ end
180
+ end
181
+
182
+ DoubleEntry.transfer(Money.new(10_00), :from => account_a, :to => account_b, :code => :xfer)
183
+ end
184
+
185
+ let(:account_a) { DoubleEntry.account(:a) }
186
+ let(:account_b) { DoubleEntry.account(:b) }
187
+ let(:credit_line) { lines_for_account(account_a).first }
188
+ let(:debit_line) { lines_for_account(account_b).first }
189
+
190
+ it 'has an amount' do
191
+ expect(credit_line.amount).to eq -Money.new(10_00)
192
+ expect(debit_line.amount).to eq Money.new(10_00)
193
+ end
194
+
195
+ it 'has a code' do
196
+ expect(credit_line.code).to eq :xfer
197
+ expect(debit_line.code).to eq :xfer
198
+ end
199
+
200
+ it 'auto-sets scope when assigning account (and partner_accout, is this implementation?)' do
201
+ expect(credit_line[:account]).to eq 'a'
202
+ expect(credit_line[:scope]).to be_nil
203
+ expect(credit_line[:partner_account]).to eq 'b'
204
+ expect(credit_line[:partner_scope]).to be_nil
205
+ end
206
+
207
+ it 'has a partner_account (or is this implementation?)' do
208
+ expect(credit_line.partner_account).to eq debit_line.account
209
+ end
210
+
211
+ it 'knows if it is a credit or debit' do
212
+ expect(credit_line).to be_credit
213
+ expect(debit_line).to be_debit
214
+ expect(credit_line).to_not be_debit
215
+ expect(debit_line).to_not be_credit
216
+ end
217
+
218
+ it 'can describe itself' do
219
+ expect(credit_line.description).to eq 'Money goes out: $-10.00'
220
+ expect(debit_line.description).to eq 'Money goes in: $10.00'
221
+ end
222
+
223
+ it 'can reference its partner' do
224
+ expect(credit_line.partner).to eq debit_line
225
+ expect(debit_line.partner).to eq credit_line
226
+ end
227
+
228
+ it 'can ask for its pair (credit always coming first)' do
229
+ expect(credit_line.pair).to eq [credit_line, debit_line]
230
+ expect(debit_line.pair).to eq [credit_line, debit_line]
231
+ end
232
+
233
+ it 'can ask for the account (and get an instance)' do
234
+ expect(credit_line.account).to eq account_a
235
+ expect(debit_line.account).to eq account_b
236
+ end
237
+ end
238
+
239
+ describe 'balances' do
240
+
241
+ let(:work) { DoubleEntry.account(:work) }
242
+ let(:savings) { DoubleEntry.account(:savings) }
243
+ let(:cash) { DoubleEntry.account(:cash) }
244
+ let(:store) { DoubleEntry.account(:store) }
245
+
246
+ before do
247
+ DoubleEntry.configure do |config|
248
+ config.define_accounts do |accounts|
249
+ accounts.define(:identifier => :work)
250
+ accounts.define(:identifier => :cash)
251
+ accounts.define(:identifier => :savings)
252
+ accounts.define(:identifier => :store)
253
+ end
254
+
255
+ config.define_transfers do |transfers|
256
+ transfers.define(:code => :salary, :from => :work, :to => :cash)
257
+ transfers.define(:code => :xfer, :from => :cash, :to => :savings)
258
+ transfers.define(:code => :xfer, :from => :savings, :to => :cash)
259
+ transfers.define(:code => :purchase, :from => :cash, :to => :store)
260
+ transfers.define(:code => :layby, :from => :cash, :to => :store)
261
+ transfers.define(:code => :deposit, :from => :cash, :to => :store)
262
+ end
263
+ end
264
+
265
+ Timecop.freeze 3.weeks.ago+1.day do
266
+ # got paid from work
267
+ DoubleEntry.transfer(Money.new(1_000_00), :from => work, :code => :salary, :to => cash)
268
+ # transfer half salary into savings
269
+ DoubleEntry.transfer(Money.new(500_00), :from => cash, :code => :xfer, :to => savings)
270
+ end
271
+
272
+ Timecop.freeze 2.weeks.ago+1.day do
273
+ # got myself a darth vader helmet
274
+ DoubleEntry.transfer(Money.new(200_00), :from => cash, :code => :purchase, :to => store)
275
+ # paid off some of my darth vader suit layby (to go with the helmet)
276
+ DoubleEntry.transfer(Money.new(100_00), :from => cash, :code => :layby, :to => store)
277
+ # put a deposit on the darth vader voice changer module (for the helmet)
278
+ DoubleEntry.transfer(Money.new(100_00), :from => cash, :code => :deposit, :to => store)
279
+ end
280
+
281
+ Timecop.freeze 1.week.ago+1.day do
282
+ # transfer 200 out of savings
283
+ DoubleEntry.transfer(Money.new(200_00), :from => savings, :code => :xfer, :to => cash)
284
+ # pay the remaining balance on the darth vader voice changer module
285
+ DoubleEntry.transfer(Money.new(200_00), :from => cash, :code => :purchase, :to => store)
286
+ end
287
+
288
+ Timecop.freeze 1.week.from_now do
289
+ # go to the star wars convention AND ROCK OUT IN YOUR ACE DARTH VADER COSTUME!!!
290
+ end
291
+ end
292
+
293
+ it 'has the initial balances that we expect' do
294
+ expect(work.balance).to eq -Money.new(1_000_00)
295
+ expect(cash.balance).to eq Money.new(100_00)
296
+ expect(savings.balance).to eq Money.new(300_00)
297
+ expect(store.balance).to eq Money.new(600_00)
298
+ end
299
+
300
+ it 'should have correct account balance records' do
301
+ [work, cash, savings, store].each do |account|
302
+ expect(DoubleEntry::AccountBalance.find_by_account(account).balance).to eq account.balance
303
+ end
304
+ end
305
+
306
+ it 'affects origin/destination balance after transfer' do
307
+ savings_balance = savings.balance
308
+ cash_balance = cash.balance
309
+ amount = Money.new(10_00)
310
+
311
+ DoubleEntry.transfer(amount, :from => savings, :code => :xfer, :to => cash)
312
+
313
+ expect(savings.balance).to eq savings_balance - amount
314
+ expect(cash.balance).to eq cash_balance + amount
315
+ end
316
+
317
+ it 'can be queried at a given point in time' do
318
+ expect(cash.balance(:at => 1.week.ago)).to eq Money.new(100_00)
319
+ end
320
+
321
+ it 'can be queries between two points in time' do
322
+ expect(cash.balance(:from => 3.weeks.ago, :to => 2.weeks.ago)).to eq Money.new(500_00)
323
+ end
324
+
325
+ it 'can report on balances, scoped by code' do
326
+ expect(cash.balance(:code => :salary)).to eq Money.new(1_000_00)
327
+ end
328
+
329
+ it 'can report on balances, scoped by many codes' do
330
+ expect(store.balance(:codes => [:layby, :deposit])).to eq Money.new(200_00)
331
+ end
332
+
333
+ it 'has running balances for each line' do
334
+ lines = lines_for_account(cash)
335
+ expect(lines[0].balance).to eq Money.new(1_000_00) # salary
336
+ expect(lines[1].balance).to eq Money.new(500_00) # savings
337
+ expect(lines[2].balance).to eq Money.new(300_00) # purchase
338
+ expect(lines[3].balance).to eq Money.new(200_00) # layby
339
+ expect(lines[4].balance).to eq Money.new(100_00) # deposit
340
+ expect(lines[5].balance).to eq Money.new(300_00) # savings
341
+ expect(lines[6].balance).to eq Money.new(100_00) # purchase
342
+ end
343
+ end
344
+
345
+ describe 'scoping of accounts' do
346
+ before do
347
+ DoubleEntry.configure do |config|
348
+ config.define_accounts do |accounts|
349
+ accounts.define(:identifier => :bank)
350
+ accounts.define(:identifier => :cash, :scope_identifier => ->(user) { user.id })
351
+ accounts.define(:identifier => :savings, :scope_identifier => ->(user) { user.id })
352
+ end
353
+
354
+ config.define_transfers do |transfers|
355
+ transfers.define(:from => :bank, :to => :cash, :code => :xfer)
356
+ transfers.define(:from => :cash, :to => :cash, :code => :xfer)
357
+ transfers.define(:from => :cash, :to => :savings, :code => :xfer)
358
+ end
359
+ end
360
+ end
361
+
362
+ let(:bank) { DoubleEntry.account(:bank) }
363
+
364
+ let(:john) { User.make! }
365
+ let(:johns_cash) { DoubleEntry.account(:cash, :scope => john) }
366
+ let(:johns_savings) { DoubleEntry.account(:savings, :scope => john) }
367
+
368
+ let(:ryan) { User.make! }
369
+ let(:ryans_cash) { DoubleEntry.account(:cash, :scope => ryan) }
370
+ let(:ryans_savings) { DoubleEntry.account(:savings, :scope => ryan) }
371
+
372
+ it 'treats each separately scoped account having their own separate balances' do
373
+ DoubleEntry.transfer(Money.new(20_00), :from => bank, :to => johns_cash, :code => :xfer)
374
+ DoubleEntry.transfer(Money.new(10_00), :from => bank, :to => ryans_cash, :code => :xfer)
375
+ expect(johns_cash.balance).to eq Money.new(20_00)
376
+ expect(ryans_cash.balance).to eq Money.new(10_00)
377
+ end
378
+
379
+ it 'allows transfer between two separately scoped accounts' do
380
+ DoubleEntry.transfer(Money.new(10_00), :from => ryans_cash, :to => johns_cash, :code => :xfer)
381
+ expect(ryans_cash.balance).to eq -Money.new(10_00)
382
+ expect(johns_cash.balance).to eq Money.new(10_00)
383
+ end
384
+
385
+ it 'reports balance correctly if called from either account or finances object' do
386
+ DoubleEntry.transfer(Money.new(10_00), :from => ryans_cash, :to => johns_cash, :code => :xfer)
387
+ expect(ryans_cash.balance).to eq -Money.new(10_00)
388
+ expect(DoubleEntry.balance(:cash, :scope => ryan)).to eq -Money.new(10_00)
389
+ end
390
+
391
+ it 'raises exception if you try to transfer between the same account, despite it being scoped' do
392
+ expect do
393
+ DoubleEntry.transfer(Money.new(10_00), :from => ryans_cash, :to => ryans_cash, :code => :xfer)
394
+ end.to raise_error(DoubleEntry::TransferNotAllowed)
395
+ end
396
+
397
+ it 'allows transfer from one persons account to the same persons other kind of account' do
398
+ DoubleEntry.transfer(Money.new(100_00), :from => ryans_cash, :to => ryans_savings, :code => :xfer)
399
+ expect(ryans_cash.balance).to eq -Money.new(100_00)
400
+ expect(ryans_savings.balance).to eq Money.new(100_00)
401
+ end
402
+
403
+ it 'allows you to report on scoped accounts globally' do
404
+ expect(DoubleEntry.balance(:cash)).to eq ryans_cash.balance + johns_cash.balance
405
+ expect(DoubleEntry.balance(:savings)).to eq ryans_savings.balance + johns_savings.balance
406
+ end
407
+ end
408
+
409
+ end
@@ -1,26 +1,25 @@
1
1
  # encoding: utf-8
2
- # These make it easier to quickly set up account balances for testing.
3
-
4
- # user scoping magic, accepts a User, Fixnum, or String
5
2
  user_scope = lambda do |user_identifier|
6
- if user_identifier.is_a?(Fixnum) or user_identifier.is_a?(String)
7
- user_identifier
8
- elsif user_identifier.is_a?(User)
3
+ if user_identifier.is_a?(User)
9
4
  user_identifier.id
10
5
  else
11
- raise "unknown type expected fixnum, string or user, got: #{user_identifier.inspect}"
6
+ user_identifier
12
7
  end
13
8
  end
14
9
 
15
- # A set of accounts to test with
16
- DoubleEntry.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
17
- accounts << DoubleEntry::Account.new(:identifier => :savings, :scope_identifier => user_scope, :positive_only => true)
18
- accounts << DoubleEntry::Account.new(:identifier => :checking, :scope_identifier => user_scope, :positive_only => true)
19
- accounts << DoubleEntry::Account.new(:identifier => :test, :scope_identifier => user_scope)
20
- end
10
+ DoubleEntry.configure do |config|
11
+
12
+ # A set of accounts to test with
13
+ config.define_accounts do |accounts|
14
+ accounts.define(:identifier => :savings, :scope_identifier => user_scope, :positive_only => true)
15
+ accounts.define(:identifier => :checking, :scope_identifier => user_scope, :positive_only => true)
16
+ accounts.define(:identifier => :test, :scope_identifier => user_scope)
17
+ end
18
+
19
+ # A set of allowed transfers between accounts
20
+ config.define_transfers do |transfers|
21
+ transfers.define(:from => :test, :to => :savings, :code => :bonus)
22
+ transfers.define(:from => :test, :to => :checking, :code => :pay)
23
+ end
21
24
 
22
- # A set of allowed transfers between accounts
23
- DoubleEntry.transfers = DoubleEntry::Transfer::Set.new.tap do |transfers|
24
- transfers << DoubleEntry::Transfer.new(:from => :test, :to => :savings, :code => :bonus)
25
- transfers << DoubleEntry::Transfer.new(:from => :test, :to => :checking, :code => :pay)
26
25
  end