double_entry 0.10.0 → 0.10.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rspec +2 -1
- data/.rubocop.yml +55 -0
- data/.travis.yml +23 -12
- data/README.md +5 -1
- data/Rakefile +8 -3
- data/double_entry.gemspec +4 -3
- data/lib/active_record/locking_extensions.rb +28 -40
- data/lib/active_record/locking_extensions/log_subscriber.rb +4 -4
- data/lib/double_entry.rb +0 -2
- data/lib/double_entry/account.rb +13 -16
- data/lib/double_entry/account_balance.rb +0 -4
- data/lib/double_entry/balance_calculator.rb +4 -5
- data/lib/double_entry/configurable.rb +0 -2
- data/lib/double_entry/configuration.rb +2 -3
- data/lib/double_entry/errors.rb +2 -2
- data/lib/double_entry/line.rb +13 -16
- data/lib/double_entry/locking.rb +13 -18
- data/lib/double_entry/reporting.rb +2 -3
- data/lib/double_entry/reporting/aggregate.rb +90 -88
- data/lib/double_entry/reporting/aggregate_array.rb +58 -58
- data/lib/double_entry/reporting/day_range.rb +37 -35
- data/lib/double_entry/reporting/hour_range.rb +40 -37
- data/lib/double_entry/reporting/line_aggregate.rb +27 -28
- data/lib/double_entry/reporting/month_range.rb +67 -67
- data/lib/double_entry/reporting/time_range.rb +40 -38
- data/lib/double_entry/reporting/time_range_array.rb +3 -5
- data/lib/double_entry/reporting/week_range.rb +77 -78
- data/lib/double_entry/reporting/year_range.rb +27 -27
- data/lib/double_entry/transfer.rb +14 -15
- data/lib/double_entry/validation/line_check.rb +92 -86
- data/lib/double_entry/version.rb +1 -1
- data/lib/generators/double_entry/install/install_generator.rb +1 -2
- data/lib/generators/double_entry/install/templates/migration.rb +0 -2
- data/script/jack_hammer +1 -1
- data/spec/active_record/locking_extensions_spec.rb +45 -38
- data/spec/double_entry/account_balance_spec.rb +4 -5
- data/spec/double_entry/account_spec.rb +43 -44
- data/spec/double_entry/balance_calculator_spec.rb +6 -8
- data/spec/double_entry/configuration_spec.rb +14 -16
- data/spec/double_entry/line_spec.rb +25 -26
- data/spec/double_entry/locking_spec.rb +34 -39
- data/spec/double_entry/reporting/aggregate_array_spec.rb +8 -10
- data/spec/double_entry/reporting/aggregate_spec.rb +84 -44
- data/spec/double_entry/reporting/line_aggregate_spec.rb +7 -6
- data/spec/double_entry/reporting/month_range_spec.rb +109 -103
- data/spec/double_entry/reporting/time_range_array_spec.rb +145 -135
- data/spec/double_entry/reporting/time_range_spec.rb +36 -35
- data/spec/double_entry/reporting/week_range_spec.rb +82 -76
- data/spec/double_entry/reporting_spec.rb +9 -13
- data/spec/double_entry/transfer_spec.rb +13 -15
- data/spec/double_entry/validation/line_check_spec.rb +73 -79
- data/spec/double_entry_spec.rb +65 -68
- data/spec/generators/double_entry/install/install_generator_spec.rb +7 -10
- data/spec/spec_helper.rb +68 -10
- data/spec/support/accounts.rb +2 -4
- data/spec/support/double_entry_spec_helper.rb +4 -4
- data/spec/support/gemfiles/Gemfile.rails-3.2.x +1 -0
- metadata +31 -2
@@ -24,10 +24,10 @@ module DoubleEntry
|
|
24
24
|
else
|
25
25
|
# all other lookups can be performed with running balances
|
26
26
|
result = lines.
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
27
|
+
from(lines_table_name(options)).
|
28
|
+
order('id DESC').
|
29
|
+
limit(1).
|
30
|
+
pluck(:balance)
|
31
31
|
result.empty? ? Money.zero(account.currency) : Money.new(result.first, account.currency)
|
32
32
|
end
|
33
33
|
end
|
@@ -94,6 +94,5 @@ module DoubleEntry
|
|
94
94
|
lines
|
95
95
|
end
|
96
96
|
end
|
97
|
-
|
98
97
|
end
|
99
98
|
end
|
@@ -3,7 +3,6 @@ module DoubleEntry
|
|
3
3
|
include Configurable
|
4
4
|
|
5
5
|
class Configuration
|
6
|
-
|
7
6
|
delegate(
|
8
7
|
:accounts,
|
9
8
|
:accounts=,
|
@@ -11,7 +10,7 @@ module DoubleEntry
|
|
11
10
|
:scope_identifier_max_length=,
|
12
11
|
:account_identifier_max_length,
|
13
12
|
:account_identifier_max_length=,
|
14
|
-
:to =>
|
13
|
+
:to => 'DoubleEntry::Account',
|
15
14
|
)
|
16
15
|
|
17
16
|
delegate(
|
@@ -19,7 +18,7 @@ module DoubleEntry
|
|
19
18
|
:transfers=,
|
20
19
|
:code_max_length,
|
21
20
|
:code_max_length=,
|
22
|
-
:to =>
|
21
|
+
:to => 'DoubleEntry::Transfer',
|
23
22
|
)
|
24
23
|
|
25
24
|
def define_accounts
|
data/lib/double_entry/errors.rb
CHANGED
@@ -11,6 +11,6 @@ module DoubleEntry
|
|
11
11
|
class AccountWouldBeSentNegative < RuntimeError; end
|
12
12
|
class AccountWouldBeSentPositiveError < RuntimeError; end
|
13
13
|
class MismatchedCurrencies < RuntimeError; end
|
14
|
-
class MissingAccountError < RuntimeError; end
|
15
|
-
class AccountScopeMismatchError < RuntimeError; end
|
14
|
+
class MissingAccountError < RuntimeError; end
|
15
|
+
class AccountScopeMismatchError < RuntimeError; end
|
16
16
|
end
|
data/lib/double_entry/line.rb
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
|
3
3
|
module DoubleEntry
|
4
|
-
|
5
4
|
# This is the table to end all tables!
|
6
5
|
#
|
7
6
|
# Every financial transaction gets two entries in here: one for the source
|
@@ -56,7 +55,6 @@ module DoubleEntry
|
|
56
55
|
# by account, or account and code, over a particular period.
|
57
56
|
#
|
58
57
|
class Line < ActiveRecord::Base
|
59
|
-
|
60
58
|
belongs_to :detail, :polymorphic => true
|
61
59
|
|
62
60
|
def amount
|
@@ -94,11 +92,11 @@ module DoubleEntry
|
|
94
92
|
self[:code].try(:to_sym)
|
95
93
|
end
|
96
94
|
|
97
|
-
def account=(
|
98
|
-
self[:account] =
|
99
|
-
self.scope =
|
100
|
-
|
101
|
-
|
95
|
+
def account=(account)
|
96
|
+
self[:account] = account.identifier.to_s
|
97
|
+
self.scope = account.scope_identity
|
98
|
+
fail 'Missing Account' unless self.account
|
99
|
+
account
|
102
100
|
end
|
103
101
|
|
104
102
|
def account
|
@@ -109,11 +107,11 @@ module DoubleEntry
|
|
109
107
|
account.currency if self[:account]
|
110
108
|
end
|
111
109
|
|
112
|
-
def partner_account=(
|
113
|
-
self[:partner_account] =
|
114
|
-
self.partner_scope =
|
115
|
-
|
116
|
-
|
110
|
+
def partner_account=(partner_account)
|
111
|
+
self[:partner_account] = partner_account.identifier.to_s
|
112
|
+
self.partner_scope = partner_account.scope_identity
|
113
|
+
fail 'Missing Partner Account' unless self.partner_account
|
114
|
+
partner_account
|
117
115
|
end
|
118
116
|
|
119
117
|
def partner_account
|
@@ -149,16 +147,15 @@ module DoubleEntry
|
|
149
147
|
SQL
|
150
148
|
end
|
151
149
|
|
152
|
-
|
150
|
+
private
|
153
151
|
|
154
152
|
def check_balance_will_remain_valid
|
155
153
|
if account.positive_only && balance < Money.zero
|
156
|
-
|
154
|
+
fail AccountWouldBeSentNegative, account
|
157
155
|
end
|
158
156
|
if account.negative_only && balance > Money.zero
|
159
|
-
|
157
|
+
fail AccountWouldBeSentPositiveError, account
|
160
158
|
end
|
161
159
|
end
|
162
160
|
end
|
163
|
-
|
164
161
|
end
|
data/lib/double_entry/locking.rb
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
|
-
|
4
3
|
# Lock financial accounts to ensure consistency.
|
5
4
|
#
|
6
5
|
# In order to ensure financial transactions always keep track of balances
|
@@ -51,7 +50,7 @@ module DoubleEntry
|
|
51
50
|
end
|
52
51
|
|
53
52
|
class Lock
|
54
|
-
@@locks =
|
53
|
+
@@locks = {}
|
55
54
|
|
56
55
|
def initialize(accounts)
|
57
56
|
# Make sure we always lock in the same order, to avoid deadlocks.
|
@@ -65,9 +64,7 @@ module DoubleEntry
|
|
65
64
|
|
66
65
|
unless lock_and_call(&block)
|
67
66
|
create_missing_account_balances
|
68
|
-
unless lock_and_call(&block)
|
69
|
-
raise LockDisaster
|
70
|
-
end
|
67
|
+
fail LockDisaster unless lock_and_call(&block)
|
71
68
|
end
|
72
69
|
end
|
73
70
|
|
@@ -78,7 +75,9 @@ module DoubleEntry
|
|
78
75
|
|
79
76
|
def ensure_locked!
|
80
77
|
@accounts.each do |account|
|
81
|
-
|
78
|
+
unless lock?(account)
|
79
|
+
fail LockNotHeld, "No lock held for account: #{account.identifier}, scope #{account.scope}"
|
80
|
+
end
|
82
81
|
end
|
83
82
|
end
|
84
83
|
|
@@ -88,7 +87,7 @@ module DoubleEntry
|
|
88
87
|
locks[account]
|
89
88
|
end
|
90
89
|
|
91
|
-
|
90
|
+
private
|
92
91
|
|
93
92
|
def locks
|
94
93
|
@@locks[Thread.current.object_id]
|
@@ -103,19 +102,18 @@ module DoubleEntry
|
|
103
102
|
end
|
104
103
|
|
105
104
|
# Return true if there's a lock on the given account.
|
106
|
-
def
|
107
|
-
in_a_locked_transaction? && locks.
|
105
|
+
def lock?(account)
|
106
|
+
in_a_locked_transaction? && locks.key?(account)
|
108
107
|
end
|
109
108
|
|
110
109
|
# Raise an exception unless we're outside any transactions.
|
111
110
|
def ensure_outermost_transaction!
|
112
111
|
minimum_transaction_level = Locking.configuration.running_inside_transactional_fixtures ? 1 : 0
|
113
112
|
unless AccountBalance.connection.open_transactions <= minimum_transaction_level
|
114
|
-
|
113
|
+
fail LockMustBeOutermostTransaction
|
115
114
|
end
|
116
115
|
end
|
117
116
|
|
118
|
-
|
119
117
|
# Start a transaction, grab locks on the given accounts, then call the block
|
120
118
|
# from within the transaction.
|
121
119
|
#
|
@@ -144,12 +142,12 @@ module DoubleEntry
|
|
144
142
|
# If one or more account balance records don't exist, set
|
145
143
|
# accounts_with_balances to the corresponding accounts, and return false.
|
146
144
|
def grab_locks
|
147
|
-
account_balances = @accounts.map {|account| AccountBalance.find_by_account(account, :lock => true) }
|
145
|
+
account_balances = @accounts.map { |account| AccountBalance.find_by_account(account, :lock => true) }
|
148
146
|
|
149
147
|
if account_balances.any?(&:nil?)
|
150
|
-
@accounts_without_balances =
|
151
|
-
|
152
|
-
|
148
|
+
@accounts_without_balances = @accounts.zip(account_balances).
|
149
|
+
select { |_account, account_balance| account_balance.nil? }.
|
150
|
+
collect { |account, _account_balance| account }
|
153
151
|
false
|
154
152
|
else
|
155
153
|
self.locks = Hash[*@accounts.zip(account_balances).flatten]
|
@@ -174,9 +172,6 @@ module DoubleEntry
|
|
174
172
|
|
175
173
|
# Raised when attempting a transfer on an account that's not locked.
|
176
174
|
class LockNotHeld < RuntimeError
|
177
|
-
def initialize(account)
|
178
|
-
super "No lock held for account: #{account.identifier}, scope #{account.scope}"
|
179
|
-
end
|
180
175
|
end
|
181
176
|
|
182
177
|
# Raised if things go horribly, horribly wrong. This should never happen.
|
@@ -11,7 +11,6 @@ require 'double_entry/reporting/line_aggregate'
|
|
11
11
|
require 'double_entry/reporting/time_range_array'
|
12
12
|
|
13
13
|
module DoubleEntry
|
14
|
-
|
15
14
|
# @api private
|
16
15
|
module Reporting
|
17
16
|
include Configurable
|
@@ -131,11 +130,11 @@ module DoubleEntry
|
|
131
130
|
# ) # might return the user ids: [ 1423, 12232, 34729 ]
|
132
131
|
# @param [Money] minimum_balance Minimum account balance a scope must have
|
133
132
|
# to be included in the result set.
|
134
|
-
# @param [Symbol] account_identifier
|
133
|
+
# @param [Symbol] account_identifier
|
135
134
|
# @return [Array<Fixnum>] Scopes
|
136
135
|
#
|
137
136
|
def scopes_with_minimum_balance_for_account(minimum_balance, account_identifier)
|
138
|
-
select_values(sanitize_sql_array([<<-SQL, account_identifier, minimum_balance.cents])).map
|
137
|
+
select_values(sanitize_sql_array([<<-SQL, account_identifier, minimum_balance.cents])).map(&:to_i)
|
139
138
|
SELECT scope
|
140
139
|
FROM #{AccountBalance.table_name}
|
141
140
|
WHERE account = ?
|
@@ -1,112 +1,114 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
|
-
|
4
|
-
|
5
|
-
|
3
|
+
module Reporting
|
4
|
+
class Aggregate
|
5
|
+
attr_reader :function, :account, :code, :range, :options, :filter, :currency
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
7
|
+
def initialize(function, account, code, options)
|
8
|
+
@function = function.to_s
|
9
|
+
fail AggregateFunctionNotSupported unless %w(sum count average).include?(@function)
|
10
10
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
11
|
+
@account = account
|
12
|
+
@code = code ? code.to_s : nil
|
13
|
+
@options = options
|
14
|
+
@range = options[:range]
|
15
|
+
@filter = options[:filter]
|
16
|
+
@currency = DoubleEntry::Account.currency(account)
|
17
|
+
end
|
18
18
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
19
|
+
def amount(force_recalculation = false)
|
20
|
+
if force_recalculation
|
21
|
+
clear_old_aggregates
|
22
|
+
calculate
|
23
|
+
else
|
24
|
+
retrieve || calculate
|
25
|
+
end
|
25
26
|
end
|
26
|
-
end
|
27
27
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
28
|
+
def formatted_amount(value = amount())
|
29
|
+
value ||= 0
|
30
|
+
if function == 'count'
|
31
|
+
value
|
32
|
+
else
|
33
|
+
Money.new(value, currency)
|
34
|
+
end
|
34
35
|
end
|
35
|
-
end
|
36
36
|
|
37
37
|
private
|
38
38
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
end
|
43
|
-
|
44
|
-
def clear_old_aggregates
|
45
|
-
LineAggregate.delete_all(field_hash)
|
46
|
-
end
|
47
|
-
|
48
|
-
def calculate
|
49
|
-
if range.class == YearRange
|
50
|
-
aggregate = calculate_yearly_aggregate
|
51
|
-
else
|
52
|
-
aggregate = LineAggregate.aggregate(function, account, code, range, filter)
|
39
|
+
def retrieve
|
40
|
+
aggregate = LineAggregate.where(field_hash).first
|
41
|
+
aggregate.amount if aggregate
|
53
42
|
end
|
54
43
|
|
55
|
-
|
56
|
-
|
57
|
-
fields[:amount] = aggregate || 0
|
58
|
-
LineAggregate.create! fields
|
44
|
+
def clear_old_aggregates
|
45
|
+
LineAggregate.delete_all(field_hash)
|
59
46
|
end
|
60
47
|
|
61
|
-
|
62
|
-
|
48
|
+
def calculate
|
49
|
+
if range.class == YearRange
|
50
|
+
aggregate = calculate_yearly_aggregate
|
51
|
+
else
|
52
|
+
aggregate = LineAggregate.aggregate(function, account, code, range, filter)
|
53
|
+
end
|
63
54
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
# Combining monthly aggregates will mean that the figure will be partially memoized
|
69
|
-
if function == "average"
|
70
|
-
calculate_yearly_average
|
71
|
-
else
|
72
|
-
zero = formatted_amount(0)
|
73
|
-
result = (1..12).inject(zero) do |total, month|
|
74
|
-
total += Reporting.aggregate(
|
75
|
-
function, account, code,
|
76
|
-
:range => MonthRange.new(:year => range.year, :month => month),
|
77
|
-
:filter => filter,
|
78
|
-
)
|
55
|
+
if range_is_complete?
|
56
|
+
fields = field_hash
|
57
|
+
fields[:amount] = aggregate || 0
|
58
|
+
LineAggregate.create! fields
|
79
59
|
end
|
80
|
-
|
60
|
+
|
61
|
+
aggregate
|
81
62
|
end
|
82
|
-
end
|
83
63
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
64
|
+
def calculate_yearly_aggregate
|
65
|
+
# We calculate yearly aggregates by combining monthly aggregates
|
66
|
+
# otherwise they will get excruciatingly slow to calculate
|
67
|
+
# as the year progresses. (I am thinking mainly of the 'current' year.)
|
68
|
+
# Combining monthly aggregates will mean that the figure will be partially memoized
|
69
|
+
if function == 'average'
|
70
|
+
calculate_yearly_average
|
71
|
+
else
|
72
|
+
zero = formatted_amount(0)
|
73
|
+
result = (1..12).inject(zero) do |total, month|
|
74
|
+
total + Reporting.aggregate(
|
75
|
+
function,
|
76
|
+
account,
|
77
|
+
code,
|
78
|
+
:range => MonthRange.new(:year => range.year, :month => month),
|
79
|
+
:filter => filter,
|
80
|
+
)
|
81
|
+
end
|
82
|
+
result.is_a?(Money) ? result.cents : result
|
83
|
+
end
|
84
|
+
end
|
91
85
|
|
92
|
-
|
93
|
-
|
94
|
-
|
86
|
+
def calculate_yearly_average
|
87
|
+
# need this seperate function, because an average of averages is not the correct average
|
88
|
+
year_range = YearRange.new(:year => range.year)
|
89
|
+
sum = Reporting.aggregate(:sum, account, code, :range => year_range, :filter => filter)
|
90
|
+
count = Reporting.aggregate(:count, account, code, :range => year_range, :filter => filter)
|
91
|
+
(count == 0) ? 0 : (sum / count).cents
|
92
|
+
end
|
95
93
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
94
|
+
def range_is_complete?
|
95
|
+
Time.now > range.finish
|
96
|
+
end
|
97
|
+
|
98
|
+
def field_hash
|
99
|
+
{
|
100
|
+
:function => function,
|
101
|
+
:account => account,
|
102
|
+
:code => code,
|
103
|
+
:year => range.year,
|
104
|
+
:month => range.month,
|
105
|
+
:week => range.week,
|
106
|
+
:day => range.day,
|
107
|
+
:hour => range.hour,
|
108
|
+
:filter => filter.inspect,
|
109
|
+
:range_type => range.range_type.to_s,
|
110
|
+
}
|
111
|
+
end
|
109
112
|
end
|
110
113
|
end
|
111
|
-
end
|
112
114
|
end
|