double_entry 0.10.0 → 0.10.1
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 +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
|