double_entry 0.2.0 → 0.3.0
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.
- data/README.md +19 -11
- data/lib/double_entry.rb +9 -104
- data/lib/double_entry/account.rb +27 -2
- data/lib/double_entry/balance_calculator.rb +105 -0
- data/lib/double_entry/errors.rb +13 -0
- data/lib/double_entry/line.rb +0 -4
- data/lib/double_entry/reporting.rb +86 -1
- data/lib/double_entry/reporting/aggregate.rb +3 -4
- data/lib/double_entry/reporting/line_aggregate.rb +1 -1
- data/lib/double_entry/reporting/time_range_array.rb +30 -33
- data/lib/double_entry/transfer.rb +17 -0
- data/lib/double_entry/version.rb +1 -1
- data/script/jack_hammer +2 -2
- data/spec/double_entry/balance_calculator_spec.rb +131 -0
- data/spec/double_entry/reporting/aggregate_array_spec.rb +72 -58
- data/spec/double_entry/reporting/aggregate_spec.rb +36 -0
- data/spec/double_entry/reporting/time_range_array_spec.rb +4 -5
- data/spec/double_entry_spec.rb +0 -5
- metadata +6 -2
data/README.md
CHANGED
@@ -66,7 +66,9 @@ than scoped accounts due to lock contention.
|
|
66
66
|
|
67
67
|
To get a particular account:
|
68
68
|
|
69
|
-
|
69
|
+
```ruby
|
70
|
+
account = DoubleEntry.account(:spending, :scope => user)
|
71
|
+
```
|
70
72
|
|
71
73
|
(This actually returns an Account::Instance object.)
|
72
74
|
|
@@ -77,7 +79,9 @@ See **DoubleEntry::Account** for more info.
|
|
77
79
|
|
78
80
|
Calling:
|
79
81
|
|
80
|
-
|
82
|
+
```ruby
|
83
|
+
account.balance
|
84
|
+
```
|
81
85
|
|
82
86
|
will return the current balance for an account as a Money object.
|
83
87
|
|
@@ -86,7 +90,9 @@ will return the current balance for an account as a Money object.
|
|
86
90
|
|
87
91
|
To transfer money between accounts:
|
88
92
|
|
89
|
-
|
93
|
+
```ruby
|
94
|
+
DoubleEntry.transfer(20.dollars, :from => account_a, :to => account_b, :code => :purchase)
|
95
|
+
```
|
90
96
|
|
91
97
|
The possible transfers, and their codes, should be defined in the configuration.
|
92
98
|
|
@@ -99,10 +105,12 @@ If you're doing more than one transfer in a single financial transaction, or
|
|
99
105
|
you're doing other database operations along with the transfer, you'll need to
|
100
106
|
manually lock the accounts you're using:
|
101
107
|
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
108
|
+
```ruby
|
109
|
+
DoubleEntry.lock_accounts(account_a, account_b) do
|
110
|
+
# Do some other stuff in here...
|
111
|
+
DoubleEntry.transfer(20.dollars, :from => account_a, :to => account_b, :code => :purchase)
|
112
|
+
end
|
113
|
+
```
|
106
114
|
|
107
115
|
The lock_accounts call generates a database transaction, which must be the
|
108
116
|
outermost transaction.
|
@@ -152,13 +160,13 @@ DoubleEntry.configure do |config|
|
|
152
160
|
end
|
153
161
|
end
|
154
162
|
|
155
|
-
accounts.define(identifier
|
156
|
-
accounts.define(identifier
|
163
|
+
accounts.define(:identifier => :savings, :scope_identifier => user_scope, :positive_only => true)
|
164
|
+
accounts.define(:identifier => :checking, :scope_identifier => user_scope)
|
157
165
|
end
|
158
166
|
|
159
167
|
config.define_transfers do |transfers|
|
160
|
-
transfers.define(from
|
161
|
-
transfers.define(from
|
168
|
+
transfers.define(:from => :checking, :to => :savings, :code => :deposit)
|
169
|
+
transfers.define(:from => :savings, :to => :checking, :code => :withdraw)
|
162
170
|
end
|
163
171
|
end
|
164
172
|
```
|
data/lib/double_entry.rb
CHANGED
@@ -6,10 +6,12 @@ require 'money'
|
|
6
6
|
require 'encapsulate_as_money'
|
7
7
|
|
8
8
|
require 'double_entry/version'
|
9
|
+
require 'double_entry/errors'
|
9
10
|
require 'double_entry/configurable'
|
10
11
|
require 'double_entry/configuration'
|
11
12
|
require 'double_entry/account'
|
12
13
|
require 'double_entry/account_balance'
|
14
|
+
require 'double_entry/balance_calculator'
|
13
15
|
require 'double_entry/locking'
|
14
16
|
require 'double_entry/transfer'
|
15
17
|
require 'double_entry/line'
|
@@ -22,15 +24,6 @@ require 'double_entry/validation'
|
|
22
24
|
# transferring money around the system.
|
23
25
|
module DoubleEntry
|
24
26
|
|
25
|
-
class UnknownAccount < RuntimeError; end
|
26
|
-
class TransferNotAllowed < RuntimeError; end
|
27
|
-
class TransferIsNegative < RuntimeError; end
|
28
|
-
class RequiredMetaMissing < RuntimeError; end
|
29
|
-
class DuplicateAccount < RuntimeError; end
|
30
|
-
class DuplicateTransfer < RuntimeError; end
|
31
|
-
class UserAccountNotLocked < RuntimeError; end
|
32
|
-
class AccountWouldBeSentNegative < RuntimeError; end
|
33
|
-
|
34
27
|
class << self
|
35
28
|
|
36
29
|
# Get the particular account instance with the provided identifier and
|
@@ -47,16 +40,7 @@ module DoubleEntry
|
|
47
40
|
# configured. It is unknown.
|
48
41
|
#
|
49
42
|
def account(identifier, options = {})
|
50
|
-
account
|
51
|
-
current_account.identifier == identifier &&
|
52
|
-
(options[:scope] ? current_account.scoped? : !current_account.scoped?)
|
53
|
-
end
|
54
|
-
|
55
|
-
if account
|
56
|
-
DoubleEntry::Account::Instance.new(:account => account, :scope => options[:scope])
|
57
|
-
else
|
58
|
-
raise UnknownAccount.new("account: #{identifier} scope: #{options[:scope]}")
|
59
|
-
end
|
43
|
+
Account.account(configuration.accounts, identifier, options)
|
60
44
|
end
|
61
45
|
|
62
46
|
# Transfer money from one account to another.
|
@@ -83,7 +67,7 @@ module DoubleEntry
|
|
83
67
|
# of this account.
|
84
68
|
# @option options :to [DoubleEntry::Account::Instance] Transfer money into
|
85
69
|
# this account.
|
86
|
-
# @option options :code [Symbol]
|
70
|
+
# @option options :code [Symbol] The application specific code for this
|
87
71
|
# type of transfer. As specified in the transfer configuration.
|
88
72
|
# @option options :meta [String] Metadata to associate with this transfer.
|
89
73
|
# @option options :detail [ActiveRecord::Base] ActiveRecord model
|
@@ -93,20 +77,13 @@ module DoubleEntry
|
|
93
77
|
# accounts with the provided code is not allowed. Check configuration.
|
94
78
|
#
|
95
79
|
def transfer(amount, options = {})
|
96
|
-
|
97
|
-
from, to, code, meta, detail = options[:from], options[:to], options[:code], options[:meta], options[:detail]
|
98
|
-
transfer = configuration.transfers.find(from, to, code)
|
99
|
-
if transfer
|
100
|
-
transfer.process!(amount, from, to, code, meta, detail)
|
101
|
-
else
|
102
|
-
raise TransferNotAllowed.new([from.identifier, to.identifier, code].inspect)
|
103
|
-
end
|
80
|
+
Transfer.transfer(configuration.transfers, amount, options)
|
104
81
|
end
|
105
82
|
|
106
|
-
# Get the current balance of an account
|
83
|
+
# Get the current or historic balance of an account.
|
107
84
|
#
|
108
85
|
# @param account [DoubleEntry::Account:Instance, Symbol]
|
109
|
-
# @option options :scope [
|
86
|
+
# @option options :scope [Object, String]
|
110
87
|
# @option options :from [Time]
|
111
88
|
# @option options :to [Time]
|
112
89
|
# @option options :at [Time]
|
@@ -114,56 +91,7 @@ module DoubleEntry
|
|
114
91
|
# @option options :codes [Array<Symbol>]
|
115
92
|
# @return [Money]
|
116
93
|
def balance(account, options = {})
|
117
|
-
|
118
|
-
scope = (account.is_a?(Symbol) ? scope_arg : account.scope_identity)
|
119
|
-
account = (account.is_a?(Symbol) ? account : account.identifier).to_s
|
120
|
-
from, to, at = options[:from], options[:to], options[:at]
|
121
|
-
code, codes = options[:code], options[:codes]
|
122
|
-
|
123
|
-
# time based scoping
|
124
|
-
conditions = if at
|
125
|
-
# lookup method could use running balance, with a order by limit one clause
|
126
|
-
# (unless it's a reporting call, i.e. account == symbol and not an instance)
|
127
|
-
['account = ? and created_at <= ?', account, at] # index this??
|
128
|
-
elsif from and to
|
129
|
-
['account = ? and created_at >= ? and created_at <= ?', account, from, to] # index this??
|
130
|
-
else
|
131
|
-
# lookup method could use running balance, with a order by limit one clause
|
132
|
-
# (unless it's a reporting call, i.e. account == symbol and not an instance)
|
133
|
-
['account = ?', account]
|
134
|
-
end
|
135
|
-
|
136
|
-
# code based scoping
|
137
|
-
if code
|
138
|
-
conditions[0] << ' and code = ?' # index this??
|
139
|
-
conditions << code.to_s
|
140
|
-
elsif codes
|
141
|
-
conditions[0] << ' and code in (?)' # index this??
|
142
|
-
conditions << codes.collect { |c| c.to_s }
|
143
|
-
end
|
144
|
-
|
145
|
-
# account based scoping
|
146
|
-
if scope
|
147
|
-
conditions[0] << ' and scope = ?'
|
148
|
-
conditions << scope
|
149
|
-
|
150
|
-
# This is to work around a MySQL 5.1 query optimiser bug that causes the ORDER BY
|
151
|
-
# on the query to fail in some circumstances, resulting in an old balance being
|
152
|
-
# returned. This was biting us intermittently in spec runs.
|
153
|
-
# See http://bugs.mysql.com/bug.php?id=51431
|
154
|
-
if Line.connection.adapter_name.match /mysql/i
|
155
|
-
use_index = "USE INDEX (lines_scope_account_id_idx)"
|
156
|
-
end
|
157
|
-
end
|
158
|
-
|
159
|
-
if (from and to) or (code or codes)
|
160
|
-
# from and to or code lookups have to be done via sum
|
161
|
-
Money.new(Line.where(conditions).sum(:amount))
|
162
|
-
else
|
163
|
-
# all other lookups can be performed with running balances
|
164
|
-
line = Line.select("id, balance").from("#{Line.quoted_table_name} #{use_index}").where(conditions).order('id desc').first
|
165
|
-
line ? line.balance : Money.empty
|
166
|
-
end
|
94
|
+
BalanceCalculator.calculate(account, options)
|
167
95
|
end
|
168
96
|
|
169
97
|
# Lock accounts in preparation for transfers.
|
@@ -183,33 +111,10 @@ module DoubleEntry
|
|
183
111
|
# The transaction must be the outermost database transaction
|
184
112
|
#
|
185
113
|
def lock_accounts(*accounts, &block)
|
186
|
-
|
114
|
+
Locking.lock_accounts(*accounts, &block)
|
187
115
|
end
|
188
116
|
|
189
117
|
# @api private
|
190
|
-
def describe(line)
|
191
|
-
# make sure we have a test for this refactoring, the test
|
192
|
-
# conditions are: i forget... but it's important!
|
193
|
-
if line.credit?
|
194
|
-
configuration.transfers.find(line.account, line.partner_account, line.code)
|
195
|
-
else
|
196
|
-
configuration.transfers.find(line.partner_account, line.account, line.code)
|
197
|
-
end.description.call(line)
|
198
|
-
end
|
199
|
-
|
200
|
-
# This is used by the concurrency test script.
|
201
|
-
#
|
202
|
-
# @api private
|
203
|
-
# @return [Boolean] true if all the amounts for an account add up to the final balance,
|
204
|
-
# which they always should.
|
205
|
-
def reconciled?(account)
|
206
|
-
scoped_lines = Line.where(:account => "#{account.identifier}", :scope => "#{account.scope}")
|
207
|
-
sum_of_amounts = scoped_lines.sum(:amount)
|
208
|
-
final_balance = scoped_lines.order(:id).last[:balance]
|
209
|
-
cached_balance = AccountBalance.find_by_account(account)[:balance]
|
210
|
-
final_balance == sum_of_amounts && final_balance == cached_balance
|
211
|
-
end
|
212
|
-
|
213
118
|
def table_name_prefix
|
214
119
|
'double_entry_'
|
215
120
|
end
|
data/lib/double_entry/account.rb
CHANGED
@@ -1,11 +1,27 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
3
|
class Account
|
4
|
+
|
5
|
+
# @api private
|
6
|
+
def self.account(defined_accounts, identifier, options = {})
|
7
|
+
account = defined_accounts.find(identifier, options[:scope].present?)
|
8
|
+
DoubleEntry::Account::Instance.new(:account => account, :scope => options[:scope])
|
9
|
+
end
|
10
|
+
|
11
|
+
# @api private
|
4
12
|
class Set < Array
|
5
13
|
def define(attributes)
|
6
14
|
self << Account.new(attributes)
|
7
15
|
end
|
8
16
|
|
17
|
+
def find(identifier, scoped)
|
18
|
+
account = detect do |account|
|
19
|
+
account.identifier == identifier && account.scoped? == scoped
|
20
|
+
end
|
21
|
+
raise UnknownAccount.new("account: #{identifier} scoped?: #{scoped}") unless account
|
22
|
+
return account
|
23
|
+
end
|
24
|
+
|
9
25
|
def <<(account)
|
10
26
|
if any? { |a| a.identifier == account.identifier }
|
11
27
|
raise DuplicateAccount.new
|
@@ -34,8 +50,17 @@ module DoubleEntry
|
|
34
50
|
scope_identifier.call(scope).to_s if scoped?
|
35
51
|
end
|
36
52
|
|
37
|
-
|
38
|
-
|
53
|
+
# Get the current or historic balance of this account.
|
54
|
+
#
|
55
|
+
# @option options :from [Time]
|
56
|
+
# @option options :to [Time]
|
57
|
+
# @option options :at [Time]
|
58
|
+
# @option options :code [Symbol]
|
59
|
+
# @option options :codes [Array<Symbol>]
|
60
|
+
# @return [Money]
|
61
|
+
#
|
62
|
+
def balance(options = {})
|
63
|
+
BalanceCalculator.calculate(self, options)
|
39
64
|
end
|
40
65
|
|
41
66
|
include Comparable
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
module BalanceCalculator
|
4
|
+
extend self
|
5
|
+
|
6
|
+
# Get the current or historic balance of an account.
|
7
|
+
#
|
8
|
+
# @param account [DoubleEntry::Account:Instance, Symbol]
|
9
|
+
# @option args :scope [Object, String]
|
10
|
+
# @option args :from [Time]
|
11
|
+
# @option args :to [Time]
|
12
|
+
# @option args :at [Time]
|
13
|
+
# @option args :code [Symbol]
|
14
|
+
# @option args :codes [Array<Symbol>]
|
15
|
+
# @return [Money]
|
16
|
+
#
|
17
|
+
def calculate(account, args = {})
|
18
|
+
options = Options.new(account, args)
|
19
|
+
relations = RelationBuilder.new(options)
|
20
|
+
lines = relations.build
|
21
|
+
|
22
|
+
if options.between? || options.code?
|
23
|
+
# from and to or code lookups have to be done via sum
|
24
|
+
Money.new(lines.sum(:amount))
|
25
|
+
else
|
26
|
+
# all other lookups can be performed with running balances
|
27
|
+
result = lines.
|
28
|
+
from(lines_table_name(options)).
|
29
|
+
order('id DESC').
|
30
|
+
limit(1).
|
31
|
+
pluck(:balance)
|
32
|
+
result.empty? ? Money.empty : Money.new(result.first)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def lines_table_name(options)
|
39
|
+
"#{Line.quoted_table_name}#{' USE INDEX (lines_scope_account_id_idx)' if force_index?(options)}"
|
40
|
+
end
|
41
|
+
|
42
|
+
def force_index?(options)
|
43
|
+
# This is to work around a MySQL 5.1 query optimiser bug that causes the ORDER BY
|
44
|
+
# on the query to fail in some circumstances, resulting in an old balance being
|
45
|
+
# returned. This was biting us intermittently in spec runs.
|
46
|
+
# See http://bugs.mysql.com/bug.php?id=51431
|
47
|
+
options.scope? && Line.connection.adapter_name.match(/mysql/i)
|
48
|
+
end
|
49
|
+
|
50
|
+
# @api private
|
51
|
+
class Options
|
52
|
+
attr_reader :account, :scope, :from, :to, :at, :codes
|
53
|
+
|
54
|
+
def initialize(account, args = {})
|
55
|
+
if account.is_a? Symbol
|
56
|
+
@account = account.to_s
|
57
|
+
@scope = args[:scope].present? ? args[:scope].id.to_s : nil
|
58
|
+
else
|
59
|
+
@account = account.identifier.to_s
|
60
|
+
@scope = account.scope_identity
|
61
|
+
end
|
62
|
+
@codes = (args[:codes].to_a << args[:code]).compact
|
63
|
+
@from = args[:from]
|
64
|
+
@to = args[:to]
|
65
|
+
@at = args[:at]
|
66
|
+
end
|
67
|
+
|
68
|
+
def at?
|
69
|
+
!!at
|
70
|
+
end
|
71
|
+
|
72
|
+
def between?
|
73
|
+
!!(from && to && !at?)
|
74
|
+
end
|
75
|
+
|
76
|
+
def code?
|
77
|
+
codes.present?
|
78
|
+
end
|
79
|
+
|
80
|
+
def scope?
|
81
|
+
!!scope
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# @api private
|
86
|
+
class RelationBuilder
|
87
|
+
attr_reader :options
|
88
|
+
delegate :account, :scope, :scope?, :from, :to, :between?, :at, :at?, :codes, :code?, :to => :options
|
89
|
+
|
90
|
+
def initialize(options)
|
91
|
+
@options = options
|
92
|
+
end
|
93
|
+
|
94
|
+
def build
|
95
|
+
lines = Line.where(:account => account)
|
96
|
+
lines = lines.where('created_at <= ?', at) if at?
|
97
|
+
lines = lines.where(:created_at => from..to) if between?
|
98
|
+
lines = lines.where(:code => codes) if code?
|
99
|
+
lines = lines.where(:scope => scope) if scope?
|
100
|
+
lines
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
|
4
|
+
class UnknownAccount < RuntimeError; end
|
5
|
+
class TransferNotAllowed < RuntimeError; end
|
6
|
+
class TransferIsNegative < RuntimeError; end
|
7
|
+
class RequiredMetaMissing < RuntimeError; end
|
8
|
+
class DuplicateAccount < RuntimeError; end
|
9
|
+
class DuplicateTransfer < RuntimeError; end
|
10
|
+
class UserAccountNotLocked < RuntimeError; end
|
11
|
+
class AccountWouldBeSentNegative < RuntimeError; end
|
12
|
+
|
13
|
+
end
|
data/lib/double_entry/line.rb
CHANGED
@@ -122,10 +122,6 @@ module DoubleEntry
|
|
122
122
|
amount > Money.empty
|
123
123
|
end
|
124
124
|
|
125
|
-
def description
|
126
|
-
DoubleEntry.describe(self)
|
127
|
-
end
|
128
|
-
|
129
125
|
# Query out just the id and created_at fields for lines, without
|
130
126
|
# instantiating any ActiveRecord objects.
|
131
127
|
def self.find_id_and_created_at(options)
|
@@ -2,13 +2,13 @@
|
|
2
2
|
require 'double_entry/reporting/aggregate'
|
3
3
|
require 'double_entry/reporting/aggregate_array'
|
4
4
|
require 'double_entry/reporting/time_range'
|
5
|
-
require 'double_entry/reporting/time_range_array'
|
6
5
|
require 'double_entry/reporting/day_range'
|
7
6
|
require 'double_entry/reporting/hour_range'
|
8
7
|
require 'double_entry/reporting/week_range'
|
9
8
|
require 'double_entry/reporting/month_range'
|
10
9
|
require 'double_entry/reporting/year_range'
|
11
10
|
require 'double_entry/reporting/line_aggregate'
|
11
|
+
require 'double_entry/reporting/time_range_array'
|
12
12
|
|
13
13
|
module DoubleEntry
|
14
14
|
|
@@ -26,10 +26,82 @@ module DoubleEntry
|
|
26
26
|
end
|
27
27
|
end
|
28
28
|
|
29
|
+
class AggregateFunctionNotSupported < RuntimeError; end
|
30
|
+
|
31
|
+
# Perform an aggregate calculation on a set of transfers for an account.
|
32
|
+
#
|
33
|
+
# The transfers included in the calculation can be limited by time range
|
34
|
+
# and provided custom filters.
|
35
|
+
#
|
36
|
+
# @example Find the sum for all $10 :save transfers in all :checking accounts in the current month (assume the date is January 30, 2014).
|
37
|
+
# time_range = DoubleEntry::TimeRange.make(2014, 1)
|
38
|
+
# class ::DoubleEntry::Line
|
39
|
+
# scope :ten_dollar_transfers, -> { where(:amount => 10_00) }
|
40
|
+
# end
|
41
|
+
# DoubleEntry.aggregate(:sum, :checking, :save, range: time_range, filter: [:ten_dollar_transfers])
|
42
|
+
# @param function [Symbol] The function to perform on the set of transfers.
|
43
|
+
# Valid functions are :sum, :count, and :average
|
44
|
+
# @param account [Symbol] The symbol identifying the account to perform
|
45
|
+
# the aggregate calculation on. As specified in the account configuration.
|
46
|
+
# @param code [Symbol] The application specific code for the type of
|
47
|
+
# transfer to perform an aggregate calculation on. As specified in the
|
48
|
+
# transfer configuration.
|
49
|
+
# @option options :range [DoubleEntry::TimeRange] Only include transfers
|
50
|
+
# in the given time range in the calculation.
|
51
|
+
# @option options :filter [Array[Symbol], or Array[Hash<Symbol,Parameter>]]
|
52
|
+
# A custom filter to apply before performing the aggregate calculation.
|
53
|
+
# Currently, filters must be monkey patched as scopes into the DoubleEntry::Line
|
54
|
+
# class in order to be used as filters, as the example shows.
|
55
|
+
# If the filter requires a parameter, it must be given in a Hash, otherwise
|
56
|
+
# pass an array with the symbol names for the defined scopes.
|
57
|
+
# @return Returns a Money object for :sum and :average calculations, or a
|
58
|
+
# Fixnum for :count calculations.
|
59
|
+
# @raise [Reporting::AggregateFunctionNotSupported] The provided function
|
60
|
+
# is not supported.
|
61
|
+
#
|
29
62
|
def aggregate(function, account, code, options = {})
|
30
63
|
Aggregate.new(function, account, code, options).formatted_amount
|
31
64
|
end
|
32
65
|
|
66
|
+
# Perform an aggregate calculation on a set of transfers for an account
|
67
|
+
# and return the results in an array partitioned by a time range type.
|
68
|
+
#
|
69
|
+
# The transfers included in the calculation can be limited by a time range
|
70
|
+
# and provided custom filters.
|
71
|
+
#
|
72
|
+
# @example Find the number of all $10 :save transfers in all :checking accounts per month for the entire year (Assume the year is 2014).
|
73
|
+
# DoubleEntry.aggregate_array(:sum, :checking, :save, range_type: 'month', start: '2014-01-01', finish: '2014-12-31')
|
74
|
+
# @param function [Symbol] The function to perform on the set of transfers.
|
75
|
+
# Valid functions are :sum, :count, and :average
|
76
|
+
# @param account [Symbol] The symbol identifying the account to perform
|
77
|
+
# the aggregate calculation on. As specified in the account configuration.
|
78
|
+
# @param code [Symbol] The application specific code for the type of
|
79
|
+
# transfer to perform an aggregate calculation on. As specified in the
|
80
|
+
# transfer configuration.
|
81
|
+
# @option options :filter [Array[Symbol], or Array[Hash<Symbol,Parameter>]]
|
82
|
+
# A custom filter to apply before performing the aggregate calculation.
|
83
|
+
# Currently, filters must be monkey patched as scopes into the DoubleEntry::Line
|
84
|
+
# class in order to be used as filters, as the example shows.
|
85
|
+
# If the filter requires a parameter, it must be given in a Hash, otherwise
|
86
|
+
# pass an array with the symbol names for the defined scopes.
|
87
|
+
# @option options :range_type [String] The type of time range to return data
|
88
|
+
# for. For example, specifying 'month' will return an array of the resulting
|
89
|
+
# aggregate calculation for each month.
|
90
|
+
# Valid range_types are 'hour', 'day', 'week', 'month', and 'year'
|
91
|
+
# @option options :start [String] The start date for the time range to perform
|
92
|
+
# calculations in. The default start date is the start_of_business (can
|
93
|
+
# be specified in configuration).
|
94
|
+
# The format of the string must be as follows: 'YYYY-mm-dd'
|
95
|
+
# @option options :finish [String] The finish (or end) date for the time range
|
96
|
+
# to perform calculations in. The default finish date is the current date.
|
97
|
+
# The format of the string must be as follows: 'YYYY-mm-dd'
|
98
|
+
# @return [Array[Money/Fixnum]] Returns an array of Money objects for :sum
|
99
|
+
# and :average calculations, or an array of Fixnum for :count calculations.
|
100
|
+
# The array is indexed by the range_type. For example, if range_type is
|
101
|
+
# specified as 'month', each index in the array will represent a month.
|
102
|
+
# @raise [Reporting::AggregateFunctionNotSupported] The provided function
|
103
|
+
# is not supported.
|
104
|
+
#
|
33
105
|
def aggregate_array(function, account, code, options = {})
|
34
106
|
AggregateArray.new(function, account, code, options)
|
35
107
|
end
|
@@ -55,6 +127,19 @@ module DoubleEntry
|
|
55
127
|
SQL
|
56
128
|
end
|
57
129
|
|
130
|
+
# This is used by the concurrency test script.
|
131
|
+
#
|
132
|
+
# @api private
|
133
|
+
# @return [Boolean] true if all the amounts for an account add up to the final balance,
|
134
|
+
# which they always should.
|
135
|
+
def reconciled?(account)
|
136
|
+
scoped_lines = Line.where(:account => "#{account.identifier}", :scope => "#{account.scope}")
|
137
|
+
sum_of_amounts = scoped_lines.sum(:amount)
|
138
|
+
final_balance = scoped_lines.order(:id).last[:balance]
|
139
|
+
cached_balance = AccountBalance.find_by_account(account)[:balance]
|
140
|
+
final_balance == sum_of_amounts && final_balance == cached_balance
|
141
|
+
end
|
142
|
+
|
58
143
|
private
|
59
144
|
|
60
145
|
delegate :connection, :to => ActiveRecord::Base
|
@@ -2,16 +2,15 @@
|
|
2
2
|
module DoubleEntry
|
3
3
|
module Reporting
|
4
4
|
class Aggregate
|
5
|
-
attr_reader :function, :account, :code, :
|
5
|
+
attr_reader :function, :account, :code, :range, :options, :filter
|
6
6
|
|
7
7
|
def initialize(function, account, code, options)
|
8
8
|
@function = function.to_s
|
9
|
-
raise
|
9
|
+
raise AggregateFunctionNotSupported unless %w[sum count average].include?(@function)
|
10
10
|
|
11
11
|
@account = account.to_s
|
12
12
|
@code = code ? code.to_s : nil
|
13
13
|
@options = options
|
14
|
-
@scope = options[:scope]
|
15
14
|
@range = options[:range]
|
16
15
|
@filter = options[:filter]
|
17
16
|
end
|
@@ -55,7 +54,7 @@ module DoubleEntry
|
|
55
54
|
if range.class == YearRange
|
56
55
|
aggregate = calculate_yearly_aggregate
|
57
56
|
else
|
58
|
-
aggregate = LineAggregate.aggregate(function, account, code,
|
57
|
+
aggregate = LineAggregate.aggregate(function, account, code, range, filter)
|
59
58
|
end
|
60
59
|
|
61
60
|
if range_is_complete?
|
@@ -4,7 +4,7 @@ module DoubleEntry
|
|
4
4
|
class LineAggregate < ActiveRecord::Base
|
5
5
|
extend EncapsulateAsMoney
|
6
6
|
|
7
|
-
def self.aggregate(function, account, code,
|
7
|
+
def self.aggregate(function, account, code, range, named_scopes)
|
8
8
|
collection = aggregate_collection(named_scopes)
|
9
9
|
collection = collection.where(:account => account)
|
10
10
|
collection = collection.where(:created_at => range.start..range.finish)
|
@@ -1,45 +1,42 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
|
-
|
4
|
-
|
5
|
-
class << self
|
3
|
+
module Reporting
|
4
|
+
class TimeRangeArray
|
6
5
|
|
7
|
-
|
8
|
-
|
6
|
+
attr_reader :type, :require_start
|
7
|
+
alias_method :require_start?, :require_start
|
9
8
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
when 'day'
|
14
|
-
make_array DayRange, start, finish
|
15
|
-
when 'week'
|
16
|
-
make_array WeekRange, start, finish
|
17
|
-
when 'month'
|
18
|
-
make_array MonthRange, start, finish
|
19
|
-
when 'year'
|
20
|
-
make_array YearRange, start
|
21
|
-
else
|
22
|
-
raise ArgumentError.new("Invalid range type '#{range_type}'")
|
23
|
-
end
|
9
|
+
def initialize(options = {})
|
10
|
+
@type = options[:type]
|
11
|
+
@require_start = options[:require_start]
|
24
12
|
end
|
25
13
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
while(loop != last) do
|
36
|
-
loop = loop.next
|
37
|
-
results << loop
|
14
|
+
def make(start = nil, finish = nil)
|
15
|
+
raise "Must specify start of range" if start == nil && require_start?
|
16
|
+
start = type.from_time(Time.parse(start || Reporting.configuration.start_of_business))
|
17
|
+
finish = finish ? type.from_time(Time.parse(finish)) : type.current
|
18
|
+
[ start ].tap do |array|
|
19
|
+
while start != finish
|
20
|
+
start = start.next
|
21
|
+
array << start
|
22
|
+
end
|
38
23
|
end
|
24
|
+
end
|
25
|
+
|
26
|
+
FACTORIES = {
|
27
|
+
'hour' => new(:type => HourRange, :require_start => true),
|
28
|
+
'day' => new(:type => DayRange, :require_start => true),
|
29
|
+
'week' => new(:type => WeekRange, :require_start => true),
|
30
|
+
'month' => new(:type => MonthRange, :require_start => false),
|
31
|
+
'year' => new(:type => YearRange, :require_start => false),
|
32
|
+
}
|
39
33
|
|
40
|
-
|
34
|
+
def self.make(range_type, start = nil, finish = nil)
|
35
|
+
factory = FACTORIES[range_type]
|
36
|
+
raise ArgumentError.new("Invalid range type '#{range_type}'") unless factory
|
37
|
+
factory.make(start, finish)
|
41
38
|
end
|
39
|
+
|
42
40
|
end
|
43
41
|
end
|
44
|
-
end
|
45
42
|
end
|
@@ -1,6 +1,17 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
3
|
class Transfer
|
4
|
+
|
5
|
+
# @api private
|
6
|
+
def self.transfer(defined_transfers, amount, options = {})
|
7
|
+
raise TransferIsNegative if amount < Money.empty
|
8
|
+
from, to, code, meta, detail = options[:from], options[:to], options[:code], options[:meta], options[:detail]
|
9
|
+
defined_transfers.
|
10
|
+
find!(from, to, code).
|
11
|
+
process!(amount, from, to, code, meta, detail)
|
12
|
+
end
|
13
|
+
|
14
|
+
# @api private
|
4
15
|
class Set < Array
|
5
16
|
def define(attributes)
|
6
17
|
self << Transfer.new(attributes)
|
@@ -10,6 +21,12 @@ module DoubleEntry
|
|
10
21
|
_find(from.identifier, to.identifier, code)
|
11
22
|
end
|
12
23
|
|
24
|
+
def find!(from, to, code)
|
25
|
+
transfer = find(from, to, code)
|
26
|
+
raise TransferNotAllowed.new([from.identifier, to.identifier, code].inspect) unless transfer
|
27
|
+
return transfer
|
28
|
+
end
|
29
|
+
|
13
30
|
def <<(transfer)
|
14
31
|
if _find(transfer.from, transfer.to, transfer.code)
|
15
32
|
raise DuplicateTransfer.new
|
data/lib/double_entry/version.rb
CHANGED
data/script/jack_hammer
CHANGED
@@ -156,11 +156,11 @@ def reconcile
|
|
156
156
|
error_count += 1
|
157
157
|
end
|
158
158
|
|
159
|
-
if $accounts.all? {|account| DoubleEntry.reconciled?(account) }
|
159
|
+
if $accounts.all? {|account| DoubleEntry::Reporting.reconciled?(account) }
|
160
160
|
puts "All accounts reconciled, FTW!"
|
161
161
|
else
|
162
162
|
$accounts.each do |account|
|
163
|
-
if !DoubleEntry.reconciled?(account)
|
163
|
+
if !DoubleEntry::Reporting.reconciled?(account)
|
164
164
|
puts "Account #{account.identifier} failed to reconcile. :("
|
165
165
|
|
166
166
|
# See http://bugs.mysql.com/bug.php?id=51431
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe DoubleEntry::BalanceCalculator do
|
5
|
+
|
6
|
+
describe '#calculate' do
|
7
|
+
let(:account) { double.as_null_object }
|
8
|
+
let(:scope) { nil }
|
9
|
+
let(:from) { nil }
|
10
|
+
let(:to) { nil }
|
11
|
+
let(:at) { nil }
|
12
|
+
let(:code) { nil }
|
13
|
+
let(:codes) { nil }
|
14
|
+
let(:relation) { double.as_null_object }
|
15
|
+
|
16
|
+
before do
|
17
|
+
allow(DoubleEntry::Line).to receive(:where).and_return(relation)
|
18
|
+
DoubleEntry::BalanceCalculator.calculate(
|
19
|
+
account,
|
20
|
+
:scope => scope,
|
21
|
+
:from => from,
|
22
|
+
:to => to,
|
23
|
+
:at => at,
|
24
|
+
:code => code,
|
25
|
+
:codes => codes,
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
describe 'what happens with different accounts' do
|
30
|
+
context 'when the given account is a symbol' do
|
31
|
+
let(:account) { :account }
|
32
|
+
|
33
|
+
it 'scopes the lines summed by the account symbol' do
|
34
|
+
expect(DoubleEntry::Line).to have_received(:where).with(:account => 'account')
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'with a scopeable entity provided' do
|
38
|
+
let(:scope) { double(:id => 'scope') }
|
39
|
+
|
40
|
+
it 'scopes the lines summed by the scope of the scopeable entity...scope' do
|
41
|
+
expect(relation).to have_received(:where).with(:scope => 'scope')
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
context 'with no scope provided' do
|
46
|
+
it 'does not scope the lines summed by the given scope' do
|
47
|
+
expect(relation).to_not have_received(:where).with(:scope => 'scope')
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
context 'when the given account is DoubleEntry::Account-like' do
|
53
|
+
let(:account) do
|
54
|
+
DoubleEntry::Account::Instance.new(
|
55
|
+
:account => DoubleEntry::Account.new(
|
56
|
+
:identifier => 'account_identity',
|
57
|
+
:scope_identifier => lambda { |scope_id| scope_id },
|
58
|
+
),
|
59
|
+
:scope => 'account_scope_identity'
|
60
|
+
)
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'scopes the lines summed by the accounts identifier and its scope identity' do
|
64
|
+
expect(DoubleEntry::Line).to have_received(:where).with(:account => 'account_identity')
|
65
|
+
expect(relation).to have_received(:where).with(:scope => 'account_scope_identity')
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
describe 'what happens with different times' do
|
71
|
+
context 'when we want to sum the lines before a given created_at date' do
|
72
|
+
let(:at) { Time.parse('2014-06-19 15:09:18 +1000') }
|
73
|
+
|
74
|
+
it 'scopes the lines summed to times before (or at) the given time' do
|
75
|
+
expect(relation).to have_received(:where).with(
|
76
|
+
'created_at <= ?', Time.parse('2014-06-19 15:09:18 +1000')
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
context 'when a time range is also specified' do
|
81
|
+
let(:from) { Time.parse('2014-06-19 10:09:18 +1000') }
|
82
|
+
let(:to) { Time.parse('2014-06-19 20:09:18 +1000') }
|
83
|
+
|
84
|
+
it 'ignores the time range when summing the lines' do
|
85
|
+
expect(relation).to_not have_received(:where).with(
|
86
|
+
:created_at => Time.parse('2014-06-19 10:09:18 +1000')..Time.parse('2014-06-19 20:09:18 +1000')
|
87
|
+
)
|
88
|
+
expect(relation).to_not have_received(:sum)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
context 'when we want to sum the lines between a given range' do
|
94
|
+
let(:from) { Time.parse('2014-06-19 10:09:18 +1000') }
|
95
|
+
let(:to) { Time.parse('2014-06-19 20:09:18 +1000') }
|
96
|
+
|
97
|
+
it 'scopes the lines summed to times within the given range' do
|
98
|
+
expect(relation).to have_received(:where).with(
|
99
|
+
:created_at => Time.parse('2014-06-19 10:09:18 +1000')..Time.parse('2014-06-19 20:09:18 +1000')
|
100
|
+
)
|
101
|
+
expect(relation).to have_received(:sum).with(:amount)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
context 'when a single code is provided' do
|
107
|
+
let(:code) { 'code1' }
|
108
|
+
|
109
|
+
it 'scopes the lines summed by the given code' do
|
110
|
+
expect(relation).to have_received(:where).with(:code => ['code1'])
|
111
|
+
expect(relation).to have_received(:sum).with(:amount)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
context 'when a list of codes is provided' do
|
116
|
+
let(:codes) { ['code1', 'code2'] }
|
117
|
+
|
118
|
+
it 'scopes the lines summed by the given codes' do
|
119
|
+
expect(relation).to have_received(:where).with(:code => ['code1', 'code2'])
|
120
|
+
expect(relation).to have_received(:sum).with(:amount)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
context 'when no codes are provided' do
|
125
|
+
it 'does not scope the lines summed by any code' do
|
126
|
+
expect(relation).to_not have_received(:where).with(:code => anything)
|
127
|
+
expect(relation).to_not have_received(:sum).with(:amount)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -1,75 +1,89 @@
|
|
1
1
|
require 'spec_helper'
|
2
|
-
|
2
|
+
module DoubleEntry
|
3
|
+
module Reporting
|
4
|
+
describe AggregateArray do
|
3
5
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
6
|
+
let(:user) { User.make! }
|
7
|
+
let(:start) { nil }
|
8
|
+
let(:finish) { nil }
|
9
|
+
let(:range_type) { 'year' }
|
10
|
+
let(:function) { :sum }
|
8
11
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
12
|
+
subject(:aggregate_array) {
|
13
|
+
Reporting.aggregate_array(
|
14
|
+
function,
|
15
|
+
:savings,
|
16
|
+
:bonus,
|
17
|
+
:range_type => range_type,
|
18
|
+
:start => start,
|
19
|
+
:finish => finish,
|
20
|
+
)
|
21
|
+
}
|
19
22
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
23
|
+
context 'given a deposit was made in 2007 and 2008' do
|
24
|
+
before do
|
25
|
+
Timecop.travel(Time.local(2007)) do
|
26
|
+
perform_deposit user, 10_00
|
27
|
+
end
|
28
|
+
Timecop.travel(Time.local(2008)) do
|
29
|
+
perform_deposit user, 20_00
|
30
|
+
end
|
31
|
+
end
|
29
32
|
|
30
|
-
|
31
|
-
|
33
|
+
context 'given the date is 2009-03-19' do
|
34
|
+
before { Timecop.travel(Time.local(2009, 3, 19)) }
|
32
35
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
36
|
+
context 'when called with range type of "year"' do
|
37
|
+
let(:range_type) { 'year' }
|
38
|
+
let(:start) { '2006-08-03' }
|
39
|
+
it { should eq [ Money.new(0), Money.new(10_00), Money.new(20_00), Money.new(0) ] }
|
40
|
+
end
|
41
|
+
end
|
37
42
|
end
|
38
|
-
end
|
39
|
-
end
|
40
43
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
44
|
+
context 'given a deposit was made in October and December 2006' do
|
45
|
+
before do
|
46
|
+
Timecop.travel(Time.local(2006, 10)) do
|
47
|
+
perform_deposit user, 10_00
|
48
|
+
end
|
49
|
+
Timecop.travel(Time.local(2006, 12)) do
|
50
|
+
perform_deposit user, 20_00
|
51
|
+
end
|
52
|
+
end
|
50
53
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
54
|
+
context 'when called with range type of "month", a start of "2006-09-01", and finish of "2007-01-02"' do
|
55
|
+
let(:range_type) { 'month' }
|
56
|
+
let(:start) { '2006-09-01' }
|
57
|
+
let(:finish) { '2007-01-02' }
|
58
|
+
it { should eq [ Money.new(0), Money.new(10_00), Money.new(0), Money.new(20_00), Money.new(0), ] }
|
59
|
+
end
|
60
|
+
|
61
|
+
context 'given the date is 2007-02-02' do
|
62
|
+
before { Timecop.travel(Time.local(2007, 2, 2)) }
|
63
|
+
|
64
|
+
context 'when called with range type of "month"' do
|
65
|
+
let(:range_type) { 'month' }
|
66
|
+
let(:start) { '2006-08-03' }
|
67
|
+
it { should eq [ Money.new(0), Money.new(0), Money.new(10_00), Money.new(0), Money.new(20_00), Money.new(0), Money.new(0) ] }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
57
71
|
|
58
|
-
|
59
|
-
|
72
|
+
context 'when called with range type of "invalid_and_should_not_work"' do
|
73
|
+
let(:range_type) { 'invalid_and_should_not_work' }
|
74
|
+
it 'raises an argument error' do
|
75
|
+
expect { aggregate_array }.to raise_error ArgumentError, "Invalid range type 'invalid_and_should_not_work'"
|
76
|
+
end
|
77
|
+
end
|
60
78
|
|
61
|
-
context 'when
|
79
|
+
context 'when an invalid function is provided' do
|
62
80
|
let(:range_type) { 'month' }
|
63
81
|
let(:start) { '2006-08-03' }
|
64
|
-
|
82
|
+
let(:function) { :invalid_function }
|
83
|
+
it 'raises an AggregateFunctionNotSupported error' do
|
84
|
+
expect{ aggregate_array }.to raise_error AggregateFunctionNotSupported
|
85
|
+
end
|
65
86
|
end
|
66
87
|
end
|
67
88
|
end
|
68
|
-
|
69
|
-
context 'when called with range type of "invalid_and_should_not_work"' do
|
70
|
-
let(:range_type) { 'invalid_and_should_not_work' }
|
71
|
-
it 'should raise an argument error' do
|
72
|
-
expect { aggregate_array }.to raise_error ArgumentError, "Invalid range type 'invalid_and_should_not_work'"
|
73
|
-
end
|
74
|
-
end
|
75
89
|
end
|
@@ -5,6 +5,12 @@ module DoubleEntry
|
|
5
5
|
describe Aggregate do
|
6
6
|
|
7
7
|
let(:user) { User.make! }
|
8
|
+
let(:expected_weekly_average) do
|
9
|
+
(Money.new(20_00) + Money.new(40_00) + Money.new(50_00)) / 3
|
10
|
+
end
|
11
|
+
let(:expected_monthly_average) do
|
12
|
+
(Money.new(20_00) + Money.new(40_00) + Money.new(50_00) + Money.new(40_00) + Money.new(50_00)) / 5
|
13
|
+
end
|
8
14
|
|
9
15
|
before do
|
10
16
|
# Thursday
|
@@ -107,12 +113,42 @@ module DoubleEntry
|
|
107
113
|
).to eq Money.new(200_00)
|
108
114
|
end
|
109
115
|
|
116
|
+
it 'calculates the average monthly all_time ranges correctly' do
|
117
|
+
expect(
|
118
|
+
Reporting.aggregate(:average, :savings, :bonus, :range => TimeRange.make(:year => 2009, :month => 12, :range_type => :all_time))
|
119
|
+
).to eq expected_monthly_average
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'returns the correct count for weekly all_time ranges correctly' do
|
123
|
+
expect(
|
124
|
+
Reporting.aggregate(:count, :savings, :bonus, :range => TimeRange.make(:year => 2009, :month => 12, :range_type => :all_time))
|
125
|
+
).to eq 5
|
126
|
+
end
|
127
|
+
|
110
128
|
it 'should calculate weekly all_time ranges correctly' do
|
111
129
|
expect(
|
112
130
|
Reporting.aggregate(:sum, :savings, :bonus, :range => TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
|
113
131
|
).to eq Money.new(110_00)
|
114
132
|
end
|
115
133
|
|
134
|
+
it 'calculates the average weekly all_time ranges correctly' do
|
135
|
+
expect(
|
136
|
+
Reporting.aggregate(:average, :savings, :bonus, :range => TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
|
137
|
+
).to eq expected_weekly_average
|
138
|
+
end
|
139
|
+
|
140
|
+
it 'returns the correct count for weekly all_time ranges correctly' do
|
141
|
+
expect(
|
142
|
+
Reporting.aggregate(:count, :savings, :bonus, :range => TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
|
143
|
+
).to eq 3
|
144
|
+
end
|
145
|
+
|
146
|
+
it "raises an AggregateFunctionNotSupported exception" do
|
147
|
+
expect{
|
148
|
+
Reporting.aggregate(:not_supported_calculation, :savings, :bonus, :range => TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
|
149
|
+
}.to raise_error(AggregateFunctionNotSupported)
|
150
|
+
end
|
151
|
+
|
116
152
|
context 'filters' do
|
117
153
|
|
118
154
|
let(:range) { TimeRange.make(:year => 2011, :month => 10) }
|
@@ -21,7 +21,7 @@ module DoubleEntry::Reporting
|
|
21
21
|
context 'given start and finish are nil' do
|
22
22
|
it 'should raise an error' do
|
23
23
|
expect { TimeRangeArray.make(range_type, nil, nil) }.
|
24
|
-
to raise_error 'Must specify
|
24
|
+
to raise_error 'Must specify start of range'
|
25
25
|
end
|
26
26
|
end
|
27
27
|
end
|
@@ -44,7 +44,7 @@ module DoubleEntry::Reporting
|
|
44
44
|
context 'given start and finish are nil' do
|
45
45
|
it 'should raise an error' do
|
46
46
|
expect { TimeRangeArray.make(range_type, nil, nil) }.
|
47
|
-
to raise_error 'Must specify
|
47
|
+
to raise_error 'Must specify start of range'
|
48
48
|
end
|
49
49
|
end
|
50
50
|
end
|
@@ -66,7 +66,7 @@ module DoubleEntry::Reporting
|
|
66
66
|
context 'given start and finish are nil' do
|
67
67
|
it 'should raise an error' do
|
68
68
|
expect { TimeRangeArray.make(range_type, nil, nil) }.
|
69
|
-
to raise_error 'Must specify
|
69
|
+
to raise_error 'Must specify start of range'
|
70
70
|
end
|
71
71
|
end
|
72
72
|
end
|
@@ -117,11 +117,10 @@ module DoubleEntry::Reporting
|
|
117
117
|
context 'and the date is "2009-11-23"' do
|
118
118
|
before { Timecop.freeze(Time.new(2009, 11, 23)) }
|
119
119
|
|
120
|
-
it 'takes
|
120
|
+
it 'takes notice of start and finish' do
|
121
121
|
should eq [
|
122
122
|
YearRange.from_time(Time.new(2007)),
|
123
123
|
YearRange.from_time(Time.new(2008)),
|
124
|
-
YearRange.from_time(Time.new(2009)),
|
125
124
|
]
|
126
125
|
end
|
127
126
|
|
data/spec/double_entry_spec.rb
CHANGED
@@ -215,11 +215,6 @@ describe DoubleEntry do
|
|
215
215
|
expect(debit_line).to_not be_credit
|
216
216
|
end
|
217
217
|
|
218
|
-
it 'can describe itself' do
|
219
|
-
expect(credit_line.description).to eq 'Money goes out: $-10.00'
|
220
|
-
expect(debit_line.description).to eq 'Money goes in: $10.00'
|
221
|
-
end
|
222
|
-
|
223
218
|
it 'can reference its partner' do
|
224
219
|
expect(credit_line.partner).to eq debit_line
|
225
220
|
expect(debit_line.partner).to eq credit_line
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: double_entry
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -16,7 +16,7 @@ authors:
|
|
16
16
|
autorequire:
|
17
17
|
bindir: bin
|
18
18
|
cert_chain: []
|
19
|
-
date: 2014-
|
19
|
+
date: 2014-07-11 00:00:00.000000000 Z
|
20
20
|
dependencies:
|
21
21
|
- !ruby/object:Gem::Dependency
|
22
22
|
name: money
|
@@ -272,8 +272,10 @@ files:
|
|
272
272
|
- lib/double_entry.rb
|
273
273
|
- lib/double_entry/account.rb
|
274
274
|
- lib/double_entry/account_balance.rb
|
275
|
+
- lib/double_entry/balance_calculator.rb
|
275
276
|
- lib/double_entry/configurable.rb
|
276
277
|
- lib/double_entry/configuration.rb
|
278
|
+
- lib/double_entry/errors.rb
|
277
279
|
- lib/double_entry/line.rb
|
278
280
|
- lib/double_entry/locking.rb
|
279
281
|
- lib/double_entry/reporting.rb
|
@@ -298,6 +300,7 @@ files:
|
|
298
300
|
- spec/active_record/locking_extensions_spec.rb
|
299
301
|
- spec/double_entry/account_balance_spec.rb
|
300
302
|
- spec/double_entry/account_spec.rb
|
303
|
+
- spec/double_entry/balance_calculator_spec.rb
|
301
304
|
- spec/double_entry/configuration_spec.rb
|
302
305
|
- spec/double_entry/line_spec.rb
|
303
306
|
- spec/double_entry/locking_spec.rb
|
@@ -349,6 +352,7 @@ test_files:
|
|
349
352
|
- spec/active_record/locking_extensions_spec.rb
|
350
353
|
- spec/double_entry/account_balance_spec.rb
|
351
354
|
- spec/double_entry/account_spec.rb
|
355
|
+
- spec/double_entry/balance_calculator_spec.rb
|
352
356
|
- spec/double_entry/configuration_spec.rb
|
353
357
|
- spec/double_entry/line_spec.rb
|
354
358
|
- spec/double_entry/locking_spec.rb
|