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