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