double_entry 1.0.1 → 2.0.0.beta5

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 (79) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +497 -0
  3. data/README.md +107 -44
  4. data/double_entry.gemspec +22 -49
  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.rb +29 -21
  8. data/lib/double_entry/account.rb +39 -46
  9. data/lib/double_entry/account_balance.rb +20 -3
  10. data/lib/double_entry/balance_calculator.rb +5 -5
  11. data/lib/double_entry/configurable.rb +1 -0
  12. data/lib/double_entry/configuration.rb +8 -2
  13. data/lib/double_entry/errors.rb +13 -13
  14. data/lib/double_entry/line.rb +7 -6
  15. data/lib/double_entry/locking.rb +5 -5
  16. data/lib/double_entry/transfer.rb +37 -30
  17. data/lib/double_entry/validation.rb +1 -0
  18. data/lib/double_entry/validation/account_fixer.rb +36 -0
  19. data/lib/double_entry/validation/line_check.rb +25 -43
  20. data/lib/double_entry/version.rb +1 -1
  21. data/lib/generators/double_entry/install/install_generator.rb +22 -1
  22. data/lib/generators/double_entry/install/templates/initializer.rb +20 -0
  23. data/lib/generators/double_entry/install/templates/migration.rb +45 -55
  24. metadata +35 -256
  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/lib/double_entry/reporting.rb +0 -181
  32. data/lib/double_entry/reporting/aggregate.rb +0 -110
  33. data/lib/double_entry/reporting/aggregate_array.rb +0 -76
  34. data/lib/double_entry/reporting/day_range.rb +0 -42
  35. data/lib/double_entry/reporting/hour_range.rb +0 -45
  36. data/lib/double_entry/reporting/line_aggregate.rb +0 -16
  37. data/lib/double_entry/reporting/line_aggregate_filter.rb +0 -79
  38. data/lib/double_entry/reporting/month_range.rb +0 -94
  39. data/lib/double_entry/reporting/time_range.rb +0 -59
  40. data/lib/double_entry/reporting/time_range_array.rb +0 -49
  41. data/lib/double_entry/reporting/week_range.rb +0 -107
  42. data/lib/double_entry/reporting/year_range.rb +0 -40
  43. data/script/jack_hammer +0 -210
  44. data/script/setup.sh +0 -8
  45. data/spec/active_record/locking_extensions_spec.rb +0 -110
  46. data/spec/double_entry/account_balance_spec.rb +0 -7
  47. data/spec/double_entry/account_spec.rb +0 -130
  48. data/spec/double_entry/balance_calculator_spec.rb +0 -88
  49. data/spec/double_entry/configuration_spec.rb +0 -50
  50. data/spec/double_entry/line_spec.rb +0 -80
  51. data/spec/double_entry/locking_spec.rb +0 -214
  52. data/spec/double_entry/performance/double_entry_performance_spec.rb +0 -32
  53. data/spec/double_entry/performance/reporting/aggregate_performance_spec.rb +0 -50
  54. data/spec/double_entry/reporting/aggregate_array_spec.rb +0 -123
  55. data/spec/double_entry/reporting/aggregate_spec.rb +0 -205
  56. data/spec/double_entry/reporting/line_aggregate_filter_spec.rb +0 -90
  57. data/spec/double_entry/reporting/line_aggregate_spec.rb +0 -39
  58. data/spec/double_entry/reporting/month_range_spec.rb +0 -139
  59. data/spec/double_entry/reporting/time_range_array_spec.rb +0 -169
  60. data/spec/double_entry/reporting/time_range_spec.rb +0 -45
  61. data/spec/double_entry/reporting/week_range_spec.rb +0 -103
  62. data/spec/double_entry/reporting_spec.rb +0 -181
  63. data/spec/double_entry/transfer_spec.rb +0 -93
  64. data/spec/double_entry/validation/line_check_spec.rb +0 -99
  65. data/spec/double_entry_spec.rb +0 -428
  66. data/spec/generators/double_entry/install/install_generator_spec.rb +0 -30
  67. data/spec/spec_helper.rb +0 -118
  68. data/spec/support/accounts.rb +0 -21
  69. data/spec/support/blueprints.rb +0 -43
  70. data/spec/support/database.example.yml +0 -21
  71. data/spec/support/database.travis.yml +0 -24
  72. data/spec/support/double_entry_spec_helper.rb +0 -27
  73. data/spec/support/gemfiles/Gemfile.rails-3.2.x +0 -8
  74. data/spec/support/gemfiles/Gemfile.rails-4.1.x +0 -6
  75. data/spec/support/gemfiles/Gemfile.rails-4.2.x +0 -5
  76. data/spec/support/gemfiles/Gemfile.rails-5.0.x +0 -5
  77. data/spec/support/performance_helper.rb +0 -26
  78. data/spec/support/reporting_configuration.rb +0 -6
  79. data/spec/support/schema.rb +0 -74
@@ -1,40 +0,0 @@
1
- # encoding: utf-8
2
- module DoubleEntry
3
- module Reporting
4
- class YearRange < TimeRange
5
- attr_reader :year
6
-
7
- def initialize(options)
8
- super options
9
-
10
- year_start = Time.local(@year, 1, 1)
11
- @start = year_start
12
- @finish = year_start.end_of_year
13
- end
14
-
15
- def self.current
16
- new(:year => Time.now.year)
17
- end
18
-
19
- def self.from_time(time)
20
- new(:year => time.year)
21
- end
22
-
23
- def ==(other)
24
- year == other.year
25
- end
26
-
27
- def previous
28
- YearRange.new(:year => year - 1)
29
- end
30
-
31
- def next
32
- YearRange.new(:year => year + 1)
33
- end
34
-
35
- def to_s
36
- year.to_s
37
- end
38
- end
39
- end
40
- end
data/script/jack_hammer DELETED
@@ -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
data/script/setup.sh DELETED
@@ -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