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
@@ -0,0 +1,187 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
|
4
|
+
# Lock financial accounts to ensure consistency.
|
5
|
+
#
|
6
|
+
# In order to ensure financial transactions always keep track of balances
|
7
|
+
# consistently, database-level locking is needed. This module takes care of
|
8
|
+
# it.
|
9
|
+
#
|
10
|
+
# See DoubleEntry.lock_accounts and DoubleEntry.transfer for the public interface
|
11
|
+
# to this stuff.
|
12
|
+
#
|
13
|
+
# Locking is done on DoubleEntry::AccountBalance records. If an AccountBalance
|
14
|
+
# record for an account doesn't exist when you try to lock it, the locking
|
15
|
+
# code will create one.
|
16
|
+
#
|
17
|
+
# script/jack_hammer can be used to run concurrency tests on double_entry to
|
18
|
+
# validates that locking works properly.
|
19
|
+
module Locking
|
20
|
+
include Configurable
|
21
|
+
|
22
|
+
class Configuration
|
23
|
+
# Set this in your tests if you're using transactional_fixtures, so we know
|
24
|
+
# not to complain about a containing transaction when you call lock_accounts.
|
25
|
+
attr_accessor :running_inside_transactional_fixtures
|
26
|
+
|
27
|
+
def initialize #:nodoc:
|
28
|
+
@running_inside_transactional_fixtures = false
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Run the passed in block in a transaction with the given accounts locked for update.
|
33
|
+
#
|
34
|
+
# The transaction must be the outermost transaction to ensure data integrity. A
|
35
|
+
# LockMustBeOutermostTransaction will be raised if it isn't.
|
36
|
+
def self.lock_accounts(*accounts)
|
37
|
+
lock = Lock.new(accounts)
|
38
|
+
|
39
|
+
if lock.in_a_locked_transaction?
|
40
|
+
lock.ensure_locked!
|
41
|
+
yield
|
42
|
+
else
|
43
|
+
lock.perform_lock(&Proc.new)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Return the account balance record for the given account name if there's a
|
48
|
+
# lock on it, or raise a LockNotHeld if there isn't.
|
49
|
+
def self.balance_for_locked_account(account)
|
50
|
+
Lock.new([account]).balance_for(account)
|
51
|
+
end
|
52
|
+
|
53
|
+
class Lock
|
54
|
+
@@locks = Hash.new
|
55
|
+
|
56
|
+
def initialize(accounts)
|
57
|
+
# Make sure we always lock in the same order, to avoid deadlocks.
|
58
|
+
@accounts = accounts.flatten.sort
|
59
|
+
end
|
60
|
+
|
61
|
+
# Lock the given accounts, creating account balance records for them if
|
62
|
+
# needed.
|
63
|
+
def perform_lock(&block)
|
64
|
+
ensure_outermost_transaction!
|
65
|
+
|
66
|
+
unless lock_and_call(&block)
|
67
|
+
create_missing_account_balances
|
68
|
+
unless lock_and_call(&block)
|
69
|
+
raise LockDisaster
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Return true if we're inside a lock_accounts block.
|
75
|
+
def in_a_locked_transaction?
|
76
|
+
!locks.nil?
|
77
|
+
end
|
78
|
+
|
79
|
+
def ensure_locked!
|
80
|
+
@accounts.each do |account|
|
81
|
+
raise LockNotHeld.new(account) unless have_lock?(account)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def balance_for(account)
|
86
|
+
ensure_locked!
|
87
|
+
|
88
|
+
locks[account]
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def locks
|
94
|
+
@@locks[Thread.current.object_id]
|
95
|
+
end
|
96
|
+
|
97
|
+
def locks=(locks)
|
98
|
+
@@locks[Thread.current.object_id] = locks
|
99
|
+
end
|
100
|
+
|
101
|
+
def remove_locks
|
102
|
+
@@locks.delete(Thread.current.object_id)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Return true if there's a lock on the given account.
|
106
|
+
def have_lock?(account)
|
107
|
+
in_a_locked_transaction? && locks.has_key?(account)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Raise an exception unless we're outside any transactions.
|
111
|
+
def ensure_outermost_transaction!
|
112
|
+
minimum_transaction_level = Locking.configuration.running_inside_transactional_fixtures ? 1 : 0
|
113
|
+
unless AccountBalance.connection.open_transactions <= minimum_transaction_level
|
114
|
+
raise LockMustBeOutermostTransaction
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
# Start a transaction, grab locks on the given accounts, then call the block
|
120
|
+
# from within the transaction.
|
121
|
+
#
|
122
|
+
# If any account can't be locked (because there isn't a corresponding account
|
123
|
+
# balance record), don't call the block, and return false.
|
124
|
+
def lock_and_call
|
125
|
+
locks_succeeded = nil
|
126
|
+
AccountBalance.restartable_transaction do
|
127
|
+
locks_succeeded = AccountBalance.with_restart_on_deadlock { grab_locks }
|
128
|
+
if locks_succeeded
|
129
|
+
begin
|
130
|
+
yield
|
131
|
+
ensure
|
132
|
+
remove_locks
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
locks_succeeded
|
137
|
+
end
|
138
|
+
|
139
|
+
# Grab a lock on the account balance record for each account.
|
140
|
+
#
|
141
|
+
# If all the account balance records exist, set locks to a hash mapping
|
142
|
+
# accounts to account balances, and return true.
|
143
|
+
#
|
144
|
+
# If one or more account balance records don't exist, set
|
145
|
+
# accounts_with_balances to the corresponding accounts, and return false.
|
146
|
+
def grab_locks
|
147
|
+
account_balances = @accounts.map {|account| AccountBalance.find_by_account(account, :lock => true) }
|
148
|
+
|
149
|
+
if account_balances.any?(&:nil?)
|
150
|
+
@accounts_without_balances = @accounts.zip(account_balances).
|
151
|
+
select {|account, account_balance| account_balance.nil? }.
|
152
|
+
collect {|account, account_balance| account }
|
153
|
+
false
|
154
|
+
else
|
155
|
+
self.locks = Hash[*@accounts.zip(account_balances).flatten]
|
156
|
+
true
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Create all the account_balances for the given accounts.
|
161
|
+
def create_missing_account_balances
|
162
|
+
@accounts_without_balances.each do |account|
|
163
|
+
# Get the initial balance from the lines table.
|
164
|
+
balance = account.balance
|
165
|
+
|
166
|
+
# Try to create the balance record, but ignore it if someone else has done it in the meantime.
|
167
|
+
AccountBalance.create_ignoring_duplicates!(:account => account, :balance => balance)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# Raised when lock_accounts is called inside an existing transaction.
|
173
|
+
class LockMustBeOutermostTransaction < RuntimeError
|
174
|
+
end
|
175
|
+
|
176
|
+
# Raised when attempting a transfer on an account that's not locked.
|
177
|
+
class LockNotHeld < RuntimeError
|
178
|
+
def initialize(account)
|
179
|
+
super "No lock held for account: #{account.identifier}, scope #{account.scope}"
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Raised if things go horribly, horribly wrong. This should never happen.
|
184
|
+
class LockDisaster < RuntimeError
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
class MonthRange < TimeRange
|
4
|
+
|
5
|
+
class << self
|
6
|
+
def from_time(time)
|
7
|
+
new(:year => time.year, :month => time.month)
|
8
|
+
end
|
9
|
+
|
10
|
+
def current
|
11
|
+
from_time(Time.now)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Obtain a sequence of MonthRanges from the given start to the current
|
15
|
+
# month.
|
16
|
+
#
|
17
|
+
# @option options :from [Time] Time of the first in the returned sequence
|
18
|
+
# of MonthRanges.
|
19
|
+
# @return [Array<MonthRange>]
|
20
|
+
def reportable_months(options = {})
|
21
|
+
month = options[:from] ? from_time(options[:from]) : earliest_month
|
22
|
+
last = self.current
|
23
|
+
[month].tap do |months|
|
24
|
+
while month != last
|
25
|
+
month = month.next
|
26
|
+
months << month
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def earliest_month
|
32
|
+
from_time(DoubleEntry::Reporting.configuration.start_of_business)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
attr_reader :year, :month
|
37
|
+
|
38
|
+
def initialize(options = {})
|
39
|
+
super options
|
40
|
+
|
41
|
+
if options.present?
|
42
|
+
@month = options[:month]
|
43
|
+
|
44
|
+
month_start = Time.local(year, options[:month], 1)
|
45
|
+
@start = month_start
|
46
|
+
@finish = month_start.end_of_month
|
47
|
+
|
48
|
+
@start = MonthRange.earliest_month.start if options[:range_type] == :all_time
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def previous
|
53
|
+
if month <= 1
|
54
|
+
MonthRange.new :year => year - 1, :month => 12
|
55
|
+
else
|
56
|
+
MonthRange.new :year => year, :month => month - 1
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def next
|
61
|
+
if month >= 12
|
62
|
+
MonthRange.new :year => year + 1, :month => 1
|
63
|
+
else
|
64
|
+
MonthRange.new :year => year, :month => month + 1
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def beginning_of_financial_year
|
69
|
+
first_month_of_financial_year = DoubleEntry::Reporting.configuration.first_month_of_financial_year
|
70
|
+
year = (month >= first_month_of_financial_year) ? @year : (@year - 1)
|
71
|
+
MonthRange.new(:year => year, :month => first_month_of_financial_year)
|
72
|
+
end
|
73
|
+
|
74
|
+
alias_method :succ, :next
|
75
|
+
|
76
|
+
def <=>(other)
|
77
|
+
self.start <=> other.start
|
78
|
+
end
|
79
|
+
|
80
|
+
def ==(other)
|
81
|
+
(self.month == other.month) and (self.year == other.year)
|
82
|
+
end
|
83
|
+
|
84
|
+
def all_time
|
85
|
+
MonthRange.new(:year => year, :month => month, :range_type => :all_time)
|
86
|
+
end
|
87
|
+
|
88
|
+
def to_s
|
89
|
+
start.strftime("%Y, %b")
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
module Reporting
|
4
|
+
include Configurable
|
5
|
+
|
6
|
+
class Configuration
|
7
|
+
attr_accessor :start_of_business, :first_month_of_financial_year
|
8
|
+
|
9
|
+
def initialize #:nodoc:
|
10
|
+
@start_of_business = Time.new(1970, 1, 1)
|
11
|
+
@first_month_of_financial_year = 7
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
class TimeRange
|
4
|
+
attr_reader :start, :finish
|
5
|
+
attr_reader :year, :month, :week, :day, :hour, :range_type
|
6
|
+
|
7
|
+
def self.make(options = {})
|
8
|
+
@options = options
|
9
|
+
case
|
10
|
+
when (options[:year] and options[:week] and options[:day] and options[:hour])
|
11
|
+
HourRange.new(options)
|
12
|
+
when (options[:year] and options[:week] and options[:day])
|
13
|
+
DayRange.new(options)
|
14
|
+
when (options[:year] and options[:week])
|
15
|
+
WeekRange.new(options)
|
16
|
+
when (options[:year] and options[:month])
|
17
|
+
MonthRange.new(options)
|
18
|
+
when options[:year]
|
19
|
+
YearRange.new(options)
|
20
|
+
else
|
21
|
+
raise "Invalid range information #{options}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.range_from_time_for_period(start_time, period_name)
|
26
|
+
case period_name
|
27
|
+
when 'month'
|
28
|
+
DoubleEntry::YearRange.from_time(start_time)
|
29
|
+
when 'week'
|
30
|
+
DoubleEntry::YearRange.from_time(start_time)
|
31
|
+
when 'day'
|
32
|
+
DoubleEntry::MonthRange.from_time(start_time)
|
33
|
+
when 'hour'
|
34
|
+
DoubleEntry::DayRange.from_time(start_time)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def include?(time)
|
39
|
+
(time >= @start) and (time <= @finish)
|
40
|
+
end
|
41
|
+
|
42
|
+
def initialize(options)
|
43
|
+
@year = options[:year]
|
44
|
+
@range_type = options[:range_type] || :normal
|
45
|
+
end
|
46
|
+
|
47
|
+
def key
|
48
|
+
"#{@year}:#{@month}:#{@week}:#{@day}:#{@hour}"
|
49
|
+
end
|
50
|
+
|
51
|
+
def human_readable_name
|
52
|
+
self.class.name.gsub('DoubleEntry::', '').gsub('Range', '')
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
class TimeRangeArray
|
4
|
+
class << self
|
5
|
+
|
6
|
+
def make(range_type, start, finish = nil)
|
7
|
+
raise "Must specify range for #{range_type}-by-#{range_type} reports" if start == nil
|
8
|
+
|
9
|
+
case range_type
|
10
|
+
when 'hour'
|
11
|
+
make_array HourRange, start, finish
|
12
|
+
when 'day'
|
13
|
+
make_array DayRange, start, finish
|
14
|
+
when 'week'
|
15
|
+
make_array WeekRange, start, finish
|
16
|
+
when 'month'
|
17
|
+
make_array MonthRange, start, finish
|
18
|
+
when 'year'
|
19
|
+
make_array YearRange, start
|
20
|
+
else
|
21
|
+
raise ArgumentError.new("Invalid range type '#{range_type}'")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def make_array(type, start, finish = nil)
|
28
|
+
start = type.from_time(Time.parse(start))
|
29
|
+
finish = type.from_time(Time.parse(finish)) if finish
|
30
|
+
|
31
|
+
loop = start
|
32
|
+
last = finish || type.current
|
33
|
+
results = [loop]
|
34
|
+
while(loop != last) do
|
35
|
+
loop = loop.next
|
36
|
+
results << loop
|
37
|
+
end
|
38
|
+
|
39
|
+
results
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
class Transfer
|
4
|
+
class Set < Array
|
5
|
+
def find(from, to, code)
|
6
|
+
_find(from.identifier, to.identifier, code)
|
7
|
+
end
|
8
|
+
|
9
|
+
def <<(transfer)
|
10
|
+
if _find(transfer.from, transfer.to, transfer.code)
|
11
|
+
raise DuplicateTransfer.new
|
12
|
+
else
|
13
|
+
super(transfer)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def _find(from, to, code)
|
20
|
+
detect do |transfer|
|
21
|
+
transfer.from == from and transfer.to == to and transfer.code == code
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_accessor :code, :from, :to, :description, :meta_requirement
|
27
|
+
|
28
|
+
def initialize(attributes)
|
29
|
+
@meta_requirement = []
|
30
|
+
attributes.each { |name, value| send("#{name}=", value) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def process!(amount, from, to, code, meta, detail)
|
34
|
+
if from.scope_identity == to.scope_identity and from.identifier == to.identifier
|
35
|
+
raise TransferNotAllowed.new
|
36
|
+
end
|
37
|
+
|
38
|
+
meta_requirement.each do |key|
|
39
|
+
if meta[key].nil?
|
40
|
+
raise RequiredMetaMissing.new
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
Locking.lock_accounts(from, to) do
|
45
|
+
credit, debit = Line.new, Line.new
|
46
|
+
|
47
|
+
credit_balance = Locking.balance_for_locked_account(from)
|
48
|
+
debit_balance = Locking.balance_for_locked_account(to)
|
49
|
+
|
50
|
+
credit_balance.update_attribute :balance, credit_balance.balance - amount
|
51
|
+
debit_balance.update_attribute :balance, debit_balance.balance + amount
|
52
|
+
|
53
|
+
credit.amount, debit.amount = -amount, amount
|
54
|
+
credit.account, debit.account = from, to
|
55
|
+
credit.code, debit.code = code, code
|
56
|
+
credit.meta, debit.meta = meta, meta
|
57
|
+
credit.detail, debit.detail = detail, detail
|
58
|
+
credit.balance, debit.balance = credit_balance.balance, debit_balance.balance
|
59
|
+
|
60
|
+
credit.partner_account, debit.partner_account = to, from
|
61
|
+
|
62
|
+
credit.save!
|
63
|
+
debit.partner_id = credit.id
|
64
|
+
debit.save!
|
65
|
+
credit.update_attribute :partner_id, debit.id
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
end
|