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.
- checksums.yaml +13 -5
- data/.gitignore +5 -6
- data/.rspec +1 -0
- data/.travis.yml +19 -0
- data/.yardopts +2 -0
- data/Gemfile +0 -1
- data/LICENSE.md +19 -0
- data/README.md +221 -14
- data/Rakefile +12 -0
- data/double_entry.gemspec +30 -15
- data/gemfiles/Gemfile.rails-3.2.0 +5 -0
- data/gemfiles/Gemfile.rails-4.0.0 +5 -0
- data/gemfiles/Gemfile.rails-4.1.0 +5 -0
- data/lib/active_record/locking_extensions.rb +61 -0
- data/lib/double_entry.rb +267 -2
- data/lib/double_entry/account.rb +82 -0
- data/lib/double_entry/account_balance.rb +31 -0
- data/lib/double_entry/aggregate.rb +118 -0
- data/lib/double_entry/aggregate_array.rb +65 -0
- data/lib/double_entry/configurable.rb +52 -0
- data/lib/double_entry/day_range.rb +38 -0
- data/lib/double_entry/hour_range.rb +40 -0
- data/lib/double_entry/line.rb +147 -0
- data/lib/double_entry/line_aggregate.rb +37 -0
- data/lib/double_entry/line_check.rb +118 -0
- data/lib/double_entry/locking.rb +187 -0
- data/lib/double_entry/month_range.rb +92 -0
- data/lib/double_entry/reporting.rb +16 -0
- data/lib/double_entry/time_range.rb +55 -0
- data/lib/double_entry/time_range_array.rb +43 -0
- data/lib/double_entry/transfer.rb +70 -0
- data/lib/double_entry/version.rb +3 -1
- data/lib/double_entry/week_range.rb +99 -0
- data/lib/double_entry/year_range.rb +39 -0
- data/lib/generators/double_entry/install/install_generator.rb +22 -0
- data/lib/generators/double_entry/install/templates/migration.rb +68 -0
- data/script/jack_hammer +201 -0
- data/script/setup.sh +8 -0
- data/spec/active_record/locking_extensions_spec.rb +54 -0
- data/spec/double_entry/account_balance_spec.rb +8 -0
- data/spec/double_entry/account_spec.rb +23 -0
- data/spec/double_entry/aggregate_array_spec.rb +75 -0
- data/spec/double_entry/aggregate_spec.rb +168 -0
- data/spec/double_entry/double_entry_spec.rb +391 -0
- data/spec/double_entry/line_aggregate_spec.rb +8 -0
- data/spec/double_entry/line_check_spec.rb +88 -0
- data/spec/double_entry/line_spec.rb +72 -0
- data/spec/double_entry/locking_spec.rb +154 -0
- data/spec/double_entry/month_range_spec.rb +131 -0
- data/spec/double_entry/reporting_spec.rb +25 -0
- data/spec/double_entry/time_range_array_spec.rb +149 -0
- data/spec/double_entry/time_range_spec.rb +43 -0
- data/spec/double_entry/week_range_spec.rb +88 -0
- data/spec/generators/double_entry/install/install_generator_spec.rb +33 -0
- data/spec/spec_helper.rb +47 -0
- data/spec/support/accounts.rb +26 -0
- data/spec/support/blueprints.rb +34 -0
- data/spec/support/database.example.yml +16 -0
- data/spec/support/database.travis.yml +18 -0
- data/spec/support/double_entry_spec_helper.rb +19 -0
- data/spec/support/reporting_configuration.rb +6 -0
- data/spec/support/schema.rb +71 -0
- metadata +277 -18
- data/LICENSE.txt +0 -22
data/lib/double_entry/version.rb
CHANGED
@@ -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
|
data/script/jack_hammer
ADDED
@@ -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
|