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

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