double_entry 0.0.1.pre → 0.1.0.pre.pre.alpha

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +13 -5
  2. data/.gitignore +5 -6
  3. data/.rspec +1 -0
  4. data/.travis.yml +19 -0
  5. data/.yardopts +2 -0
  6. data/Gemfile +0 -1
  7. data/LICENSE.md +19 -0
  8. data/README.md +221 -14
  9. data/Rakefile +12 -0
  10. data/double_entry.gemspec +30 -15
  11. data/gemfiles/Gemfile.rails-3.2.0 +5 -0
  12. data/gemfiles/Gemfile.rails-4.0.0 +5 -0
  13. data/gemfiles/Gemfile.rails-4.1.0 +5 -0
  14. data/lib/active_record/locking_extensions.rb +61 -0
  15. data/lib/double_entry.rb +267 -2
  16. data/lib/double_entry/account.rb +82 -0
  17. data/lib/double_entry/account_balance.rb +31 -0
  18. data/lib/double_entry/aggregate.rb +118 -0
  19. data/lib/double_entry/aggregate_array.rb +65 -0
  20. data/lib/double_entry/configurable.rb +52 -0
  21. data/lib/double_entry/day_range.rb +38 -0
  22. data/lib/double_entry/hour_range.rb +40 -0
  23. data/lib/double_entry/line.rb +147 -0
  24. data/lib/double_entry/line_aggregate.rb +37 -0
  25. data/lib/double_entry/line_check.rb +118 -0
  26. data/lib/double_entry/locking.rb +187 -0
  27. data/lib/double_entry/month_range.rb +92 -0
  28. data/lib/double_entry/reporting.rb +16 -0
  29. data/lib/double_entry/time_range.rb +55 -0
  30. data/lib/double_entry/time_range_array.rb +43 -0
  31. data/lib/double_entry/transfer.rb +70 -0
  32. data/lib/double_entry/version.rb +3 -1
  33. data/lib/double_entry/week_range.rb +99 -0
  34. data/lib/double_entry/year_range.rb +39 -0
  35. data/lib/generators/double_entry/install/install_generator.rb +22 -0
  36. data/lib/generators/double_entry/install/templates/migration.rb +68 -0
  37. data/script/jack_hammer +201 -0
  38. data/script/setup.sh +8 -0
  39. data/spec/active_record/locking_extensions_spec.rb +54 -0
  40. data/spec/double_entry/account_balance_spec.rb +8 -0
  41. data/spec/double_entry/account_spec.rb +23 -0
  42. data/spec/double_entry/aggregate_array_spec.rb +75 -0
  43. data/spec/double_entry/aggregate_spec.rb +168 -0
  44. data/spec/double_entry/double_entry_spec.rb +391 -0
  45. data/spec/double_entry/line_aggregate_spec.rb +8 -0
  46. data/spec/double_entry/line_check_spec.rb +88 -0
  47. data/spec/double_entry/line_spec.rb +72 -0
  48. data/spec/double_entry/locking_spec.rb +154 -0
  49. data/spec/double_entry/month_range_spec.rb +131 -0
  50. data/spec/double_entry/reporting_spec.rb +25 -0
  51. data/spec/double_entry/time_range_array_spec.rb +149 -0
  52. data/spec/double_entry/time_range_spec.rb +43 -0
  53. data/spec/double_entry/week_range_spec.rb +88 -0
  54. data/spec/generators/double_entry/install/install_generator_spec.rb +33 -0
  55. data/spec/spec_helper.rb +47 -0
  56. data/spec/support/accounts.rb +26 -0
  57. data/spec/support/blueprints.rb +34 -0
  58. data/spec/support/database.example.yml +16 -0
  59. data/spec/support/database.travis.yml +18 -0
  60. data/spec/support/double_entry_spec_helper.rb +19 -0
  61. data/spec/support/reporting_configuration.rb +6 -0
  62. data/spec/support/schema.rb +71 -0
  63. metadata +277 -18
  64. data/LICENSE.txt +0 -22
@@ -0,0 +1,54 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe ActiveRecord::LockingExtensions do
5
+ PG_DEADLOCK = ActiveRecord::StatementInvalid.new("PG::Error: ERROR: deadlock detected")
6
+ MYSQL_DEADLOCK = ActiveRecord::StatementInvalid.new("Mysql::Error: Deadlock found when trying to get lock")
7
+
8
+ context "#restartable_transaction" do
9
+ it "keeps running the lock until a ActiveRecord::RestartTransaction isn't raised" do
10
+ expect(User).to receive(:create!).ordered.and_raise(ActiveRecord::RestartTransaction)
11
+ expect(User).to receive(:create!).ordered.and_raise(ActiveRecord::RestartTransaction)
12
+ expect(User).to receive(:create!).ordered.and_return(true)
13
+
14
+ expect { User.restartable_transaction { User.create! } }.to_not raise_error
15
+ end
16
+ end
17
+
18
+ context "#with_restart_on_deadlock" do
19
+ context "raises a ActiveRecord::RestartTransaction error if a deadlock occurs" do
20
+ it "in mysql" do
21
+ expect { User.with_restart_on_deadlock { raise MYSQL_DEADLOCK } }.to raise_error(ActiveRecord::RestartTransaction)
22
+ end
23
+
24
+ it "in postgres" do
25
+ expect { User.with_restart_on_deadlock { raise PG_DEADLOCK } }.to raise_error(ActiveRecord::RestartTransaction)
26
+ end
27
+ end
28
+ end
29
+
30
+ context "#create_ignoring_duplicates" do
31
+ it "does not raise an error if a duplicate index error is raised in the database" do
32
+ User.make! :username => "keith"
33
+
34
+ expect { User.make! :username => "keith" }.to raise_error
35
+ expect { User.create_ignoring_duplicates! :username => "keith" }.to_not raise_error
36
+ end
37
+
38
+ context "retries the creation if a deadlock error is raised from the database" do
39
+ it "in mysql" do
40
+ expect(User).to receive(:create!).ordered.and_raise(MYSQL_DEADLOCK)
41
+ expect(User).to receive(:create!).ordered.and_return(true)
42
+
43
+ expect { User.create_ignoring_duplicates! }.to_not raise_error
44
+ end
45
+
46
+ it "in postgres" do
47
+ expect(User).to receive(:create!).ordered.and_raise(PG_DEADLOCK)
48
+ expect(User).to receive(:create!).ordered.and_return(true)
49
+
50
+ expect { User.create_ignoring_duplicates! }.to_not raise_error
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,8 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+ describe DoubleEntry::AccountBalance do
4
+
5
+ it "has a table name prefixed with double_entry_" do
6
+ expect(DoubleEntry::AccountBalance.table_name).to eq "double_entry_account_balances"
7
+ end
8
+ end
@@ -0,0 +1,23 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+ describe DoubleEntry::Account do
4
+ let(:empty_scope) { lambda {|value| value } }
5
+
6
+ it "instances should be sortable" do
7
+ account = DoubleEntry::Account.new(:identifier => "savings", :scope_identifier => empty_scope)
8
+ a = DoubleEntry::Account::Instance.new(:account => account, :scope => "123")
9
+ b = DoubleEntry::Account::Instance.new(:account => account, :scope => "456")
10
+ expect([b, a].sort).to eq [a, b]
11
+ end
12
+
13
+ it "instances should be hashable" do
14
+ account = DoubleEntry::Account.new(:identifier => "savings", :scope_identifier => empty_scope)
15
+ a1 = DoubleEntry::Account::Instance.new(:account => account, :scope => "123")
16
+ a2 = DoubleEntry::Account::Instance.new(:account => account, :scope => "123")
17
+ b = DoubleEntry::Account::Instance.new(:account => account, :scope => "456")
18
+
19
+ expect(a1.hash).to eq a2.hash
20
+ expect(a1.hash).to_not eq b.hash
21
+ end
22
+
23
+ end
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+ describe DoubleEntry::AggregateArray do
3
+
4
+ let(:user) { User.make! }
5
+ let(:start) { nil }
6
+ let(:finish) { nil }
7
+ let(:range_type) { 'year' }
8
+
9
+ subject(:aggregate_array) {
10
+ DoubleEntry.aggregate_array(
11
+ :sum,
12
+ :savings,
13
+ :bonus,
14
+ :range_type => range_type,
15
+ :start => start,
16
+ :finish => finish,
17
+ )
18
+ }
19
+
20
+ context 'given a deposit was made in 2007 and 2008' do
21
+ before do
22
+ Timecop.travel(Time.local(2007)) do
23
+ perform_deposit user, 10_00
24
+ end
25
+ Timecop.travel(Time.local(2008)) do
26
+ perform_deposit user, 20_00
27
+ end
28
+ end
29
+
30
+ context 'given the date is 2009-03-19' do
31
+ before { Timecop.travel(Time.local(2009, 3, 19)) }
32
+
33
+ context 'when called with range type of "year"' do
34
+ let(:range_type) { 'year' }
35
+ let(:start) { '2006-08-03' }
36
+ it { should eq [ Money.new(0), Money.new(10_00), Money.new(20_00), Money.new(0) ] }
37
+ end
38
+ end
39
+ end
40
+
41
+ context 'given a deposit was made in October and December 2006' do
42
+ before do
43
+ Timecop.travel(Time.local(2006, 10)) do
44
+ perform_deposit user, 10_00
45
+ end
46
+ Timecop.travel(Time.local(2006, 12)) do
47
+ perform_deposit user, 20_00
48
+ end
49
+ end
50
+
51
+ context 'when called with range type of "month", a start of "2006-09-01", and finish of "2007-01-02"' do
52
+ let(:range_type) { 'month' }
53
+ let(:start) { '2006-09-01' }
54
+ let(:finish) { '2007-01-02' }
55
+ it { should eq [ Money.new(0), Money.new(10_00), Money.new(0), Money.new(20_00), Money.new(0), ] }
56
+ end
57
+
58
+ context 'given the date is 2007-02-02' do
59
+ before { Timecop.travel(Time.local(2007, 2, 2)) }
60
+
61
+ context 'when called with range type of "month"' do
62
+ let(:range_type) { 'month' }
63
+ let(:start) { '2006-08-03' }
64
+ it { should eq [ Money.new(0), Money.new(0), Money.new(10_00), Money.new(0), Money.new(20_00), Money.new(0), Money.new(0) ] }
65
+ end
66
+ end
67
+ end
68
+
69
+ context 'when called with range type of "invalid_and_should_not_work"' do
70
+ let(:range_type) { 'invalid_and_should_not_work' }
71
+ it 'should raise an argument error' do
72
+ expect { aggregate_array }.to raise_error ArgumentError, "Invalid range type 'invalid_and_should_not_work'"
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,168 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe DoubleEntry::Aggregate do
5
+
6
+ let(:user) { User.make! }
7
+
8
+ before do
9
+ # Thursday
10
+ Timecop.freeze Time.local(2009, 10, 1) do
11
+ perform_deposit user, 20_00
12
+ end
13
+
14
+ # Saturday
15
+ Timecop.freeze Time.local(2009, 10, 3) do
16
+ perform_deposit user, 40_00
17
+ end
18
+
19
+ Timecop.freeze Time.local(2009, 10, 10) do
20
+ perform_deposit user, 50_00
21
+ end
22
+ Timecop.freeze Time.local(2009, 11, 1, 0, 59, 0) do
23
+ perform_deposit user, 40_00
24
+ end
25
+ Timecop.freeze Time.local(2009, 11, 1, 1, 00, 0) do
26
+ perform_deposit user, 50_00
27
+ end
28
+ end
29
+
30
+ it 'should store the aggregate for quick retrieval' do
31
+ DoubleEntry.aggregate(:sum, :savings, :bonus,
32
+ :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 10))
33
+ expect(DoubleEntry::LineAggregate.count).to eq 1
34
+ end
35
+
36
+ it 'should only store the aggregate once if it is requested more than once' do
37
+ DoubleEntry.aggregate(:sum, :savings, :bonus,
38
+ :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 9))
39
+ DoubleEntry.aggregate(:sum, :savings, :bonus,
40
+ :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 9))
41
+ DoubleEntry.aggregate(:sum, :savings, :bonus,
42
+ :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 10))
43
+ expect(DoubleEntry::LineAggregate.count).to eq 2
44
+ end
45
+
46
+ it 'should calculate the complete year correctly' do
47
+ expect(
48
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009))
49
+ ).to eq Money.new(200_00)
50
+ end
51
+
52
+ it 'should calculate seperate months correctly' do
53
+ expect(
54
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 10))
55
+ ).to eq Money.new(110_00)
56
+ expect(
57
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 11))
58
+ ).to eq Money.new(90_00)
59
+ end
60
+
61
+ it 'should calculate seperate weeks correctly' do
62
+ # Week 40 - Mon Sep 28, 2009 to Sun Oct 4 2009
63
+ expect(
64
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :week => 40))
65
+ ).to eq Money.new(60_00)
66
+ end
67
+
68
+ it 'should calculate seperate days correctly' do
69
+ # 1 Nov 2009
70
+ expect(
71
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :week => 44, :day => 7))
72
+ ).to eq Money.new(90_00)
73
+ end
74
+
75
+ it 'should calculate seperate hours correctly' do
76
+ # 1 Nov 2009
77
+ expect(
78
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :week => 44, :day => 7, :hour => 0))
79
+ ).to eq Money.new(40_00)
80
+ expect(
81
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :week => 44, :day => 7, :hour => 1))
82
+ ).to eq Money.new(50_00)
83
+ end
84
+
85
+ it 'should calculate, but not store aggregates when the time range is still current' do
86
+ Timecop.freeze Time.local(2009, 11, 21) do
87
+ expect(
88
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 11))
89
+ ).to eq Money.new(90_00)
90
+ expect(DoubleEntry::LineAggregate.count).to eq 0
91
+ end
92
+ end
93
+
94
+ it 'should calculate, but not store aggregates when the time range is in the future' do
95
+ Timecop.freeze Time.local(2009, 11, 21) do
96
+ expect(
97
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 12))
98
+ ).to eq Money.new(0)
99
+ expect(DoubleEntry::LineAggregate.count).to eq 0
100
+ end
101
+ end
102
+
103
+ it 'should calculate monthly all_time ranges correctly' do
104
+ expect(
105
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :month => 12, :range_type => :all_time))
106
+ ).to eq Money.new(200_00)
107
+ end
108
+
109
+ it 'should calculate weekly all_time ranges correctly' do
110
+ expect(
111
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => DoubleEntry::TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
112
+ ).to eq Money.new(110_00)
113
+ end
114
+
115
+ context 'filters' do
116
+
117
+ let(:range) { DoubleEntry::TimeRange.make(:year => 2011, :month => 10) }
118
+
119
+ class ::DoubleEntry::Line
120
+ scope :test_filter, -> { where(:amount => 10_00) }
121
+ end
122
+
123
+ before do
124
+ Timecop.freeze Time.local(2011, 10, 10) do
125
+ perform_deposit user, 10_00
126
+ end
127
+
128
+ Timecop.freeze Time.local(2011, 10, 10) do
129
+ perform_deposit user, 9_00
130
+ end
131
+ end
132
+
133
+ it 'saves filtered aggregations' do
134
+ expect {
135
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range, :filter => [:test_filter])
136
+ }.to change { DoubleEntry::LineAggregate.count }.by 1
137
+ end
138
+
139
+ it 'saves filtered aggregation only once for a range' do
140
+ expect {
141
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range, :filter => [:test_filter])
142
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range, :filter => [:test_filter])
143
+ }.to change { DoubleEntry::LineAggregate.count }.by 1
144
+ end
145
+
146
+ it 'saves filtered aggregations and non filtered aggregations separately' do
147
+ expect {
148
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range, :filter => [:test_filter])
149
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range)
150
+ }.to change { DoubleEntry::LineAggregate.count }.by 2
151
+ end
152
+
153
+ it 'loads the correct saved aggregation' do
154
+
155
+ # cache the results for filtered and unfiltered aggregations
156
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range, :filter => [:test_filter])
157
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range)
158
+
159
+ # ensure a second call loads the correct cached value
160
+ expect(
161
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range, :filter => [:test_filter])
162
+ ).to eq Money.new(10_00)
163
+ expect(
164
+ DoubleEntry.aggregate(:sum, :savings, :bonus, :range => range)
165
+ ).to eq Money.new(19_00)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,391 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe DoubleEntry do
5
+
6
+ # these specs blat the DoubleEntry configuration, so take
7
+ # a copy and clean up after ourselves
8
+ before do
9
+ @config_accounts = DoubleEntry.accounts
10
+ @config_transfers = DoubleEntry.transfers
11
+ end
12
+
13
+ after do
14
+ DoubleEntry.accounts = @config_accounts
15
+ DoubleEntry.transfers = @config_transfers
16
+ end
17
+
18
+
19
+ describe 'configuration' do
20
+ it 'checks for duplicates of accounts' do
21
+ expect do
22
+ DoubleEntry.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
23
+ accounts << DoubleEntry::Account.new(:identifier => :gah!)
24
+ accounts << DoubleEntry::Account.new(:identifier => :gah!)
25
+ end
26
+ end.to raise_error(DoubleEntry::DuplicateAccount)
27
+ end
28
+
29
+ it 'checks for duplicates of transfers' do
30
+ expect do
31
+ DoubleEntry.transfers = DoubleEntry::Transfer::Set.new.tap do |transfers|
32
+ transfers << DoubleEntry::Transfer.new(:from => :savings, :to => :cash, :code => :xfer)
33
+ transfers << DoubleEntry::Transfer.new(:from => :savings, :to => :cash, :code => :xfer)
34
+ end
35
+ end.to raise_error(DoubleEntry::DuplicateTransfer)
36
+ end
37
+ end
38
+
39
+ describe 'accounts' do
40
+ before do
41
+ @scope = double('a scope', :id => 1)
42
+
43
+ DoubleEntry.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
44
+ accounts << DoubleEntry::Account.new(:identifier => :unscoped)
45
+ accounts << DoubleEntry::Account.new(:identifier => :scoped, :scope_identifier => lambda { |u| u.id })
46
+ end
47
+ end
48
+
49
+ describe 'fetching' do
50
+ it 'can find an unscoped account by identifier' do
51
+ expect(DoubleEntry.account(:unscoped)).to_not be_nil
52
+ end
53
+
54
+ it 'can find a scoped account by identifier' do
55
+ expect(DoubleEntry.account(:scoped, :scope => @scope)).to_not be_nil
56
+ end
57
+
58
+ it 'raises an exception when it cannot find an account' do
59
+ expect { DoubleEntry.account(:invalid) }.to raise_error(DoubleEntry::UnknownAccount)
60
+ end
61
+
62
+ it 'raises exception when you ask for an unscoped account w/ scope' do
63
+ expect { DoubleEntry.account(:unscoped, :scope => @scope) }.to raise_error(DoubleEntry::UnknownAccount)
64
+ end
65
+
66
+ it 'raises exception when you ask for a scoped account w/ out scope' do
67
+ expect { DoubleEntry.account(:scoped) }.to raise_error(DoubleEntry::UnknownAccount)
68
+ end
69
+ end
70
+
71
+ context "an unscoped account" do
72
+ subject(:unscoped) { DoubleEntry.account(:unscoped) }
73
+
74
+ it "has an identifier" do
75
+ expect(unscoped.identifier).to eq :unscoped
76
+ end
77
+ end
78
+ context "a scoped account" do
79
+ subject(:scoped) { DoubleEntry.account(:scoped, :scope => @scope) }
80
+
81
+ it "has an identifier" do
82
+ expect(scoped.identifier).to eq :scoped
83
+ end
84
+ end
85
+ end
86
+
87
+ describe 'transfers' do
88
+ before do
89
+ DoubleEntry.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
90
+ accounts << DoubleEntry::Account.new(:identifier => :savings)
91
+ accounts << DoubleEntry::Account.new(:identifier => :cash)
92
+ accounts << DoubleEntry::Account.new(:identifier => :trash)
93
+ end
94
+
95
+ DoubleEntry.transfers = DoubleEntry::Transfer::Set.new.tap do |transfers|
96
+ transfers << DoubleEntry::Transfer.new(:from => :savings, :to => :cash, :code => :xfer, :meta_requirement => [:ref])
97
+ end
98
+
99
+ @savings = DoubleEntry.account(:savings)
100
+ @cash = DoubleEntry.account(:cash)
101
+ @trash = DoubleEntry.account(:trash)
102
+ end
103
+
104
+ it 'can transfer from an account to an account, if the transfer is allowed' do
105
+ expect do
106
+ DoubleEntry.transfer(Money.new(100_00), :from => @savings, :to => @cash, :code => :xfer, :meta => {:ref => 'shopping!'})
107
+ end.to_not raise_error
108
+ end
109
+
110
+ it 'raises an exception when the transfer is not allowed (wrong direction)' do
111
+ expect do
112
+ DoubleEntry.transfer(Money.new(100_00), :from => @cash, :to => @savings, :code => :xfer)
113
+ end.to raise_error(DoubleEntry::TransferNotAllowed)
114
+ end
115
+
116
+ it 'raises an exception when the transfer is not allowed (wrong code)' do
117
+ expect do
118
+ DoubleEntry.transfer(Money.new(100_00), :from => @savings, :to => @cash, :code => :yfer, :meta => {:ref => 'shopping!'})
119
+ end.to raise_error(DoubleEntry::TransferNotAllowed)
120
+ end
121
+
122
+ it 'raises an exception when the transfer is not allowed (does not exist, at all)' do
123
+ expect do
124
+ DoubleEntry.transfer(Money.new(100_00), :from => @cash, :to => @trash)
125
+ end.to raise_error(DoubleEntry::TransferNotAllowed)
126
+ end
127
+
128
+ it 'raises an exception when required meta data is omitted' do
129
+ expect do
130
+ DoubleEntry.transfer(Money.new(100_00), :from => @savings, :to => @cash, :code => :xfer, :meta => {})
131
+ end.to raise_error(DoubleEntry::RequiredMetaMissing)
132
+ end
133
+ end
134
+
135
+ describe 'lines' do
136
+ before do
137
+ DoubleEntry.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
138
+ accounts << DoubleEntry::Account.new(:identifier => :a)
139
+ accounts << DoubleEntry::Account.new(:identifier => :b)
140
+ end
141
+
142
+ DoubleEntry.transfers = DoubleEntry::Transfer::Set.new.tap do |transfers|
143
+ description = lambda do |line|
144
+ "Money goes #{line.credit? ? 'out' : 'in'}: #{line.amount.format}"
145
+ end
146
+ transfers << DoubleEntry::Transfer.new(:code => :xfer, :from => :a, :to => :b, :description => description)
147
+ end
148
+
149
+ @a, @b = DoubleEntry.account(:a), DoubleEntry.account(:b)
150
+ DoubleEntry.transfer(Money.new(10_00), :from => @a, :to => @b, :code => :xfer)
151
+ @credit = lines_for_account(@a).first
152
+ @debit = lines_for_account(@b).first
153
+ end
154
+
155
+ it 'has an amount' do
156
+ expect(@credit.amount).to eq -Money.new(10_00)
157
+ expect(@debit.amount).to eq Money.new(10_00)
158
+ end
159
+
160
+ it 'has a code' do
161
+ expect(@credit.code).to eq :xfer
162
+ expect(@debit.code).to eq :xfer
163
+ end
164
+
165
+ it 'auto-sets scope when assigning account (and partner_accout, is this implementation?)' do
166
+ expect(@credit[:account]).to eq 'a'
167
+ expect(@credit[:scope]).to be_nil
168
+ expect(@credit[:partner_account]).to eq 'b'
169
+ expect(@credit[:partner_scope]).to be_nil
170
+ end
171
+
172
+ it 'has a partner_account (or is this implementation?)' do
173
+ expect(@credit.partner_account).to eq @debit.account
174
+ end
175
+
176
+ it 'knows if it is a credit or debit' do
177
+ expect(@credit).to be_credit
178
+ expect(@debit).to be_debit
179
+ expect(@credit).to_not be_debit
180
+ expect(@debit).to_not be_credit
181
+ end
182
+
183
+ it 'can describe itself' do
184
+ expect(@credit.description).to eq 'Money goes out: $-10.00'
185
+ expect(@debit.description).to eq 'Money goes in: $10.00'
186
+ end
187
+
188
+ it 'can reference its partner' do
189
+ expect(@credit.partner).to eq @debit
190
+ expect(@debit.partner).to eq @credit
191
+ end
192
+
193
+ it 'can ask for its pair (credit always coming first)' do
194
+ expect(@credit.pair).to eq [@credit, @debit]
195
+ expect(@debit.pair).to eq [@credit, @debit]
196
+ end
197
+
198
+ it 'can ask for the account (and get an instance)' do
199
+ expect(@credit.account).to eq @a
200
+ expect(@debit.account).to eq @b
201
+ end
202
+ end
203
+
204
+ describe 'balances' do
205
+ before do
206
+ DoubleEntry.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
207
+ accounts << DoubleEntry::Account.new(:identifier => :work)
208
+ accounts << DoubleEntry::Account.new(:identifier => :cash)
209
+ accounts << DoubleEntry::Account.new(:identifier => :savings)
210
+ accounts << DoubleEntry::Account.new(:identifier => :store)
211
+ end
212
+
213
+ DoubleEntry.transfers = DoubleEntry::Transfer::Set.new.tap do |transfers|
214
+ transfers << DoubleEntry::Transfer.new(:code => :salary, :from => :work, :to => :cash)
215
+ transfers << DoubleEntry::Transfer.new(:code => :xfer, :from => :cash, :to => :savings)
216
+ transfers << DoubleEntry::Transfer.new(:code => :xfer, :from => :savings, :to => :cash)
217
+ transfers << DoubleEntry::Transfer.new(:code => :purchase, :from => :cash, :to => :store)
218
+ transfers << DoubleEntry::Transfer.new(:code => :layby, :from => :cash, :to => :store)
219
+ transfers << DoubleEntry::Transfer.new(:code => :deposit, :from => :cash, :to => :store)
220
+ end
221
+
222
+ @work = DoubleEntry.account(:work)
223
+ @savings = DoubleEntry.account(:savings)
224
+ @cash = DoubleEntry.account(:cash)
225
+ @store = DoubleEntry.account(:store)
226
+
227
+ Timecop.freeze 3.weeks.ago+1.day do
228
+ # got paid from work
229
+ DoubleEntry.transfer(Money.new(1_000_00), :from => @work, :code => :salary, :to => @cash)
230
+ # transfer half salary into savings
231
+ DoubleEntry.transfer(Money.new(500_00), :from => @cash, :code => :xfer, :to => @savings)
232
+ end
233
+
234
+ Timecop.freeze 2.weeks.ago+1.day do
235
+ # got myself a darth vader helmet
236
+ DoubleEntry.transfer(Money.new(200_00), :from => @cash, :code => :purchase, :to => @store)
237
+ # paid off some of my darth vader suit layby (to go with the helmet)
238
+ DoubleEntry.transfer(Money.new(100_00), :from => @cash, :code => :layby, :to => @store)
239
+ # put a deposit on the darth vader voice changer module (for the helmet)
240
+ DoubleEntry.transfer(Money.new(100_00), :from => @cash, :code => :deposit, :to => @store)
241
+ end
242
+
243
+ Timecop.freeze 1.week.ago+1.day do
244
+ # transfer 200 out of savings
245
+ DoubleEntry.transfer(Money.new(200_00), :from => @savings, :code => :xfer, :to => @cash)
246
+ # pay the remaining balance on the darth vader voice changer module
247
+ DoubleEntry.transfer(Money.new(200_00), :from => @cash, :code => :purchase, :to => @store)
248
+ end
249
+
250
+ Timecop.freeze 1.week.from_now do
251
+ # go to the star wars convention AND ROCK OUT IN YOUR ACE DARTH VADER COSTUME!!!
252
+ end
253
+ end
254
+
255
+ it 'has the initial balances that we expect' do
256
+ expect(@work.balance).to eq -Money.new(1_000_00)
257
+ expect(@cash.balance).to eq Money.new(100_00)
258
+ expect(@savings.balance).to eq Money.new(300_00)
259
+ expect(@store.balance).to eq Money.new(600_00)
260
+ end
261
+
262
+ it 'should have correct account balance records' do
263
+ [@work, @cash, @savings, @store].each do |account|
264
+ expect(DoubleEntry::AccountBalance.find_by_account(account).balance).to eq account.balance
265
+ end
266
+ end
267
+
268
+ it 'affects origin/destination balance after transfer' do
269
+ @savings_balance = @savings.balance
270
+ @cash_balance = @cash.balance
271
+ @amount = Money.new(10_00)
272
+
273
+ DoubleEntry.transfer(@amount, :from => @savings, :code => :xfer, :to => @cash)
274
+
275
+ expect(@savings.balance).to eq @savings_balance - @amount
276
+ expect(@cash.balance).to eq @cash_balance + @amount
277
+ end
278
+
279
+ it 'can be queried at a given point in time' do
280
+ expect(@cash.balance(:at => 1.week.ago)).to eq Money.new(100_00)
281
+ end
282
+
283
+ it 'can be queries between two points in time' do
284
+ expect(@cash.balance(:from => 3.weeks.ago, :to => 2.weeks.ago)).to eq Money.new(500_00)
285
+ end
286
+
287
+ it 'can report on balances, scoped by code' do
288
+ expect(@cash.balance(:code => :salary)).to eq Money.new(1_000_00)
289
+ end
290
+
291
+ it 'can report on balances, scoped by many codes' do
292
+ expect(@store.balance(:codes => [:layby, :deposit])).to eq Money.new(200_00)
293
+ end
294
+
295
+ it 'has running balances for each line' do
296
+ @lines = lines_for_account(@cash)
297
+ expect(@lines[0].balance).to eq Money.new(1_000_00) # salary
298
+ expect(@lines[1].balance).to eq Money.new(500_00) # savings
299
+ expect(@lines[2].balance).to eq Money.new(300_00) # purchase
300
+ expect(@lines[3].balance).to eq Money.new(200_00) # layby
301
+ expect(@lines[4].balance).to eq Money.new(100_00) # deposit
302
+ expect(@lines[5].balance).to eq Money.new(300_00) # savings
303
+ expect(@lines[6].balance).to eq Money.new(100_00) # purchase
304
+ end
305
+ end
306
+
307
+ describe 'scoping of accounts' do
308
+ before do
309
+ DoubleEntry.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
310
+ accounts << DoubleEntry::Account.new(:identifier => :bank)
311
+ accounts << DoubleEntry::Account.new(:identifier => :cash, :scope_identifier => lambda { |user| user.id })
312
+ accounts << DoubleEntry::Account.new(:identifier => :savings, :scope_identifier => lambda { |user| user.id })
313
+ end
314
+
315
+ DoubleEntry.transfers = DoubleEntry::Transfer::Set.new.tap do |transfers|
316
+ transfers << DoubleEntry::Transfer.new(:from => :bank, :to => :cash, :code => :xfer)
317
+ transfers << DoubleEntry::Transfer.new(:from => :cash, :to => :cash, :code => :xfer)
318
+ transfers << DoubleEntry::Transfer.new(:from => :cash, :to => :savings, :code => :xfer)
319
+ end
320
+
321
+ @john = User.make!
322
+ @ryan = User.make!
323
+
324
+ @bank = DoubleEntry.account(:bank)
325
+ @johns_cash = DoubleEntry.account(:cash, :scope => @john)
326
+ @johns_savings = DoubleEntry.account(:savings, :scope => @john)
327
+ @ryans_cash = DoubleEntry.account(:cash, :scope => @ryan)
328
+ @ryans_savings = DoubleEntry.account(:savings, :scope => @ryan)
329
+ end
330
+
331
+ it 'treats each separately scoped account having their own separate balances' do
332
+ DoubleEntry.transfer(Money.new(20_00), :from => @bank, :to => @johns_cash, :code => :xfer)
333
+ DoubleEntry.transfer(Money.new(10_00), :from => @bank, :to => @ryans_cash, :code => :xfer)
334
+ expect(@johns_cash.balance).to eq Money.new(20_00)
335
+ expect(@ryans_cash.balance).to eq Money.new(10_00)
336
+ end
337
+
338
+ it 'allows transfer between two separately scoped accounts' do
339
+ DoubleEntry.transfer(Money.new(10_00), :from => @ryans_cash, :to => @johns_cash, :code => :xfer)
340
+ expect(@ryans_cash.balance).to eq -Money.new(10_00)
341
+ expect(@johns_cash.balance).to eq Money.new(10_00)
342
+ end
343
+
344
+ it 'reports balance correctly if called from either account or finances object' do
345
+ DoubleEntry.transfer(Money.new(10_00), :from => @ryans_cash, :to => @johns_cash, :code => :xfer)
346
+ expect(@ryans_cash.balance).to eq -Money.new(10_00)
347
+ expect(DoubleEntry.balance(:cash, :scope => @ryan)).to eq -Money.new(10_00)
348
+ end
349
+
350
+ it 'raises exception if you try to transfer between the same account, despite it being scoped' do
351
+ expect do
352
+ DoubleEntry.transfer(Money.new(10_00), :from => @ryans_cash, :to => @ryans_cash, :code => :xfer)
353
+ end.to raise_error(DoubleEntry::TransferNotAllowed)
354
+ end
355
+
356
+ it 'allows transfer from one persons account to the same persons other kind of account' do
357
+ DoubleEntry.transfer(Money.new(100_00), :from => @ryans_cash, :to => @ryans_savings, :code => :xfer)
358
+ expect(@ryans_cash.balance).to eq -Money.new(100_00)
359
+ expect(@ryans_savings.balance).to eq Money.new(100_00)
360
+ end
361
+
362
+ it 'allows you to report on scoped accounts globally' do
363
+ expect(DoubleEntry.balance(:cash)).to eq @ryans_cash.balance+@johns_cash.balance
364
+ expect(DoubleEntry.balance(:savings)).to eq @ryans_savings.balance+@johns_savings.balance
365
+ end
366
+ end
367
+
368
+ describe "::scopes_with_minimum_balance_for_account" do
369
+ subject(:scopes) { DoubleEntry.scopes_with_minimum_balance_for_account(minimum_balance, :checking) }
370
+
371
+ context "a 'checking' account with balance $100" do
372
+ let!(:user) { User.make!(:checking_balance => Money.new(100_00)) }
373
+
374
+ context "when searching for balance $99" do
375
+ let(:minimum_balance) { Money.new(99_00) }
376
+ it { should include user.id }
377
+ end
378
+
379
+ context "when searching for balance $100" do
380
+ let(:minimum_balance) { Money.new(100_00) }
381
+ it { should include user.id }
382
+ end
383
+
384
+ context "when searching for balance $101" do
385
+ let(:minimum_balance) { Money.new(101_00) }
386
+ it { should_not include user.id }
387
+ end
388
+ end
389
+ end
390
+
391
+ end