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