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