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