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