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