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
data/lib/double_entry.rb
CHANGED
@@ -1,5 +1,270 @@
|
|
1
|
-
|
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
|
-
|
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
|