double_entry 1.0.1 → 2.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +36 -9
  4. data/double_entry.gemspec +20 -48
  5. data/lib/active_record/locking_extensions.rb +3 -3
  6. data/lib/active_record/locking_extensions/log_subscriber.rb +1 -1
  7. data/lib/double_entry/account.rb +38 -45
  8. data/lib/double_entry/account_balance.rb +18 -1
  9. data/lib/double_entry/errors.rb +13 -13
  10. data/lib/double_entry/line.rb +3 -2
  11. data/lib/double_entry/reporting.rb +26 -38
  12. data/lib/double_entry/reporting/aggregate.rb +43 -23
  13. data/lib/double_entry/reporting/aggregate_array.rb +16 -13
  14. data/lib/double_entry/reporting/line_aggregate.rb +3 -2
  15. data/lib/double_entry/reporting/line_aggregate_filter.rb +8 -10
  16. data/lib/double_entry/reporting/line_metadata_filter.rb +33 -0
  17. data/lib/double_entry/transfer.rb +33 -27
  18. data/lib/double_entry/validation.rb +1 -0
  19. data/lib/double_entry/validation/account_fixer.rb +36 -0
  20. data/lib/double_entry/validation/line_check.rb +22 -40
  21. data/lib/double_entry/version.rb +1 -1
  22. data/lib/generators/double_entry/install/install_generator.rb +7 -1
  23. data/lib/generators/double_entry/install/templates/migration.rb +27 -25
  24. metadata +33 -243
  25. data/.gitignore +0 -32
  26. data/.rspec +0 -2
  27. data/.travis.yml +0 -29
  28. data/.yardopts +0 -2
  29. data/Gemfile +0 -2
  30. data/Rakefile +0 -15
  31. data/script/jack_hammer +0 -210
  32. data/script/setup.sh +0 -8
  33. data/spec/active_record/locking_extensions_spec.rb +0 -110
  34. data/spec/double_entry/account_balance_spec.rb +0 -7
  35. data/spec/double_entry/account_spec.rb +0 -130
  36. data/spec/double_entry/balance_calculator_spec.rb +0 -88
  37. data/spec/double_entry/configuration_spec.rb +0 -50
  38. data/spec/double_entry/line_spec.rb +0 -80
  39. data/spec/double_entry/locking_spec.rb +0 -214
  40. data/spec/double_entry/performance/double_entry_performance_spec.rb +0 -32
  41. data/spec/double_entry/performance/reporting/aggregate_performance_spec.rb +0 -50
  42. data/spec/double_entry/reporting/aggregate_array_spec.rb +0 -123
  43. data/spec/double_entry/reporting/aggregate_spec.rb +0 -205
  44. data/spec/double_entry/reporting/line_aggregate_filter_spec.rb +0 -90
  45. data/spec/double_entry/reporting/line_aggregate_spec.rb +0 -39
  46. data/spec/double_entry/reporting/month_range_spec.rb +0 -139
  47. data/spec/double_entry/reporting/time_range_array_spec.rb +0 -169
  48. data/spec/double_entry/reporting/time_range_spec.rb +0 -45
  49. data/spec/double_entry/reporting/week_range_spec.rb +0 -103
  50. data/spec/double_entry/reporting_spec.rb +0 -181
  51. data/spec/double_entry/transfer_spec.rb +0 -93
  52. data/spec/double_entry/validation/line_check_spec.rb +0 -99
  53. data/spec/double_entry_spec.rb +0 -428
  54. data/spec/generators/double_entry/install/install_generator_spec.rb +0 -30
  55. data/spec/spec_helper.rb +0 -118
  56. data/spec/support/accounts.rb +0 -21
  57. data/spec/support/blueprints.rb +0 -43
  58. data/spec/support/database.example.yml +0 -21
  59. data/spec/support/database.travis.yml +0 -24
  60. data/spec/support/double_entry_spec_helper.rb +0 -27
  61. data/spec/support/gemfiles/Gemfile.rails-3.2.x +0 -8
  62. data/spec/support/gemfiles/Gemfile.rails-4.1.x +0 -6
  63. data/spec/support/gemfiles/Gemfile.rails-4.2.x +0 -5
  64. data/spec/support/gemfiles/Gemfile.rails-5.0.x +0 -5
  65. data/spec/support/performance_helper.rb +0 -26
  66. data/spec/support/reporting_configuration.rb +0 -6
  67. data/spec/support/schema.rb +0 -74
data/.gitignore DELETED
@@ -1,32 +0,0 @@
1
- *.gem
2
- *.rbc
3
- /.config
4
- /coverage/
5
- /InstalledFiles
6
- /pkg/
7
- /spec/reports/
8
- /test/tmp/
9
- /test/version_tmp/
10
- /tmp/
11
-
12
- ## Performance profiling
13
- /profiles/
14
-
15
- ## Documentation cache and generated files:
16
- /.yardoc/
17
- /_yardoc/
18
- /doc/
19
- /rdoc/
20
-
21
- ## Environment normalisation:
22
- /.bundle/
23
- /lib/bundler/man/
24
- /Gemfile.lock
25
- /.ruby-version
26
- /.ruby-gemset
27
-
28
- # DoubleEntry specific
29
- /bin/
30
- /log/
31
- /spec/reports/
32
- /spec/support/database.yml
data/.rspec DELETED
@@ -1,2 +0,0 @@
1
- --color
2
- --require spec_helper
@@ -1,29 +0,0 @@
1
- language: ruby
2
- sudo: false
3
- before_script:
4
- - cp spec/support/database.travis.yml spec/support/database.yml
5
- - mysql -e 'create database double_entry_test;'
6
- - psql -c 'create database double_entry_test;' -U postgres
7
- script:
8
- - bundle exec rake spec
9
- - ruby script/jack_hammer -t 2000
10
- matrix:
11
- include:
12
- - rvm: 2.1
13
- gemfile: spec/support/gemfiles/Gemfile.rails-3.2.x
14
- env: DB=mysql
15
- - rvm: 2.2
16
- gemfile: spec/support/gemfiles/Gemfile.rails-4.1.x
17
- env: DB=mysql
18
- - rvm: 2.2
19
- gemfile: spec/support/gemfiles/Gemfile.rails-4.2.x
20
- env: DB=mysql
21
- - rvm: 2.3
22
- gemfile: spec/support/gemfiles/Gemfile.rails-5.0.x
23
- env: DB=mysql
24
- - rvm: 2.3
25
- gemfile: spec/support/gemfiles/Gemfile.rails-5.0.x
26
- env: DB=sqlite
27
- - rvm: 2.3
28
- gemfile: spec/support/gemfiles/Gemfile.rails-5.0.x
29
- env: DB=postgres
data/.yardopts DELETED
@@ -1,2 +0,0 @@
1
- --markup markdown
2
- - LICENSE.md
data/Gemfile DELETED
@@ -1,2 +0,0 @@
1
- source 'https://rubygems.org'
2
- gemspec
data/Rakefile DELETED
@@ -1,15 +0,0 @@
1
- require 'rspec/core/rake_task'
2
- require 'bundler/gem_tasks'
3
-
4
- RSpec::Core::RakeTask.new(:spec) do |t|
5
- t.verbose = false
6
- t.ruby_opts = '-w'
7
- end
8
-
9
- task :default do
10
- %w(mysql postgres sqlite).each do |db|
11
- puts "Running tests with `DB=#{db}`"
12
- ENV['DB'] = db
13
- Rake::Task['spec'].execute
14
- end
15
- end
@@ -1,210 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- # Run a concurrency test on the double_entry code.
4
- #
5
- # This spawns a bunch of processes, and does random transactions between a set
6
- # of accounts, then validates that all the numbers add up at the end.
7
- #
8
- # You can also tell it to flush our the account balances table at regular
9
- # intervals, to validate that new account balances records get created with the
10
- # correct balances from the lines table.
11
- #
12
- # Run it without arguments to get the usage.
13
-
14
- require 'optparse'
15
- require 'bundler/setup'
16
- require 'active_record'
17
- require 'database_cleaner'
18
-
19
- support = File.expand_path("../../spec/support/", __FILE__)
20
-
21
- db_engine = ENV['DB'] || 'mysql'
22
-
23
- if db_engine == 'sqlite'
24
- puts "Skipping jackhammer for SQLITE..."
25
- exit 0
26
- end
27
-
28
- ActiveRecord::Base.establish_connection YAML.load_file(File.join(support, "database.yml"))[db_engine]
29
- require "#{support}/schema"
30
-
31
- lib = File.expand_path('../../lib', __FILE__)
32
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
33
- require 'double_entry'
34
-
35
- def parse_options
36
- $account_count = 5
37
- $process_count = 20
38
- $transfer_count = 20000
39
- $balance_flush_count = 1
40
- $use_threads = false
41
-
42
- options = OptionParser.new
43
-
44
- options.on("-a", "--accounts=COUNT", Integer, "Number of accounts (default: #{$account_count})") do |value|
45
- $account_count = value
46
- end
47
-
48
- options.on("-p", "--processes=COUNT", Integer, "Number of processes (default: #{$process_count})") do |value|
49
- $process_count = value
50
- end
51
-
52
- options.on("-t", "--transfers=COUNT", Integer, "Number of transfers (default: #{$transfer_count})") do |value|
53
- $transfer_count = value
54
- end
55
-
56
- options.on("-f", "--flush-balances=COUNT", Integer, "Flush account balances table COUNT times") do |value|
57
- $balance_flush_count = value
58
- end
59
-
60
- options.on("-z", "--threads", "Use threads instead of processes") do |value|
61
- $use_threads = !!value
62
- end
63
-
64
- options.parse(*ARGV)
65
- end
66
-
67
-
68
- def clean_out_database
69
- puts "Cleaning out the database..."
70
-
71
- DatabaseCleaner.clean_with(:truncation)
72
- end
73
-
74
- def create_accounts_and_transfers
75
- puts "Setting up #{$account_count} accounts..."
76
-
77
- DoubleEntry.configure do |config|
78
-
79
- # Create the accounts.
80
- config.define_accounts do |accounts|
81
- scope = ->(x) { x }
82
- $account_count.times do |i|
83
- accounts.define(:identifier => :"account-#{i}", :scope_identifier => scope)
84
- end
85
- end
86
-
87
- # Create all the possible transfers.
88
- config.define_transfers do |transfers|
89
- config.accounts.each do |from|
90
- config.accounts.each do |to|
91
- transfers.define(:from => from.identifier, :to => to.identifier, :code => :test)
92
- end
93
- end
94
- end
95
-
96
- # Find account instances so we have something to work with.
97
- $accounts = config.accounts.map do |account|
98
- DoubleEntry.account(account.identifier, :scope => 1)
99
- end
100
- end
101
- end
102
-
103
-
104
- def run_tests
105
- puts "Spawning #{$process_count} processes..."
106
-
107
- iterations_per_process = [ ($transfer_count / $process_count / $balance_flush_count), 1 ].max
108
-
109
- $balance_flush_count.times do
110
- puts "Flushing balances"
111
- DoubleEntry::AccountBalance.delete_all
112
- ActiveRecord::Base.connection_pool.disconnect!
113
-
114
- if $use_threads
115
- puts "Using threads as workers"
116
- threads = []
117
- $process_count.times do |process_num|
118
- threads << Thread.new { run_process(iterations_per_process, process_num) }
119
- end
120
-
121
- threads.each(&:join)
122
- else
123
- puts "Using processes as workers"
124
- pids = []
125
- $process_count.times do |process_num|
126
- pids << fork { run_process(iterations_per_process, process_num) }
127
- end
128
-
129
- pids.each {|pid| Process.wait2(pid) }
130
- end
131
- end
132
- end
133
-
134
-
135
- def run_process(iterations, process_num)
136
- srand # Seed the random number generator separately for each process.
137
-
138
- puts "Process #{process_num} running #{iterations} transfers..."
139
-
140
- iterations.times do |i|
141
- account_a = $accounts.sample
142
- account_b = ($accounts - [account_a]).sample
143
- amount = rand(1000) + 1
144
-
145
- DoubleEntry.transfer(Money.new(amount), :from => account_a, :to => account_b, :code => :test)
146
-
147
- puts "Process #{process_num} completed #{i+1} transfers" if (i+1) % 100 == 0
148
- end
149
- end
150
-
151
-
152
- def reconcile
153
- error_count = 0
154
- puts "Reconciling..."
155
-
156
- if DoubleEntry::Line.count == $transfer_count * 2
157
- puts "All the Line records were written, FTW!"
158
- else
159
- puts "Not enough Line records written. :("
160
- error_count += 1
161
- end
162
-
163
- if $accounts.all? {|account| DoubleEntry::Reporting.reconciled?(account) }
164
- puts "All accounts reconciled, FTW!"
165
- else
166
- $accounts.each do |account|
167
- if !DoubleEntry::Reporting.reconciled?(account)
168
- puts "Account #{account.identifier} failed to reconcile. :("
169
-
170
- # See http://bugs.mysql.com/bug.php?id=51431
171
- use_index = if DoubleEntry::Line.connection.adapter_name.match /mysql/i
172
- "USE INDEX (lines_scope_account_id_idx)"
173
- else
174
- ""
175
- end
176
-
177
- rows = ActiveRecord::Base.connection.select_all(<<-SQL)
178
- SELECT id, amount, balance
179
- FROM #{DoubleEntry::Line.quoted_table_name} #{use_index}
180
- WHERE scope = '#{account.scope_identity}'
181
- AND account = '#{account.identifier}'
182
- ORDER BY id
183
- SQL
184
-
185
- rows.each_cons(2) do |a, b|
186
- if a["balance"].to_i + b["amount"].to_i != b["balance"].to_i
187
- puts "Bad lines entry id = #{b['id']}"
188
- error_count += 1
189
- end
190
- end
191
- end
192
- end
193
- end
194
-
195
- error_count == 0
196
- end
197
-
198
-
199
- parse_options
200
- clean_out_database
201
- create_accounts_and_transfers
202
- run_tests
203
-
204
- if reconcile
205
- puts "Done successfully :)"
206
- exit 0
207
- else
208
- puts "Done with errors :("
209
- exit 1
210
- end
@@ -1,8 +0,0 @@
1
- #!/bin/bash
2
-
3
- echo "This command will setup your local dev environment, including"
4
- echo " * bundle install"
5
- echo
6
-
7
- echo "Bundling..."
8
- bundle install --binstubs bin --path .bundle
@@ -1,110 +0,0 @@
1
- # encoding: utf-8
2
-
3
- RSpec.describe ActiveRecord::LockingExtensions do
4
- PG_DEADLOCK = ActiveRecord::StatementInvalid.new('PG::Error: ERROR: deadlock detected')
5
- MYSQL_DEADLOCK = ActiveRecord::StatementInvalid.new('Mysql::Error: Deadlock found when trying to get lock')
6
- SQLITE3_LOCK = ActiveRecord::StatementInvalid.new('SQLite3::BusyException: database is locked: UPDATE...')
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
- shared_examples 'abstract adapter' do
20
- it 'raises a ActiveRecord::RestartTransaction error if a deadlock occurs' do
21
- expect { User.with_restart_on_deadlock { fail exception } }.
22
- to raise_error(ActiveRecord::RestartTransaction)
23
- end
24
-
25
- it 'publishes a notification' do
26
- expect(ActiveSupport::Notifications).
27
- to receive(:publish).
28
- with('deadlock_restart.active_record', hash_including(:exception => exception))
29
- expect { User.with_restart_on_deadlock { fail exception } }.to raise_error
30
- end
31
- end
32
-
33
- context 'mysql' do
34
- let(:exception) { MYSQL_DEADLOCK }
35
-
36
- it_behaves_like 'abstract adapter'
37
- end
38
-
39
- context 'postgres' do
40
- let(:exception) { PG_DEADLOCK }
41
-
42
- it_behaves_like 'abstract adapter'
43
- end
44
-
45
- context 'sqlite' do
46
- let(:exception) { SQLITE3_LOCK }
47
-
48
- it_behaves_like 'abstract adapter'
49
- end
50
- end
51
-
52
- context '#create_ignoring_duplicates' do
53
- it 'does not raise an error if a duplicate index error is raised in the database' do
54
- User.make! :username => 'keith'
55
-
56
- expect { User.make! :username => 'keith' }.to raise_error
57
- expect { User.create_ignoring_duplicates! :username => 'keith' }.to_not raise_error
58
- end
59
-
60
- it 'publishes a notification when a duplicate is encountered' do
61
- User.make! :username => 'keith'
62
-
63
- expect(ActiveSupport::Notifications).
64
- to receive(:publish).
65
- with('duplicate_ignore.active_record', hash_including(:exception => kind_of(ActiveRecord::RecordNotUnique)))
66
-
67
- expect { User.create_ignoring_duplicates! :username => 'keith' }.to_not raise_error
68
- end
69
-
70
- shared_examples 'abstract adapter' do
71
- it 'retries the creation if a deadlock error is raised from the database' do
72
- expect(User).to receive(:create!).ordered.and_raise(exception)
73
- expect(User).to receive(:create!).ordered.and_return(true)
74
-
75
- expect { User.create_ignoring_duplicates! }.to_not raise_error
76
- end
77
-
78
- it 'publishes a notification on each retry' do
79
- expect(User).to receive(:create!).ordered.and_raise(exception)
80
- expect(User).to receive(:create!).ordered.and_raise(exception)
81
- expect(User).to receive(:create!).ordered.and_return(true)
82
-
83
- expect(ActiveSupport::Notifications).
84
- to receive(:publish).
85
- with('deadlock_retry.active_record', hash_including(:exception => exception)).
86
- twice
87
-
88
- expect { User.create_ignoring_duplicates! }.to_not raise_error
89
- end
90
- end
91
-
92
- context 'mysql' do
93
- let(:exception) { MYSQL_DEADLOCK }
94
-
95
- it_behaves_like 'abstract adapter'
96
- end
97
-
98
- context 'postgres' do
99
- let(:exception) { PG_DEADLOCK }
100
-
101
- it_behaves_like 'abstract adapter'
102
- end
103
-
104
- context 'sqlite' do
105
- let(:exception) { SQLITE3_LOCK }
106
-
107
- it_behaves_like 'abstract adapter'
108
- end
109
- end
110
- end
@@ -1,7 +0,0 @@
1
- # encoding: utf-8
2
- RSpec.describe DoubleEntry::AccountBalance do
3
- describe '.table_name' do
4
- subject { DoubleEntry::AccountBalance.table_name }
5
- it { should eq('double_entry_account_balances') }
6
- end
7
- end
@@ -1,130 +0,0 @@
1
- # encoding: utf-8
2
- module DoubleEntry
3
- RSpec.describe Account do
4
- let(:identity_scope) { ->(value) { value } }
5
-
6
- describe '::new' do
7
- context 'given an identifier 31 characters in length' do
8
- let(:identifier) { 'xxxxxxxx 31 characters xxxxxxxx' }
9
- specify do
10
- expect { Account.new(:identifier => identifier) }.to_not raise_error
11
- end
12
- end
13
-
14
- context 'given an identifier 32 characters in length' do
15
- let(:identifier) { 'xxxxxxxx 32 characters xxxxxxxxx' }
16
- specify do
17
- expect { Account.new(:identifier => identifier) }.to raise_error AccountIdentifierTooLongError, /'#{identifier}'/
18
- end
19
- end
20
- end
21
-
22
- describe Account::Instance do
23
- it 'is sortable' do
24
- account = Account.new(:identifier => 'savings', :scope_identifier => identity_scope)
25
- a = Account::Instance.new(:account => account, :scope => '123')
26
- b = Account::Instance.new(:account => account, :scope => '456')
27
- expect([b, a].sort).to eq [a, b]
28
- end
29
-
30
- it 'is hashable' do
31
- account = Account.new(:identifier => 'savings', :scope_identifier => identity_scope)
32
- a1 = Account::Instance.new(:account => account, :scope => '123')
33
- a2 = Account::Instance.new(:account => account, :scope => '123')
34
- b = Account::Instance.new(:account => account, :scope => '456')
35
-
36
- expect(a1.hash).to eq a2.hash
37
- expect(a1.hash).to_not eq b.hash
38
- end
39
-
40
- describe '::new' do
41
- let(:account) { Account.new(:identifier => 'x', :scope_identifier => identity_scope) }
42
- subject(:initialize_account_instance) { Account::Instance.new(:account => account, :scope => scope) }
43
-
44
- context 'given a scope identifier 23 characters in length' do
45
- let(:scope) { 'xxxx 23 characters xxxx' }
46
- specify { expect { initialize_account_instance }.to_not raise_error }
47
- end
48
-
49
- context 'given a scope identifier 24 characters in length' do
50
- let(:scope) { 'xxxx 24 characters xxxxx' }
51
- specify { expect { initialize_account_instance }.to raise_error ScopeIdentifierTooLongError, /'#{scope}'/ }
52
- end
53
- end
54
- end
55
-
56
- describe 'currency' do
57
- it 'defaults to USD currency' do
58
- account = DoubleEntry::Account.new(:identifier => 'savings', :scope_identifier => identity_scope)
59
- expect(DoubleEntry::Account::Instance.new(:account => account).currency).to eq('USD')
60
- end
61
-
62
- it 'allows the currency to be set' do
63
- account = DoubleEntry::Account.new(:identifier => 'savings', :scope_identifier => identity_scope, :currency => 'AUD')
64
- expect(DoubleEntry::Account::Instance.new(:account => account).currency).to eq('AUD')
65
- end
66
- end
67
-
68
- describe Account::Set do
69
- describe '#define' do
70
- context "given a 'savings' account is defined" do
71
- before { subject.define(:identifier => 'savings') }
72
- its(:first) { should be_an Account }
73
- its('first.identifier') { should eq 'savings' }
74
- end
75
- end
76
-
77
- describe '#active_record_scope_identifier' do
78
- subject(:scope) { Account::Set.new.active_record_scope_identifier(ar_class) }
79
-
80
- context 'given ActiveRecordScopeFactory is stubbed' do
81
- let(:scope_identifier) { double(:scope_identifier) }
82
- let(:scope_factory) { double(:scope_factory, :scope_identifier => scope_identifier) }
83
- let(:ar_class) { double(:ar_class) }
84
- before { allow(Account::ActiveRecordScopeFactory).to receive(:new).with(ar_class).and_return(scope_factory) }
85
-
86
- it { should eq scope_identifier }
87
- end
88
- end
89
- end
90
- end
91
-
92
- RSpec.describe Account::ActiveRecordScopeFactory do
93
- context 'given the class User' do
94
- subject(:factory) { Account::ActiveRecordScopeFactory.new(User) }
95
-
96
- describe '#scope_identifier' do
97
- subject(:scope_identifier) { factory.scope_identifier }
98
-
99
- describe '#call' do
100
- subject(:scope) { scope_identifier.call(value) }
101
-
102
- context 'given a User instance with ID 32' do
103
- let(:value) { User.make(:id => 32) }
104
-
105
- it { should eq 32 }
106
- end
107
-
108
- context 'given differing model instance with ID 32' do
109
- let(:value) { double(:id => 32) }
110
- it 'raises an error' do
111
- expect { scope_identifier.call(value) }.to raise_error DoubleEntry::AccountScopeMismatchError
112
- end
113
- end
114
-
115
- context "given the String 'I am a bearded lady'" do
116
- let(:value) { 'I am a bearded lady' }
117
-
118
- it { should eq 'I am a bearded lady' }
119
- end
120
-
121
- context 'given the Integer 42' do
122
- let(:value) { 42 }
123
-
124
- it { should eq 42 }
125
- end
126
- end
127
- end
128
- end
129
- end
130
- end