double_entry 0.1.0 → 0.2.0

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