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