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.
- checksums.yaml +13 -5
- data/.gitignore +5 -6
- data/.rspec +1 -0
- data/.travis.yml +19 -0
- data/.yardopts +2 -0
- data/Gemfile +0 -1
- data/LICENSE.md +19 -0
- data/README.md +221 -14
- data/Rakefile +12 -0
- data/double_entry.gemspec +30 -15
- data/gemfiles/Gemfile.rails-3.2.0 +5 -0
- data/gemfiles/Gemfile.rails-4.0.0 +5 -0
- data/gemfiles/Gemfile.rails-4.1.0 +5 -0
- data/lib/active_record/locking_extensions.rb +61 -0
- data/lib/double_entry.rb +267 -2
- data/lib/double_entry/account.rb +82 -0
- data/lib/double_entry/account_balance.rb +31 -0
- data/lib/double_entry/aggregate.rb +118 -0
- data/lib/double_entry/aggregate_array.rb +65 -0
- data/lib/double_entry/configurable.rb +52 -0
- data/lib/double_entry/day_range.rb +38 -0
- data/lib/double_entry/hour_range.rb +40 -0
- data/lib/double_entry/line.rb +147 -0
- data/lib/double_entry/line_aggregate.rb +37 -0
- data/lib/double_entry/line_check.rb +118 -0
- data/lib/double_entry/locking.rb +187 -0
- data/lib/double_entry/month_range.rb +92 -0
- data/lib/double_entry/reporting.rb +16 -0
- data/lib/double_entry/time_range.rb +55 -0
- data/lib/double_entry/time_range_array.rb +43 -0
- data/lib/double_entry/transfer.rb +70 -0
- data/lib/double_entry/version.rb +3 -1
- data/lib/double_entry/week_range.rb +99 -0
- data/lib/double_entry/year_range.rb +39 -0
- data/lib/generators/double_entry/install/install_generator.rb +22 -0
- data/lib/generators/double_entry/install/templates/migration.rb +68 -0
- data/script/jack_hammer +201 -0
- data/script/setup.sh +8 -0
- data/spec/active_record/locking_extensions_spec.rb +54 -0
- data/spec/double_entry/account_balance_spec.rb +8 -0
- data/spec/double_entry/account_spec.rb +23 -0
- data/spec/double_entry/aggregate_array_spec.rb +75 -0
- data/spec/double_entry/aggregate_spec.rb +168 -0
- data/spec/double_entry/double_entry_spec.rb +391 -0
- data/spec/double_entry/line_aggregate_spec.rb +8 -0
- data/spec/double_entry/line_check_spec.rb +88 -0
- data/spec/double_entry/line_spec.rb +72 -0
- data/spec/double_entry/locking_spec.rb +154 -0
- data/spec/double_entry/month_range_spec.rb +131 -0
- data/spec/double_entry/reporting_spec.rb +25 -0
- data/spec/double_entry/time_range_array_spec.rb +149 -0
- data/spec/double_entry/time_range_spec.rb +43 -0
- data/spec/double_entry/week_range_spec.rb +88 -0
- data/spec/generators/double_entry/install/install_generator_spec.rb +33 -0
- data/spec/spec_helper.rb +47 -0
- data/spec/support/accounts.rb +26 -0
- data/spec/support/blueprints.rb +34 -0
- data/spec/support/database.example.yml +16 -0
- data/spec/support/database.travis.yml +18 -0
- data/spec/support/double_entry_spec_helper.rb +19 -0
- data/spec/support/reporting_configuration.rb +6 -0
- data/spec/support/schema.rb +71 -0
- metadata +277 -18
- 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,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
|