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