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
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module DoubleEntry
2
- VERSION = "0.0.1.pre"
4
+ VERSION = "0.1.0.pre-alpha"
3
5
  end
@@ -0,0 +1,99 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ # We use a particularly crazy week numbering system: week 1 of any given year
4
+ # is the first week with any days that fall into that year.
5
+ #
6
+ # So, for example, week 1 of 2011 starts on 27 Dec 2010.
7
+ class WeekRange < TimeRange
8
+
9
+ class << self
10
+
11
+ def from_time(time)
12
+ time = time.to_time if time.is_a? Date
13
+ year = time.end_of_week.year
14
+ week = ((time.beginning_of_week - start_of_year(year)) / 1.week).floor + 1
15
+ new(:year => year, :week => week)
16
+ end
17
+
18
+ def current
19
+ from_time(Time.now)
20
+ end
21
+
22
+ # Obtain a sequence of WeekRanges from the given start to the current
23
+ # week.
24
+ #
25
+ # @option options :from [Time] Time of the first in the returned sequence
26
+ # of WeekRanges.
27
+ # @return [Array<WeekRange>]
28
+ def reportable_weeks(options = {})
29
+ week = options[:from] ? from_time(options[:from]) : earliest_week
30
+ last_in_sequence = self.current
31
+ [week].tap do |weeks|
32
+ while week != last_in_sequence
33
+ week = week.next
34
+ weeks << week
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def start_of_year(year)
42
+ Time.local(year, 1, 1).beginning_of_week
43
+ end
44
+
45
+ def earliest_week
46
+ from_time(DoubleEntry::Reporting.configuration.start_of_business)
47
+ end
48
+ end
49
+
50
+ attr_reader :year, :week
51
+
52
+ def initialize(options = {})
53
+ super options
54
+
55
+ if options.present?
56
+ @week = options[:week]
57
+
58
+ @start = week_and_year_to_time(@week, @year)
59
+ @finish = @start.end_of_week
60
+
61
+ @start = earliest_week.start if options[:range_type] == :all_time
62
+ end
63
+ end
64
+
65
+ def previous
66
+ from_time(@start - 1.week)
67
+ end
68
+
69
+ def next
70
+ from_time(@start + 1.week)
71
+ end
72
+
73
+ def ==(other)
74
+ (self.week == other.week) && (self.year == other.year)
75
+ end
76
+
77
+ def all_time
78
+ self.class.new(:year => year, :week => week, :range_type => :all_time)
79
+ end
80
+
81
+ def to_s
82
+ "#{year}, Week #{week}"
83
+ end
84
+
85
+ private
86
+
87
+ def from_time(time)
88
+ self.class.from_time(time)
89
+ end
90
+
91
+ def earliest_week
92
+ self.class.send(:earliest_week)
93
+ end
94
+
95
+ def week_and_year_to_time(week, year)
96
+ self.class.send(:start_of_year, year) + (week - 1).weeks
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,39 @@
1
+ # encoding: utf-8
2
+
3
+ module DoubleEntry
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
+ YearRange.new(:year => Time.now.year)
17
+ end
18
+
19
+ def self.from_time(time)
20
+ YearRange.new(:year => time.year)
21
+ end
22
+
23
+ def ==(other)
24
+ self.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
@@ -0,0 +1,22 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+ require 'rails/generators/active_record'
4
+
5
+ module DoubleEntry
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include Rails::Generators::Migration
9
+
10
+ source_root File.expand_path('../templates', __FILE__)
11
+
12
+ def self.next_migration_number(path)
13
+ ActiveRecord::Generators::Base.next_migration_number(path)
14
+ end
15
+
16
+ def copy_migrations
17
+ migration_template "migration.rb", "db/migrate/create_double_entry_tables.rb"
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,68 @@
1
+ class CreateDoubleEntryTables < ActiveRecord::Migration
2
+
3
+ def self.up
4
+ create_table "double_entry_account_balances", :force => true do |t|
5
+ t.string "account", :null => false
6
+ t.string "scope"
7
+ t.integer "balance"
8
+ t.timestamps
9
+ end
10
+
11
+ add_index "double_entry_account_balances", ["account"], :name => "index_account_balances_on_account"
12
+ add_index "double_entry_account_balances", ["scope", "account"], :name => "index_account_balances_on_scope_and_account", :unique => true
13
+
14
+ create_table "double_entry_lines", :force => true do |t|
15
+ t.string "account"
16
+ t.string "scope"
17
+ t.string "code"
18
+ t.integer "amount"
19
+ t.integer "balance"
20
+ t.integer "partner_id"
21
+ t.string "partner_account"
22
+ t.string "partner_scope"
23
+ t.string "meta"
24
+ t.integer "detail_id"
25
+ t.string "detail_type"
26
+ t.timestamps
27
+ end
28
+
29
+ add_index "double_entry_lines", ["account", "code", "created_at"], :name => "lines_account_code_created_at_idx"
30
+ add_index "double_entry_lines", ["account", "created_at"], :name => "lines_account_created_at_idx"
31
+ add_index "double_entry_lines", ["scope", "account", "created_at"], :name => "lines_scope_account_created_at_idx"
32
+ add_index "double_entry_lines", ["scope", "account", "id"], :name => "lines_scope_account_id_idx"
33
+
34
+ create_table "double_entry_line_aggregates", :force => true do |t|
35
+ t.string "function"
36
+ t.string "account"
37
+ t.string "code"
38
+ t.string "scope"
39
+ t.integer "year"
40
+ t.integer "month"
41
+ t.integer "week"
42
+ t.integer "day"
43
+ t.integer "hour"
44
+ t.integer "amount"
45
+ t.timestamps
46
+ t.string "filter"
47
+ t.string "range_type"
48
+ end
49
+
50
+ add_index "double_entry_line_aggregates", ["function", "account", "code", "year", "month", "week", "day"], :name => "line_aggregate_idx"
51
+
52
+ create_table "double_entry_line_checks", :force => true do |t|
53
+ t.integer "last_line_id"
54
+ t.boolean "errors_found"
55
+ t.text "log"
56
+ t.timestamps
57
+ end
58
+
59
+ end
60
+
61
+ def self.down
62
+ drop_table "double_entry_line_checks"
63
+ drop_table "double_entry_line_aggregates"
64
+ drop_table "double_entry_lines"
65
+ drop_table "double_entry_account_balances"
66
+ end
67
+
68
+ end
@@ -0,0 +1,201 @@
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 out 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
+ ENV['DB'] ||= 'mysql'
22
+ ActiveRecord::Base.establish_connection YAML.load_file(File.join(support, "database.yml"))[ENV['DB']]
23
+ require "#{support}/schema"
24
+
25
+ lib = File.expand_path('../../lib', __FILE__)
26
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
27
+ require 'double_entry'
28
+
29
+ def parse_options
30
+ $account_count = 5
31
+ $process_count = 20
32
+ $transfer_count = 20000
33
+ $balance_flush_count = 1
34
+ $use_threads = false
35
+
36
+ options = OptionParser.new
37
+
38
+ options.on("-a", "--accounts=COUNT", Integer, "Number of accounts (default: #{$account_count})") do |value|
39
+ $account_count = value
40
+ end
41
+
42
+ options.on("-p", "--processes=COUNT", Integer, "Number of processes (default: #{$process_count})") do |value|
43
+ $process_count = value
44
+ end
45
+
46
+ options.on("-t", "--transfers=COUNT", Integer, "Number of transfers (default: #{$transfer_count})") do |value|
47
+ $transfer_count = value
48
+ end
49
+
50
+ options.on("-f", "--flush-balances=COUNT", Integer, "Flush account balances table COUNT times") do |value|
51
+ $balance_flush_count = value
52
+ end
53
+
54
+ options.on("-z", "--threads", "Use threads instead of processes") do |value|
55
+ $use_threads = !!value
56
+ end
57
+
58
+ options.parse(*ARGV)
59
+ end
60
+
61
+
62
+ def clean_out_database
63
+ puts "Cleaning out the database..."
64
+
65
+ DatabaseCleaner.clean_with(:deletion)
66
+ end
67
+
68
+ def create_accounts_and_transfers
69
+ puts "Setting up #{$account_count} accounts..."
70
+
71
+ # Create the accounts.
72
+ scope = lambda {|x| x }
73
+ DoubleEntry.accounts = DoubleEntry::Account::Set.new
74
+ $account_count.times do |i|
75
+ DoubleEntry.accounts << DoubleEntry::Account.new(:identifier => :"account-#{i}", :scope_identifier => scope)
76
+ end
77
+
78
+ # Create all the possible transfers.
79
+ DoubleEntry.transfers = DoubleEntry::Transfer::Set.new
80
+ DoubleEntry.accounts.each do |from|
81
+ DoubleEntry.accounts.each do |to|
82
+ DoubleEntry.transfers << DoubleEntry::Transfer.new(:from => from.identifier, :to => to.identifier, :code => :test)
83
+ end
84
+ end
85
+
86
+ # Find account instances so we have something to work with.
87
+ $accounts = DoubleEntry.accounts.map do |account|
88
+ DoubleEntry.account(account.identifier, :scope => 1)
89
+ end
90
+ end
91
+
92
+
93
+ def run_tests
94
+ puts "Spawning #{$process_count} processes..."
95
+
96
+ iterations_per_process = $transfer_count / $process_count / $balance_flush_count
97
+
98
+ $balance_flush_count.times do
99
+ puts "Flushing balances"
100
+ DoubleEntry::AccountBalance.delete_all
101
+
102
+ if $use_threads
103
+ puts "Using threads as workers"
104
+ threads = []
105
+ $process_count.times do |process_num|
106
+ threads << Thread.new { run_process(iterations_per_process, process_num) }
107
+ end
108
+
109
+ threads.each(&:join)
110
+ else
111
+ puts "Using processes as workers"
112
+ pids = []
113
+ $process_count.times do |process_num|
114
+ pids << fork { run_process(iterations_per_process, process_num) }
115
+ end
116
+
117
+ pids.each {|pid| Process.wait2(pid) }
118
+ end
119
+
120
+ ActiveRecord::Base.connection_pool.disconnect!
121
+ end
122
+ end
123
+
124
+
125
+ def run_process(iterations, process_num)
126
+ ActiveRecord::Base.connection_pool.disconnect! # Get a unique DB connection for this process.
127
+ srand # Seed the random number generator separately for each process.
128
+
129
+ puts "Process #{process_num} running #{iterations} transfers..."
130
+
131
+ iterations.times do |i|
132
+ account_a = $accounts.sample
133
+ account_b = ($accounts - [account_a]).sample
134
+ amount = rand(1000) + 1
135
+
136
+ DoubleEntry.transfer(Money.new(amount), :from => account_a, :to => account_b, :code => :test)
137
+
138
+ puts "Process #{process_num} completed #{i+1} transfers" if (i+1) % 100 == 0
139
+ end
140
+ end
141
+
142
+
143
+ def reconcile
144
+ error_count = 0
145
+ puts "Reconciling..."
146
+
147
+ if DoubleEntry::Line.count == $transfer_count * 2
148
+ puts "All the Line records were written, FTW!"
149
+ else
150
+ puts "Not enough Line records written. :("
151
+ error_count += 1
152
+ end
153
+
154
+ if $accounts.all? {|account| DoubleEntry.reconciled?(account) }
155
+ puts "All accounts reconciled, FTW!"
156
+ else
157
+ $accounts.each do |account|
158
+ if !DoubleEntry.reconciled?(account)
159
+ puts "Account #{account.identifier} failed to reconcile. :("
160
+
161
+ # See http://bugs.mysql.com/bug.php?id=51431
162
+ use_index = if DoubleEntry::Line.connection.adapter_name.match /mysql/i
163
+ "USE INDEX (lines_scope_account_id_idx)"
164
+ else
165
+ ""
166
+ end
167
+
168
+ rows = ActiveRecord::Base.connection.select_all(<<-SQL)
169
+ SELECT id, amount, balance
170
+ FROM #{DoubleEntry::Line.quoted_table_name} #{use_index}
171
+ WHERE scope = '#{account.scope_identity}'
172
+ AND account = '#{account.identifier}'
173
+ ORDER BY id
174
+ SQL
175
+
176
+ rows.each_cons(2) do |a, b|
177
+ if a["balance"].to_i + b["amount"].to_i != b["balance"].to_i
178
+ puts "Bad lines entry id = #{b['id']}"
179
+ error_count += 1
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ error_count == 0
187
+ end
188
+
189
+
190
+ parse_options
191
+ clean_out_database
192
+ create_accounts_and_transfers
193
+ run_tests
194
+
195
+ if reconcile
196
+ puts "Done successfully :)"
197
+ exit 0
198
+ else
199
+ puts "Done with errors :("
200
+ exit 1
201
+ end
data/script/setup.sh ADDED
@@ -0,0 +1,8 @@
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