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
data/lib/double_entry.rb CHANGED
@@ -1,5 +1,270 @@
1
- require "double_entry/version"
1
+ # encoding: utf-8
2
2
 
3
+ require 'active_record'
4
+ require 'active_record/locking_extensions'
5
+
6
+ require 'active_support/all'
7
+
8
+ require 'money'
9
+ require 'encapsulate_as_money'
10
+
11
+ require 'double_entry/version'
12
+ require 'double_entry/configurable'
13
+ require 'double_entry/account'
14
+ require 'double_entry/account_balance'
15
+ require 'double_entry/reporting'
16
+ require 'double_entry/aggregate'
17
+ require 'double_entry/aggregate_array'
18
+ require 'double_entry/time_range'
19
+ require 'double_entry/time_range_array'
20
+ require 'double_entry/day_range'
21
+ require 'double_entry/hour_range'
22
+ require 'double_entry/week_range'
23
+ require 'double_entry/month_range'
24
+ require 'double_entry/year_range'
25
+ require 'double_entry/line'
26
+ require 'double_entry/line_aggregate'
27
+ require 'double_entry/line_check'
28
+ require 'double_entry/locking'
29
+ require 'double_entry/transfer'
30
+
31
+ # Keep track of all the monies!
32
+ #
33
+ # This module provides the public interfaces for everything to do with
34
+ # transferring money around the system.
3
35
  module DoubleEntry
4
- # Your code goes here...
36
+
37
+ class UnknownAccount < RuntimeError; end
38
+ class TransferNotAllowed < RuntimeError; end
39
+ class TransferIsNegative < RuntimeError; end
40
+ class RequiredMetaMissing < RuntimeError; end
41
+ class DuplicateAccount < RuntimeError; end
42
+ class DuplicateTransfer < RuntimeError; end
43
+ class UserAccountNotLocked < RuntimeError; end
44
+ class AccountWouldBeSentNegative < RuntimeError; end
45
+
46
+ class << self
47
+ attr_accessor :accounts, :transfers
48
+
49
+ # Get the particular account instance with the provided identifier and
50
+ # scope.
51
+ #
52
+ # @example Obtain the 'cash' account for a user
53
+ # DoubleEntry.account(:cash, scope: user)
54
+ # @param identifier [Symbol] The symbol identifying the desired account. As
55
+ # specified in the account configuration.
56
+ # @option options :scope Limit the account to the given scope. As specified
57
+ # in the account configuration.
58
+ # @return [DoubleEntry::Account::Instance]
59
+ # @raise [DoubleEntry::UnknownAccount] The described account has not been
60
+ # configured. It is unknown.
61
+ #
62
+ def account(identifier, options = {})
63
+ account = @accounts.detect do |current_account|
64
+ current_account.identifier == identifier and
65
+ (options[:scope] ? current_account.scoped? : !current_account.scoped?)
66
+ end
67
+
68
+ if account
69
+ DoubleEntry::Account::Instance.new(:account => account, :scope => options[:scope])
70
+ else
71
+ raise UnknownAccount.new("account: #{identifier} scope: #{options[:scope]}")
72
+ end
73
+ end
74
+
75
+ # Transfer money from one account to another.
76
+ #
77
+ # Only certain transfers are allowed. Define legal transfers in your
78
+ # configuration file.
79
+ #
80
+ # If you're doing more than one transfer in one hit, or you're doing other
81
+ # database operations along with your transfer, you'll need to use the
82
+ # lock_accounts method.
83
+ #
84
+ # @example Transfer $20 from a user's checking to savings account
85
+ # checking_account = DoubleEntry.account(:checking, scope: user)
86
+ # savings_account = DoubleEntry.account(:savings, scope: user)
87
+ # DoubleEntry.transfer(
88
+ # Money.new(20_00),
89
+ # from: checking_account,
90
+ # to: savings_account,
91
+ # code: :save,
92
+ # )
93
+ # @param amount [Money] The quantity of money to transfer from one account
94
+ # to the other.
95
+ # @option options :from [DoubleEntry::Account::Instance] Transfer money out
96
+ # of this account.
97
+ # @option options :to [DoubleEntry::Account::Instance] Transfer money into
98
+ # this account.
99
+ # @option options :code [Symbol] Your application specific code for this
100
+ # type of transfer. As specified in the transfer configuration.
101
+ # @option options :meta [String] Metadata to associate with this transfer.
102
+ # @option options :detail [ActiveRecord::Base] ActiveRecord model
103
+ # associated (via a polymorphic association) with the transfer.
104
+ # @raise [DoubleEntry::TransferIsNegative] The amount is less than zero.
105
+ # @raise [DoubleEntry::TransferNotAllowed] A transfer between these
106
+ # accounts with the provided code is not allowed. Check configuration.
107
+ #
108
+ def transfer(amount, options = {})
109
+ raise TransferIsNegative if amount < Money.new(0)
110
+ from, to, code, meta, detail = options[:from], options[:to], options[:code], options[:meta], options[:detail]
111
+ transfer = @transfers.find(from, to, code)
112
+ if transfer
113
+ transfer.process!(amount, from, to, code, meta, detail)
114
+ else
115
+ raise TransferNotAllowed.new([from.identifier, to.identifier, code].inspect)
116
+ end
117
+ end
118
+
119
+ # Get the current balance of an account, as a Money object.
120
+ #
121
+ # @param account [DoubleEntry::Account:Instance, Symbol]
122
+ # @option options :scope [Symbol]
123
+ # @option options :from [Time]
124
+ # @option options :to [Time]
125
+ # @option options :at [Time]
126
+ # @option options :code [Symbol]
127
+ # @option options :codes [Array<Symbol>]
128
+ # @return [Money]
129
+ def balance(account, options = {})
130
+ scope_arg = options[:scope] ? options[:scope].id.to_s : nil
131
+ scope = (account.is_a?(Symbol) ? scope_arg : account.scope_identity)
132
+ account = (account.is_a?(Symbol) ? account : account.identifier).to_s
133
+ from, to, at = options[:from], options[:to], options[:at]
134
+ code, codes = options[:code], options[:codes]
135
+
136
+ # time based scoping
137
+ conditions = if at
138
+ # lookup method could use running balance, with a order by limit one clause
139
+ # (unless it's a reporting call, i.e. account == symbol and not an instance)
140
+ ['account = ? and created_at <= ?', account, at] # index this??
141
+ elsif from and to
142
+ ['account = ? and created_at >= ? and created_at <= ?', account, from, to] # index this??
143
+ else
144
+ # lookup method could use running balance, with a order by limit one clause
145
+ # (unless it's a reporting call, i.e. account == symbol and not an instance)
146
+ ['account = ?', account]
147
+ end
148
+
149
+ # code based scoping
150
+ if code
151
+ conditions[0] << ' and code = ?' # index this??
152
+ conditions << code.to_s
153
+ elsif codes
154
+ conditions[0] << ' and code in (?)' # index this??
155
+ conditions << codes.collect { |c| c.to_s }
156
+ end
157
+
158
+ # account based scoping
159
+ if scope
160
+ conditions[0] << ' and scope = ?'
161
+ conditions << scope
162
+
163
+ # This is to work around a MySQL 5.1 query optimiser bug that causes the ORDER BY
164
+ # on the query to fail in some circumstances, resulting in an old balance being
165
+ # returned. This was biting us intermittently in spec runs.
166
+ # See http://bugs.mysql.com/bug.php?id=51431
167
+ if Line.connection.adapter_name.match /mysql/i
168
+ use_index = "USE INDEX (lines_scope_account_id_idx)"
169
+ end
170
+ end
171
+
172
+ if (from and to) or (code or codes)
173
+ # from and to or code lookups have to be done via sum
174
+ Money.new(Line.where(conditions).sum(:amount))
175
+ else
176
+ # all other lookups can be performed with running balances
177
+ line = Line.select("id, balance").from("#{Line.quoted_table_name} #{use_index}").where(conditions).order('id desc').first
178
+ line ? line.balance : Money.empty
179
+ end
180
+ end
181
+
182
+ # Identify the scopes with the given account identifier holding at least
183
+ # the provided minimum balance.
184
+ #
185
+ # @example Find users with at lease $1,000,000 in their savings accounts
186
+ # DoubleEntry.scopes_with_minimum_balance_for_account(
187
+ # Money.new(1_000_000_00),
188
+ # :savings
189
+ # ) # might return user ids: [ 1423, 12232, 34729 ]
190
+ # @param minimum_balance [Money] Minimum account balance a scope must have
191
+ # to be included in the result set.
192
+ # @param account_identifier [Symbol]
193
+ # @return [Array<Fixnum>] Scopes
194
+ def scopes_with_minimum_balance_for_account(minimum_balance, account_identifier)
195
+ select_values(sanitize_sql_array([<<-SQL, account_identifier, minimum_balance.cents])).map {|scope| scope.to_i }
196
+ SELECT scope
197
+ FROM #{AccountBalance.table_name}
198
+ WHERE account = ?
199
+ AND balance >= ?
200
+ SQL
201
+ end
202
+
203
+ # Lock accounts in preparation for transfers.
204
+ #
205
+ # This creates a transaction, and uses database-level locking to ensure
206
+ # that we're the only ones who can transfer to or from the given accounts
207
+ # for the duration of the transaction.
208
+ #
209
+ # @example Lock the savings and checking accounts for a user
210
+ # checking_account = DoubleEntry.account(:checking, scope: user)
211
+ # savings_account = DoubleEntry.account(:savings, scope: user)
212
+ # DoubleEntry.lock_accounts(checking_account, savings_account) do
213
+ # # ...
214
+ # end
215
+ # @yield Hold the locks while the provided block is processed.
216
+ # @raise [DoubleEntry::Locking::LockMustBeOutermostTransaction]
217
+ # The transaction must be the outermost database transaction
218
+ #
219
+ def lock_accounts(*accounts, &block)
220
+ DoubleEntry::Locking.lock_accounts(*accounts, &block)
221
+ end
222
+
223
+ # @api private
224
+ def describe(line)
225
+ # make sure we have a test for this refactoring, the test
226
+ # conditions are: i forget... but it's important!
227
+ if line.credit?
228
+ @transfers.find(line.account, line.partner_account, line.code)
229
+ else
230
+ @transfers.find(line.partner_account, line.account, line.code)
231
+ end.description.call(line)
232
+ end
233
+
234
+ def aggregate(function, account, code, options = {})
235
+ DoubleEntry::Aggregate.new(function, account, code, options).formatted_amount
236
+ end
237
+
238
+ def aggregate_array(function, account, code, options = {})
239
+ DoubleEntry::AggregateArray.new(function, account, code, options)
240
+ end
241
+
242
+ # This is used by the concurrency test script.
243
+ #
244
+ # @api private
245
+ # @return [Boolean] true if all the amounts for an account add up to the final balance,
246
+ # which they always should.
247
+ def reconciled?(account)
248
+ scoped_lines = Line.where(:account => "#{account.identifier}", :scope => "#{account.scope}")
249
+ sum_of_amounts = scoped_lines.sum(:amount)
250
+ final_balance = scoped_lines.order(:id).last[:balance]
251
+ cached_balance = AccountBalance.find_by_account(account)[:balance]
252
+ final_balance == sum_of_amounts && final_balance == cached_balance
253
+ end
254
+
255
+ def table_name_prefix
256
+ 'double_entry_'
257
+ end
258
+
259
+ private
260
+
261
+ delegate :connection, :to => ActiveRecord::Base
262
+ delegate :select_values, :to => :connection
263
+
264
+ def sanitize_sql_array(sql_array)
265
+ ActiveRecord::Base.send(:sanitize_sql_array, sql_array)
266
+ end
267
+
268
+ end
269
+
5
270
  end
@@ -0,0 +1,82 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ class Account
4
+ class Set < Array
5
+ def <<(account)
6
+ if detect { |a| a.identifier == account.identifier }
7
+ raise DuplicateAccount.new
8
+ else
9
+ super(account)
10
+ end
11
+ end
12
+ end
13
+
14
+ class Instance
15
+ attr_accessor :account, :scope
16
+
17
+ def initialize(attributes)
18
+ attributes.each { |name, value| send("#{name}=", value) }
19
+ end
20
+
21
+ def method_missing(method, *args)
22
+ if block_given?
23
+ account.send(method, *args, &Proc.new)
24
+ else
25
+ account.send(method, *args)
26
+ end
27
+ end
28
+
29
+ def scope_identity
30
+ scope_identifier.call(scope).to_s if scoped?
31
+ end
32
+
33
+ def balance(args = {})
34
+ DoubleEntry.balance(self, args)
35
+ end
36
+
37
+ include Comparable
38
+
39
+ def ==(other)
40
+ other.is_a?(self.class) && identifier == other.identifier && scope_identity == other.scope_identity
41
+ end
42
+
43
+ def eql?(other)
44
+ self == other
45
+ end
46
+
47
+ def <=>(account)
48
+ if scoped?
49
+ [scope_identity, identifier.to_s] <=> [account.scope_identity, account.identifier.to_s]
50
+ else
51
+ identifier.to_s <=> account.identifier.to_s
52
+ end
53
+ end
54
+
55
+ def hash
56
+ if scoped?
57
+ "#{scope_identity}:#{identifier}".hash
58
+ else
59
+ identifier.hash
60
+ end
61
+ end
62
+
63
+ def to_s
64
+ "\#{Account account: #{identifier} scope: #{scope}}"
65
+ end
66
+
67
+ def inspect
68
+ to_s
69
+ end
70
+ end
71
+
72
+ attr_accessor :identifier, :scope_identifier, :positive_only
73
+
74
+ def initialize(attributes)
75
+ attributes.each { |name, value| send("#{name}=", value) }
76
+ end
77
+
78
+ def scoped?
79
+ !!scope_identifier
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,31 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+
4
+ # Account balance records cache the current balance for each account. They
5
+ # also provide a database representation of an account that we can use to do
6
+ # DB level locking.
7
+ #
8
+ # See DoubleEntry::Locking for more info on locking.
9
+ #
10
+ # Account balances are created on demand when transfers occur.
11
+ class AccountBalance < ActiveRecord::Base
12
+ extend EncapsulateAsMoney
13
+
14
+ encapsulate_as_money :balance
15
+
16
+ def account=(account)
17
+ self[:account] = account.identifier.to_s
18
+ self[:scope] = account.scope_identity
19
+ account
20
+ end
21
+
22
+ def self.find_by_account(account, options = {})
23
+ scope = where(:scope => account.scope_identity, :account => account.identifier.to_s)
24
+ scope = scope.lock(true) if options[:lock]
25
+ scope.first
26
+ end
27
+
28
+ end
29
+
30
+ end
31
+
@@ -0,0 +1,118 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ class Aggregate
4
+ attr_reader :function, :account, :code, :scope, :range, :options, :filter
5
+
6
+ def initialize(function, account, code, options)
7
+ @function = function.to_s
8
+ raise "Function not supported" unless %w[sum count average].include?(@function)
9
+
10
+ @account = account.to_s
11
+ @code = code ? code.to_s : nil
12
+ @options = options
13
+ @scope = options[:scope]
14
+ @range = options[:range]
15
+ @filter = options[:filter]
16
+ end
17
+
18
+ def amount(force_recalculation = false)
19
+ if force_recalculation
20
+ clear_old_aggregates
21
+ calculate
22
+ else
23
+ retrieve || calculate
24
+ end
25
+ end
26
+
27
+ def formatted_amount
28
+ Aggregate.formatted_amount(function, amount)
29
+ end
30
+
31
+ def self.formatted_amount(function, amount)
32
+ safe_amount = amount || 0
33
+
34
+ case function.to_s
35
+ when 'count'
36
+ safe_amount
37
+ else
38
+ Money.new(safe_amount)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def retrieve
45
+ aggregate = LineAggregate.where(field_hash).first
46
+ aggregate.amount if aggregate
47
+ end
48
+
49
+ def clear_old_aggregates
50
+ LineAggregate.delete_all(field_hash)
51
+ end
52
+
53
+ def calculate
54
+ if range.class == DoubleEntry::YearRange
55
+ aggregate = calculate_yearly_aggregate
56
+ else
57
+ aggregate = LineAggregate.aggregate(function, account, code, nil, range, filter)
58
+ end
59
+
60
+ if range_is_complete?
61
+ fields = field_hash
62
+ fields[:amount] = aggregate || 0
63
+ LineAggregate.create! fields
64
+ end
65
+
66
+ aggregate
67
+ end
68
+
69
+ def calculate_yearly_aggregate
70
+ # We calculate yearly aggregates by combining monthly aggregates
71
+ # otherwise they will get excruciatingly slow to calculate
72
+ # as the year progresses. (I am thinking mainly of the 'current' year.)
73
+ # Combining monthly aggregates will mean that the figure will be partially memoized
74
+ case function.to_s
75
+ when 'average'
76
+ calculate_yearly_average
77
+ else
78
+ zero = Aggregate.formatted_amount(function, 0)
79
+
80
+ result = (1..12).inject(zero) do |total, month|
81
+ total += DoubleEntry.aggregate(function, account, code,
82
+ :range => DoubleEntry::MonthRange.new(:year => range.year, :month => month), :filter => filter)
83
+ end
84
+
85
+ result = result.cents if result.class == Money
86
+ result
87
+ end
88
+ end
89
+
90
+ def calculate_yearly_average
91
+ # need this seperate function, because an average of averages is not the correct average
92
+ sum = DoubleEntry.aggregate(:sum, account, code,
93
+ :range => DoubleEntry::YearRange.new(:year => range.year), :filter => filter)
94
+ count = DoubleEntry.aggregate(:count, account, code,
95
+ :range => DoubleEntry::YearRange.new(:year => range.year), :filter => filter)
96
+ (count == 0) ? 0 : (sum / count).cents
97
+ end
98
+
99
+ def range_is_complete?
100
+ Time.now > range.finish
101
+ end
102
+
103
+ def field_hash
104
+ {
105
+ :function => function,
106
+ :account => account,
107
+ :code => code,
108
+ :year => range.year,
109
+ :month => range.month,
110
+ :week => range.week,
111
+ :day => range.day,
112
+ :hour => range.hour,
113
+ :filter => filter.inspect,
114
+ :range_type => range.range_type.to_s
115
+ }
116
+ end
117
+ end
118
+ end