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

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +13 -5
  2. data/.gitignore +5 -6
  3. data/.rspec +1 -0
  4. data/.travis.yml +19 -0
  5. data/.yardopts +2 -0
  6. data/Gemfile +0 -1
  7. data/LICENSE.md +19 -0
  8. data/README.md +221 -14
  9. data/Rakefile +12 -0
  10. data/double_entry.gemspec +30 -15
  11. data/gemfiles/Gemfile.rails-3.2.0 +5 -0
  12. data/gemfiles/Gemfile.rails-4.0.0 +5 -0
  13. data/gemfiles/Gemfile.rails-4.1.0 +5 -0
  14. data/lib/active_record/locking_extensions.rb +61 -0
  15. data/lib/double_entry.rb +267 -2
  16. data/lib/double_entry/account.rb +82 -0
  17. data/lib/double_entry/account_balance.rb +31 -0
  18. data/lib/double_entry/aggregate.rb +118 -0
  19. data/lib/double_entry/aggregate_array.rb +65 -0
  20. data/lib/double_entry/configurable.rb +52 -0
  21. data/lib/double_entry/day_range.rb +38 -0
  22. data/lib/double_entry/hour_range.rb +40 -0
  23. data/lib/double_entry/line.rb +147 -0
  24. data/lib/double_entry/line_aggregate.rb +37 -0
  25. data/lib/double_entry/line_check.rb +118 -0
  26. data/lib/double_entry/locking.rb +187 -0
  27. data/lib/double_entry/month_range.rb +92 -0
  28. data/lib/double_entry/reporting.rb +16 -0
  29. data/lib/double_entry/time_range.rb +55 -0
  30. data/lib/double_entry/time_range_array.rb +43 -0
  31. data/lib/double_entry/transfer.rb +70 -0
  32. data/lib/double_entry/version.rb +3 -1
  33. data/lib/double_entry/week_range.rb +99 -0
  34. data/lib/double_entry/year_range.rb +39 -0
  35. data/lib/generators/double_entry/install/install_generator.rb +22 -0
  36. data/lib/generators/double_entry/install/templates/migration.rb +68 -0
  37. data/script/jack_hammer +201 -0
  38. data/script/setup.sh +8 -0
  39. data/spec/active_record/locking_extensions_spec.rb +54 -0
  40. data/spec/double_entry/account_balance_spec.rb +8 -0
  41. data/spec/double_entry/account_spec.rb +23 -0
  42. data/spec/double_entry/aggregate_array_spec.rb +75 -0
  43. data/spec/double_entry/aggregate_spec.rb +168 -0
  44. data/spec/double_entry/double_entry_spec.rb +391 -0
  45. data/spec/double_entry/line_aggregate_spec.rb +8 -0
  46. data/spec/double_entry/line_check_spec.rb +88 -0
  47. data/spec/double_entry/line_spec.rb +72 -0
  48. data/spec/double_entry/locking_spec.rb +154 -0
  49. data/spec/double_entry/month_range_spec.rb +131 -0
  50. data/spec/double_entry/reporting_spec.rb +25 -0
  51. data/spec/double_entry/time_range_array_spec.rb +149 -0
  52. data/spec/double_entry/time_range_spec.rb +43 -0
  53. data/spec/double_entry/week_range_spec.rb +88 -0
  54. data/spec/generators/double_entry/install/install_generator_spec.rb +33 -0
  55. data/spec/spec_helper.rb +47 -0
  56. data/spec/support/accounts.rb +26 -0
  57. data/spec/support/blueprints.rb +34 -0
  58. data/spec/support/database.example.yml +16 -0
  59. data/spec/support/database.travis.yml +18 -0
  60. data/spec/support/double_entry_spec_helper.rb +19 -0
  61. data/spec/support/reporting_configuration.rb +6 -0
  62. data/spec/support/schema.rb +71 -0
  63. metadata +277 -18
  64. data/LICENSE.txt +0 -22
@@ -0,0 +1,8 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+ describe DoubleEntry::LineAggregate do
4
+
5
+ it "has a table name prefixed with double_entry_" do
6
+ expect(DoubleEntry::LineAggregate.table_name).to eq "double_entry_line_aggregates"
7
+ end
8
+ end
@@ -0,0 +1,88 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe DoubleEntry::LineCheck do
5
+
6
+ describe '#last' do
7
+
8
+ context 'Given some checks have been created' do
9
+ before do
10
+ Timecop.freeze 3.minutes.ago do
11
+ DoubleEntry::LineCheck.create! :last_line_id => 100, :errors_found => false, :log => ''
12
+ end
13
+ Timecop.freeze 1.minute.ago do
14
+ DoubleEntry::LineCheck.create! :last_line_id => 300, :errors_found => false, :log => ''
15
+ end
16
+ Timecop.freeze 2.minutes.ago do
17
+ DoubleEntry::LineCheck.create! :last_line_id => 200, :errors_found => false, :log => ''
18
+ end
19
+ end
20
+
21
+ it 'should find the newest LineCheck created (by creation_date)' do
22
+ expect(DoubleEntry::LineCheck.last.last_line_id).to eq 300
23
+ end
24
+ end
25
+
26
+ end
27
+
28
+ describe '#perform!' do
29
+ subject(:performed_line_check) { DoubleEntry::LineCheck.perform! }
30
+
31
+ context 'Given a user with 100 dollars' do
32
+ before { User.make!(:savings_balance => Money.new(100_00)) }
33
+
34
+ context 'And all is consistent' do
35
+
36
+ context 'And all lines have been checked' do
37
+ before { DoubleEntry::LineCheck.perform! }
38
+
39
+ it { should be_nil }
40
+
41
+ it 'should not persist a new LineCheck' do
42
+ expect {
43
+ DoubleEntry::LineCheck.perform!
44
+ }.to_not change { DoubleEntry::LineCheck.count }
45
+ end
46
+ end
47
+
48
+ it { should be_instance_of DoubleEntry::LineCheck }
49
+ its(:errors_found) { should eq false }
50
+
51
+ it 'should persist the LineCheck' do
52
+ line_check = DoubleEntry::LineCheck.perform!
53
+ expect(DoubleEntry::LineCheck.last).to eq line_check
54
+ end
55
+ end
56
+
57
+ context 'And there is a consistency error in lines' do
58
+ before { DoubleEntry::Line.order(:id).limit(1).update_all('balance = balance + 1') }
59
+
60
+ its(:errors_found) { should be true }
61
+ its(:log) { should match /Error on line/ }
62
+
63
+ it 'should correct the running balance' do
64
+ expect {
65
+ DoubleEntry::LineCheck.perform!
66
+ }.to change { DoubleEntry::Line.order(:id).first.balance }.by Money.new(-1)
67
+ end
68
+ end
69
+
70
+ context 'And there is a consistency error in account balance' do
71
+ before { DoubleEntry::AccountBalance.order(:id).limit(1).update_all('balance = balance + 1') }
72
+
73
+ its(:errors_found) { should be true }
74
+
75
+ it 'should correct the account balance' do
76
+ expect {
77
+ DoubleEntry::LineCheck.perform!
78
+ }.to change { DoubleEntry::AccountBalance.order(:id).first.balance }.by Money.new(-1)
79
+ end
80
+ end
81
+ end
82
+
83
+ it "has a table name prefixed with double_entry_" do
84
+ expect(DoubleEntry::LineCheck.table_name).to eq "double_entry_line_checks"
85
+ end
86
+
87
+ end
88
+ end
@@ -0,0 +1,72 @@
1
+ # encoding: utf-8
2
+ require "spec_helper"
3
+ describe DoubleEntry::Line do
4
+
5
+ describe "persistent attributes" do
6
+ let(:persisted_line) {
7
+ DoubleEntry::Line.new(
8
+ :amount => Money.new(10_00),
9
+ :balance => Money.empty,
10
+ :account => account,
11
+ :partner_account => partner_account,
12
+ :code => code,
13
+ :meta => meta,
14
+ )
15
+ }
16
+ let(:account) { DoubleEntry.account(:test, :scope => "17") }
17
+ let(:partner_account) { DoubleEntry.account(:test, :scope => "72") }
18
+ let(:code) { :test_code }
19
+ let(:meta) { "test meta" }
20
+ before { persisted_line.save! }
21
+ subject { DoubleEntry::Line.last }
22
+
23
+ context "given code = :the_code" do
24
+ let(:code) { :the_code }
25
+ its(:code) { should eq :the_code }
26
+ end
27
+
28
+ context "given code = nil" do
29
+ let(:code) { nil }
30
+ its(:code) { should eq nil }
31
+ end
32
+
33
+ context "given account = :test, 54 " do
34
+ let(:account) { DoubleEntry.account(:test, :scope => "54") }
35
+ its("account.account.identifier") { should eq :test }
36
+ its("account.scope") { should eq "54" }
37
+ end
38
+
39
+ context "given partner_account = :test, 91 " do
40
+ let(:partner_account) { DoubleEntry.account(:test, :scope => "91") }
41
+ its("partner_account.account.identifier") { should eq :test }
42
+ its("partner_account.scope") { should eq "91" }
43
+ end
44
+
45
+ context "given meta = 'the meta'" do
46
+ let(:meta) { "the meta" }
47
+ its(:meta) { should eq "the meta" }
48
+ end
49
+
50
+ context "given meta = nil" do
51
+ let(:meta) { nil }
52
+ its(:meta) { should eq nil }
53
+ end
54
+
55
+ context "given meta has not been persisted (NULL)" do
56
+ let(:persisted_line) {
57
+ DoubleEntry::Line.new(
58
+ :amount => Money.new(10_00),
59
+ :balance => Money.empty,
60
+ :account => DoubleEntry.account(:savings, :scope => User.make!),
61
+ :code => code,
62
+ )
63
+ }
64
+ its(:meta) { should eq Hash.new }
65
+ end
66
+ end
67
+
68
+ it "has a table name prefixed with double_entry_" do
69
+ expect(DoubleEntry::Line.table_name).to eq "double_entry_lines"
70
+ end
71
+
72
+ end
@@ -0,0 +1,154 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe DoubleEntry::Locking do
5
+
6
+ before(:all) { @saved_accounts, @saved_transfers = DoubleEntry.accounts, DoubleEntry.transfers }
7
+ after(:all) { DoubleEntry.accounts, DoubleEntry.transfers = @saved_accounts, @saved_transfers }
8
+
9
+ before do
10
+ scope = lambda {|x| x }
11
+
12
+ DoubleEntry.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
13
+ accounts << DoubleEntry::Account.new(:identifier => :account_a, :scope_identifier => scope)
14
+ accounts << DoubleEntry::Account.new(:identifier => :account_b, :scope_identifier => scope)
15
+ accounts << DoubleEntry::Account.new(:identifier => :account_c, :scope_identifier => scope)
16
+ accounts << DoubleEntry::Account.new(:identifier => :account_d, :scope_identifier => scope)
17
+ end
18
+
19
+ DoubleEntry.transfers = DoubleEntry::Transfer::Set.new.tap do |transfers|
20
+ transfers << DoubleEntry::Transfer.new(:from => :account_a, :to => :account_b, :code => :test)
21
+ transfers << DoubleEntry::Transfer.new(:from => :account_c, :to => :account_d, :code => :test)
22
+ end
23
+
24
+ @account_a = DoubleEntry.account(:account_a, :scope => "1")
25
+ @account_b = DoubleEntry.account(:account_b, :scope => "2")
26
+ @account_c = DoubleEntry.account(:account_c, :scope => "3")
27
+ @account_d = DoubleEntry.account(:account_d, :scope => "4")
28
+ end
29
+
30
+ it "should create missing account balance records" do
31
+ expect do
32
+ DoubleEntry::Locking.lock_accounts(@account_a) { }
33
+ end.to change(DoubleEntry::AccountBalance, :count).by(1)
34
+
35
+ account_balance = DoubleEntry::AccountBalance.find_by_account(@account_a)
36
+ expect(account_balance).to_not be_nil
37
+ expect(account_balance.balance).to eq Money.new(0)
38
+ end
39
+
40
+ it "should take the balance for new account balance records from the lines table" do
41
+ DoubleEntry::Line.create!(:account => @account_a, :amount => Money.new(3_00), :balance => Money.new( 3_00), :code => :test)
42
+ DoubleEntry::Line.create!(:account => @account_a, :amount => Money.new(7_00), :balance => Money.new(10_00), :code => :test)
43
+
44
+ expect do
45
+ DoubleEntry::Locking.lock_accounts(@account_a) { }
46
+ end.to change(DoubleEntry::AccountBalance, :count).by(1)
47
+
48
+ account_balance = DoubleEntry::AccountBalance.find_by_account(@account_a)
49
+ expect(account_balance).to_not be_nil
50
+ expect(account_balance.balance).to eq Money.new(10_00)
51
+ end
52
+
53
+ it "should not allow locking inside a regular transaction" do
54
+ expect {
55
+ DoubleEntry::AccountBalance.transaction do
56
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do
57
+ end
58
+ end
59
+ }.to raise_error(DoubleEntry::Locking::LockMustBeOutermostTransaction)
60
+ end
61
+
62
+ it "should not allow a transfer inside a regular transaction" do
63
+ expect {
64
+ DoubleEntry::AccountBalance.transaction do
65
+ DoubleEntry.transfer(Money.new(10_00), :from => @account_a, :to => @account_b, :code => :test)
66
+ end
67
+ }.to raise_error(DoubleEntry::Locking::LockMustBeOutermostTransaction)
68
+ end
69
+
70
+ it "should allow a transfer inside a lock if we've locked the transaction accounts" do
71
+ expect {
72
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do
73
+ DoubleEntry.transfer(Money.new(10_00), :from => @account_a, :to => @account_b, :code => :test)
74
+ end
75
+ }.to_not raise_error
76
+ end
77
+
78
+ it "should not allow a transfer inside a lock if the right locks aren't held" do
79
+ expect {
80
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_c) do
81
+ DoubleEntry.transfer(Money.new(10_00), :from => @account_a, :to => @account_b, :code => :test)
82
+ end
83
+ }.to raise_error(DoubleEntry::Locking::LockNotHeld, "No lock held for account: account_b, scope 2")
84
+ end
85
+
86
+ it "should allow nested locks if the outer lock locks all the accounts" do
87
+ expect do
88
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do
89
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_b) { }
90
+ end
91
+ end.to_not raise_error
92
+ end
93
+
94
+ it "should not allow nested locks if the out lock doesn't lock all the accounts" do
95
+ expect do
96
+ DoubleEntry::Locking.lock_accounts(@account_a) do
97
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_b) { }
98
+ end
99
+ end.to raise_error(DoubleEntry::Locking::LockNotHeld, "No lock held for account: account_b, scope 2")
100
+ end
101
+
102
+ it "should roll back a locking transaction" do
103
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do
104
+ DoubleEntry.transfer(Money.new(10_00), :from => @account_a, :to => @account_b, :code => :test)
105
+ raise ActiveRecord::Rollback
106
+ end
107
+ expect(DoubleEntry.balance(@account_a)).to eq Money.new(0)
108
+ expect(DoubleEntry.balance(@account_b)).to eq Money.new(0)
109
+ end
110
+
111
+ it "should roll back a locking transaction if there's an exception" do
112
+ expect do
113
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do
114
+ DoubleEntry.transfer(Money.new(10_00), :from => @account_a, :to => @account_b, :code => :test)
115
+ raise "Yeah, right"
116
+ end
117
+ end.to raise_error("Yeah, right")
118
+ expect(DoubleEntry.balance(@account_a)).to eq Money.new(0)
119
+ expect(DoubleEntry.balance(@account_b)).to eq Money.new(0)
120
+ end
121
+
122
+ it "should allow multiple threads to lock at the same time" do
123
+ threads = Array.new
124
+
125
+ expect do
126
+ threads << Thread.new do
127
+ sleep 0.05
128
+ DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do
129
+ DoubleEntry.transfer(Money.new(10_00), :from => @account_a, :to => @account_b, :code => :test)
130
+ end
131
+ end
132
+
133
+ threads << Thread.new do
134
+ DoubleEntry::Locking.lock_accounts(@account_c, @account_d) do
135
+ sleep 0.1
136
+ DoubleEntry.transfer(Money.new(10_00), :from => @account_c, :to => @account_d, :code => :test)
137
+ end
138
+ end
139
+
140
+ threads.each(&:join)
141
+ end.to_not raise_error
142
+ end
143
+
144
+ it "should allow multiple threads to lock accounts without balances at the same time" do
145
+ threads = Array.new
146
+
147
+ expect do
148
+ threads << Thread.new { DoubleEntry::Locking.lock_accounts(@account_a, @account_b) { } }
149
+ threads << Thread.new { DoubleEntry::Locking.lock_accounts(@account_c, @account_d) { } }
150
+
151
+ threads.each(&:join)
152
+ end.to_not raise_error
153
+ end
154
+ end
@@ -0,0 +1,131 @@
1
+ # encoding: utf-8
2
+ require "spec_helper"
3
+ describe DoubleEntry::MonthRange do
4
+
5
+ describe "::from_time" do
6
+ subject(:from_time) { DoubleEntry::MonthRange.from_time(given_time) }
7
+
8
+ context "given the Time 31st March 2012" do
9
+ let(:given_time) { Time.new(2012, 3, 31) }
10
+ its(:year) { should eq 2012 }
11
+ its(:month) { should eq 3 }
12
+ end
13
+
14
+ context "given the Date 31st March 2012" do
15
+ let(:given_time) { Date.parse("2012-03-31") }
16
+ its(:year) { should eq 2012 }
17
+ its(:month) { should eq 3 }
18
+ end
19
+ end
20
+
21
+ describe "::reportable_months" do
22
+ subject(:reportable_months) { DoubleEntry::MonthRange.reportable_months }
23
+
24
+ context "The date is 1st March 1970" do
25
+ before { Timecop.freeze(Time.new(1970, 3, 1)) }
26
+
27
+ it { should eq [
28
+ DoubleEntry::MonthRange.new(year: 1970, month: 1),
29
+ DoubleEntry::MonthRange.new(year: 1970, month: 2),
30
+ DoubleEntry::MonthRange.new(year: 1970, month: 3),
31
+ ] }
32
+
33
+ context "My business started on 5th Feb 1970" do
34
+ before do
35
+ DoubleEntry::Reporting.configure do |config|
36
+ config.start_of_business = Time.new(1970, 2, 5)
37
+ end
38
+ end
39
+
40
+ it { should eq [
41
+ DoubleEntry::MonthRange.new(year: 1970, month: 2),
42
+ DoubleEntry::MonthRange.new(year: 1970, month: 3),
43
+ ] }
44
+ end
45
+ end
46
+
47
+ context "The date is 1st Jan 1970" do
48
+ before { Timecop.freeze(Time.new(1970, 1, 1)) }
49
+
50
+ it { should eq [ DoubleEntry::MonthRange.new(year: 1970, month: 1) ] }
51
+ end
52
+ end
53
+
54
+ describe "::beginning_of_financial_year" do
55
+ let(:month_range) { DoubleEntry::MonthRange.new(:year => year, :month => month) }
56
+ let(:year) { 2014 }
57
+
58
+ context "the first month of the financial year is July" do
59
+ subject(:beginning_of_financial_year) { month_range.beginning_of_financial_year }
60
+ context "returns the current year if the month is after July" do
61
+ let(:month) { 10 }
62
+ it { should eq(DoubleEntry::MonthRange.new(:year => 2014, :month => 7)) }
63
+ end
64
+
65
+ context "returns the previous year if the month is before July" do
66
+ let(:month) { 3 }
67
+ it { should eq(DoubleEntry::MonthRange.new(:year => 2013, :month => 7)) }
68
+ end
69
+ end
70
+
71
+ context "the first month of the financial year is January" do
72
+ subject(:beginning_of_financial_year) { month_range.beginning_of_financial_year }
73
+
74
+ before do
75
+ DoubleEntry::Reporting.configure do |config|
76
+ config.first_month_of_financial_year = 1
77
+ end
78
+ end
79
+
80
+ context "returns the current year if the month is after January" do
81
+ let(:month) { 10 }
82
+ it { should eq(DoubleEntry::MonthRange.new(:year => 2014, :month => 1)) }
83
+ end
84
+
85
+ context "returns the current year if the month is January" do
86
+ let(:month) { 1 }
87
+ it { should eq(DoubleEntry::MonthRange.new(:year => 2014, :month => 1)) }
88
+ end
89
+ end
90
+
91
+ context "the first month of the financial year is December" do
92
+ subject(:beginning_of_financial_year) { month_range.beginning_of_financial_year }
93
+
94
+ before do
95
+ DoubleEntry::Reporting.configure do |config|
96
+ config.first_month_of_financial_year = 12
97
+ end
98
+ end
99
+
100
+ context "returns the previous year if the month is before December (in the same year)" do
101
+ let(:month) { 11 }
102
+ it { should eq(DoubleEntry::MonthRange.new(:year => 2013, :month => 12)) }
103
+ end
104
+
105
+ context "returns the previous year if the month is after December (in the next year)" do
106
+ let(:year) { 2015 }
107
+ let(:month) { 1 }
108
+ it { should eq(DoubleEntry::MonthRange.new(:year => 2014, :month => 12)) }
109
+ end
110
+
111
+ context "returns the current year if the month is December" do
112
+ let(:month) { 12 }
113
+ it { should eq(DoubleEntry::MonthRange.new(:year => 2014, :month => 12)) }
114
+ end
115
+ end
116
+
117
+ context "Given a start time of 3rd Dec 1982" do
118
+ subject(:reportable_months) { DoubleEntry::MonthRange.reportable_months(from: Time.new(1982, 12, 3)) }
119
+
120
+ context "The date is 2nd Feb 1983" do
121
+ before { Timecop.freeze(Time.new(1983, 2, 2)) }
122
+
123
+ it { should eq [
124
+ DoubleEntry::MonthRange.new(year: 1982, month: 12),
125
+ DoubleEntry::MonthRange.new(year: 1983, month: 1),
126
+ DoubleEntry::MonthRange.new(year: 1983, month: 2),
127
+ ] }
128
+ end
129
+ end
130
+ end
131
+ end