double_entry 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +16 -14
- data/lib/double_entry.rb +9 -62
- data/lib/double_entry/account.rb +5 -1
- data/lib/double_entry/configuration.rb +21 -0
- data/lib/double_entry/reporting.rb +51 -0
- data/lib/double_entry/{aggregate.rb → reporting/aggregate.rb} +9 -7
- data/lib/double_entry/{aggregate_array.rb → reporting/aggregate_array.rb} +3 -1
- data/lib/double_entry/{day_range.rb → reporting/day_range.rb} +2 -0
- data/lib/double_entry/{hour_range.rb → reporting/hour_range.rb} +2 -0
- data/lib/double_entry/{line_aggregate.rb → reporting/line_aggregate.rb} +4 -2
- data/lib/double_entry/{month_range.rb → reporting/month_range.rb} +4 -2
- data/lib/double_entry/{time_range.rb → reporting/time_range.rb} +7 -5
- data/lib/double_entry/{time_range_array.rb → reporting/time_range_array.rb} +2 -0
- data/lib/double_entry/{week_range.rb → reporting/week_range.rb} +3 -1
- data/lib/double_entry/{year_range.rb → reporting/year_range.rb} +4 -3
- data/lib/double_entry/transfer.rb +4 -0
- data/lib/double_entry/validation.rb +1 -0
- data/lib/double_entry/{line_check.rb → validation/line_check.rb} +2 -0
- data/lib/double_entry/version.rb +1 -1
- data/script/jack_hammer +21 -16
- data/spec/double_entry/account_spec.rb +9 -0
- data/spec/double_entry/configuration_spec.rb +23 -0
- data/spec/double_entry/locking_spec.rb +24 -13
- data/spec/double_entry/{aggregate_array_spec.rb → reporting/aggregate_array_spec.rb} +2 -2
- data/spec/double_entry/reporting/aggregate_spec.rb +171 -0
- data/spec/double_entry/reporting/line_aggregate_spec.rb +10 -0
- data/spec/double_entry/{month_range_spec.rb → reporting/month_range_spec.rb} +23 -21
- data/spec/double_entry/{time_range_array_spec.rb → reporting/time_range_array_spec.rb} +41 -39
- data/spec/double_entry/{time_range_spec.rb → reporting/time_range_spec.rb} +10 -9
- data/spec/double_entry/{week_range_spec.rb → reporting/week_range_spec.rb} +26 -25
- data/spec/double_entry/reporting_spec.rb +24 -0
- data/spec/double_entry/transfer_spec.rb +17 -0
- data/spec/double_entry/{line_check_spec.rb → validation/line_check_spec.rb} +17 -16
- data/spec/double_entry_spec.rb +409 -0
- data/spec/support/accounts.rb +16 -17
- metadata +70 -35
- checksums.yaml +0 -15
- data/spec/double_entry/aggregate_spec.rb +0 -168
- data/spec/double_entry/double_entry_spec.rb +0 -391
- data/spec/double_entry/line_aggregate_spec.rb +0 -8
data/README.md
CHANGED
@@ -133,7 +133,7 @@ the accounts, and permitted transfers between those accounts.
|
|
133
133
|
The configuration file should be kept in your application's load path. For example,
|
134
134
|
*config/initializers/double_entry.rb*
|
135
135
|
|
136
|
-
For example, the following specifies two accounts,
|
136
|
+
For example, the following specifies two accounts, savings and checking.
|
137
137
|
Each account is scoped by User (where User is an object with an ID), meaning
|
138
138
|
each user can have their own account of each type.
|
139
139
|
|
@@ -142,22 +142,24 @@ This configuration also specifies that money can be transferred between the two
|
|
142
142
|
```ruby
|
143
143
|
require 'double_entry'
|
144
144
|
|
145
|
-
DoubleEntry.
|
146
|
-
|
147
|
-
|
148
|
-
user_identifier.
|
149
|
-
|
150
|
-
|
145
|
+
DoubleEntry.configure do |config|
|
146
|
+
config.define_accounts do |accounts|
|
147
|
+
user_scope = lambda do |user_identifier|
|
148
|
+
if user_identifier.is_a?(User)
|
149
|
+
user_identifier.id
|
150
|
+
else
|
151
|
+
user_identifier
|
152
|
+
end
|
151
153
|
end
|
152
|
-
end
|
153
154
|
|
154
|
-
|
155
|
-
|
156
|
-
end
|
155
|
+
accounts.define(identifier: :savings, scope_identifier: user_scope, positive_only: true)
|
156
|
+
accounts.define(identifier: :checking, scope_identifier: user_scope)
|
157
|
+
end
|
157
158
|
|
158
|
-
|
159
|
-
|
160
|
-
|
159
|
+
config.define_transfers do |transfers|
|
160
|
+
transfers.define(from: :checking, to: :savings, code: :deposit)
|
161
|
+
transfers.define(from: :savings, to: :checking, code: :withdraw)
|
162
|
+
end
|
161
163
|
end
|
162
164
|
```
|
163
165
|
|
data/lib/double_entry.rb
CHANGED
@@ -1,32 +1,20 @@
|
|
1
1
|
# encoding: utf-8
|
2
|
-
|
3
2
|
require 'active_record'
|
4
3
|
require 'active_record/locking_extensions'
|
5
|
-
|
6
4
|
require 'active_support/all'
|
7
|
-
|
8
5
|
require 'money'
|
9
6
|
require 'encapsulate_as_money'
|
10
7
|
|
11
8
|
require 'double_entry/version'
|
12
9
|
require 'double_entry/configurable'
|
10
|
+
require 'double_entry/configuration'
|
13
11
|
require 'double_entry/account'
|
14
12
|
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
13
|
require 'double_entry/locking'
|
29
14
|
require 'double_entry/transfer'
|
15
|
+
require 'double_entry/line'
|
16
|
+
require 'double_entry/reporting'
|
17
|
+
require 'double_entry/validation'
|
30
18
|
|
31
19
|
# Keep track of all the monies!
|
32
20
|
#
|
@@ -44,7 +32,6 @@ module DoubleEntry
|
|
44
32
|
class AccountWouldBeSentNegative < RuntimeError; end
|
45
33
|
|
46
34
|
class << self
|
47
|
-
attr_accessor :accounts, :transfers
|
48
35
|
|
49
36
|
# Get the particular account instance with the provided identifier and
|
50
37
|
# scope.
|
@@ -60,8 +47,8 @@ module DoubleEntry
|
|
60
47
|
# configured. It is unknown.
|
61
48
|
#
|
62
49
|
def account(identifier, options = {})
|
63
|
-
account =
|
64
|
-
current_account.identifier == identifier
|
50
|
+
account = configuration.accounts.detect do |current_account|
|
51
|
+
current_account.identifier == identifier &&
|
65
52
|
(options[:scope] ? current_account.scoped? : !current_account.scoped?)
|
66
53
|
end
|
67
54
|
|
@@ -108,7 +95,7 @@ module DoubleEntry
|
|
108
95
|
def transfer(amount, options = {})
|
109
96
|
raise TransferIsNegative if amount < Money.new(0)
|
110
97
|
from, to, code, meta, detail = options[:from], options[:to], options[:code], options[:meta], options[:detail]
|
111
|
-
transfer =
|
98
|
+
transfer = configuration.transfers.find(from, to, code)
|
112
99
|
if transfer
|
113
100
|
transfer.process!(amount, from, to, code, meta, detail)
|
114
101
|
else
|
@@ -179,27 +166,6 @@ module DoubleEntry
|
|
179
166
|
end
|
180
167
|
end
|
181
168
|
|
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
169
|
# Lock accounts in preparation for transfers.
|
204
170
|
#
|
205
171
|
# This creates a transaction, and uses database-level locking to ensure
|
@@ -225,20 +191,12 @@ module DoubleEntry
|
|
225
191
|
# make sure we have a test for this refactoring, the test
|
226
192
|
# conditions are: i forget... but it's important!
|
227
193
|
if line.credit?
|
228
|
-
|
194
|
+
configuration.transfers.find(line.account, line.partner_account, line.code)
|
229
195
|
else
|
230
|
-
|
196
|
+
configuration.transfers.find(line.partner_account, line.account, line.code)
|
231
197
|
end.description.call(line)
|
232
198
|
end
|
233
199
|
|
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
200
|
# This is used by the concurrency test script.
|
243
201
|
#
|
244
202
|
# @api private
|
@@ -255,16 +213,5 @@ module DoubleEntry
|
|
255
213
|
def table_name_prefix
|
256
214
|
'double_entry_'
|
257
215
|
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
216
|
end
|
269
|
-
|
270
217
|
end
|
data/lib/double_entry/account.rb
CHANGED
@@ -2,8 +2,12 @@
|
|
2
2
|
module DoubleEntry
|
3
3
|
class Account
|
4
4
|
class Set < Array
|
5
|
+
def define(attributes)
|
6
|
+
self << Account.new(attributes)
|
7
|
+
end
|
8
|
+
|
5
9
|
def <<(account)
|
6
|
-
if
|
10
|
+
if any? { |a| a.identifier == account.identifier }
|
7
11
|
raise DuplicateAccount.new
|
8
12
|
else
|
9
13
|
super(account)
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module DoubleEntry
|
3
|
+
include Configurable
|
4
|
+
|
5
|
+
class Configuration
|
6
|
+
attr_accessor :accounts, :transfers
|
7
|
+
|
8
|
+
def initialize #:nodoc:
|
9
|
+
@accounts = Account::Set.new
|
10
|
+
@transfers = Transfer::Set.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def define_accounts
|
14
|
+
yield accounts
|
15
|
+
end
|
16
|
+
|
17
|
+
def define_transfers
|
18
|
+
yield transfers
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -1,7 +1,21 @@
|
|
1
1
|
# encoding: utf-8
|
2
|
+
require 'double_entry/reporting/aggregate'
|
3
|
+
require 'double_entry/reporting/aggregate_array'
|
4
|
+
require 'double_entry/reporting/time_range'
|
5
|
+
require 'double_entry/reporting/time_range_array'
|
6
|
+
require 'double_entry/reporting/day_range'
|
7
|
+
require 'double_entry/reporting/hour_range'
|
8
|
+
require 'double_entry/reporting/week_range'
|
9
|
+
require 'double_entry/reporting/month_range'
|
10
|
+
require 'double_entry/reporting/year_range'
|
11
|
+
require 'double_entry/reporting/line_aggregate'
|
12
|
+
|
2
13
|
module DoubleEntry
|
14
|
+
|
15
|
+
# @api private
|
3
16
|
module Reporting
|
4
17
|
include Configurable
|
18
|
+
extend self
|
5
19
|
|
6
20
|
class Configuration
|
7
21
|
attr_accessor :start_of_business, :first_month_of_financial_year
|
@@ -12,5 +26,42 @@ module DoubleEntry
|
|
12
26
|
end
|
13
27
|
end
|
14
28
|
|
29
|
+
def aggregate(function, account, code, options = {})
|
30
|
+
Aggregate.new(function, account, code, options).formatted_amount
|
31
|
+
end
|
32
|
+
|
33
|
+
def aggregate_array(function, account, code, options = {})
|
34
|
+
AggregateArray.new(function, account, code, options)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Identify the scopes with the given account identifier holding at least
|
38
|
+
# the provided minimum balance.
|
39
|
+
#
|
40
|
+
# @example Find users with at least $1,000,000 in their savings accounts
|
41
|
+
# DoubleEntry.scopes_with_minimum_balance_for_account(
|
42
|
+
# Money.new(1_000_000_00),
|
43
|
+
# :savings
|
44
|
+
# ) # might return user ids: [ 1423, 12232, 34729 ]
|
45
|
+
# @param minimum_balance [Money] Minimum account balance a scope must have
|
46
|
+
# to be included in the result set.
|
47
|
+
# @param account_identifier [Symbol]
|
48
|
+
# @return [Array<Fixnum>] Scopes
|
49
|
+
def scopes_with_minimum_balance_for_account(minimum_balance, account_identifier)
|
50
|
+
select_values(sanitize_sql_array([<<-SQL, account_identifier, minimum_balance.cents])).map {|scope| scope.to_i }
|
51
|
+
SELECT scope
|
52
|
+
FROM #{AccountBalance.table_name}
|
53
|
+
WHERE account = ?
|
54
|
+
AND balance >= ?
|
55
|
+
SQL
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
delegate :connection, :to => ActiveRecord::Base
|
61
|
+
delegate :select_values, :to => :connection
|
62
|
+
|
63
|
+
def sanitize_sql_array(sql_array)
|
64
|
+
ActiveRecord::Base.send(:sanitize_sql_array, sql_array)
|
65
|
+
end
|
15
66
|
end
|
16
67
|
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
|
+
module Reporting
|
3
4
|
class Aggregate
|
4
5
|
attr_reader :function, :account, :code, :scope, :range, :options, :filter
|
5
6
|
|
@@ -51,7 +52,7 @@ module DoubleEntry
|
|
51
52
|
end
|
52
53
|
|
53
54
|
def calculate
|
54
|
-
if range.class ==
|
55
|
+
if range.class == YearRange
|
55
56
|
aggregate = calculate_yearly_aggregate
|
56
57
|
else
|
57
58
|
aggregate = LineAggregate.aggregate(function, account, code, nil, range, filter)
|
@@ -78,8 +79,8 @@ module DoubleEntry
|
|
78
79
|
zero = Aggregate.formatted_amount(function, 0)
|
79
80
|
|
80
81
|
result = (1..12).inject(zero) do |total, month|
|
81
|
-
total +=
|
82
|
-
:range =>
|
82
|
+
total += Reporting.aggregate(function, account, code,
|
83
|
+
:range => MonthRange.new(:year => range.year, :month => month), :filter => filter)
|
83
84
|
end
|
84
85
|
|
85
86
|
result = result.cents if result.class == Money
|
@@ -89,10 +90,10 @@ module DoubleEntry
|
|
89
90
|
|
90
91
|
def calculate_yearly_average
|
91
92
|
# need this seperate function, because an average of averages is not the correct average
|
92
|
-
sum =
|
93
|
-
:range =>
|
94
|
-
count =
|
95
|
-
:range =>
|
93
|
+
sum = Reporting.aggregate(:sum, account, code,
|
94
|
+
:range => YearRange.new(:year => range.year), :filter => filter)
|
95
|
+
count = Reporting.aggregate(:count, account, code,
|
96
|
+
:range => YearRange.new(:year => range.year), :filter => filter)
|
96
97
|
(count == 0) ? 0 : (sum / count).cents
|
97
98
|
end
|
98
99
|
|
@@ -115,4 +116,5 @@ module DoubleEntry
|
|
115
116
|
}
|
116
117
|
end
|
117
118
|
end
|
119
|
+
end
|
118
120
|
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
|
+
module Reporting
|
3
4
|
class AggregateArray < Array
|
4
5
|
# An AggregateArray is awesome
|
5
6
|
# It is useful for making reports
|
@@ -37,7 +38,7 @@ module DoubleEntry
|
|
37
38
|
# (this includes aggregates for the still-running period)
|
38
39
|
all_periods.each do |period|
|
39
40
|
unless @aggregates[period.key]
|
40
|
-
@aggregates[period.key] =
|
41
|
+
@aggregates[period.key] = Reporting.aggregate(function, account, code, :filter => filter, :range => period)
|
41
42
|
end
|
42
43
|
end
|
43
44
|
end
|
@@ -62,4 +63,5 @@ module DoubleEntry
|
|
62
63
|
TimeRangeArray.make(range_type, start, finish)
|
63
64
|
end
|
64
65
|
end
|
66
|
+
end
|
65
67
|
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
|
+
module Reporting
|
3
4
|
class LineAggregate < ActiveRecord::Base
|
4
5
|
extend EncapsulateAsMoney
|
5
6
|
|
@@ -15,7 +16,7 @@ module DoubleEntry
|
|
15
16
|
# in named_scopes to bring in data from other tables.
|
16
17
|
def self.aggregate_collection(named_scopes)
|
17
18
|
if named_scopes
|
18
|
-
collection = Line
|
19
|
+
collection = DoubleEntry::Line
|
19
20
|
named_scopes.each do |named_scope|
|
20
21
|
if named_scope.is_a?(Hash)
|
21
22
|
method_name = named_scope.keys[0]
|
@@ -26,7 +27,7 @@ module DoubleEntry
|
|
26
27
|
end
|
27
28
|
collection
|
28
29
|
else
|
29
|
-
Line
|
30
|
+
DoubleEntry::Line
|
30
31
|
end
|
31
32
|
end
|
32
33
|
|
@@ -34,4 +35,5 @@ module DoubleEntry
|
|
34
35
|
"#{year}:#{month}:#{week}:#{day}:#{hour}"
|
35
36
|
end
|
36
37
|
end
|
38
|
+
end
|
37
39
|
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
|
+
module Reporting
|
3
4
|
class MonthRange < TimeRange
|
4
5
|
|
5
6
|
class << self
|
@@ -29,7 +30,7 @@ module DoubleEntry
|
|
29
30
|
end
|
30
31
|
|
31
32
|
def earliest_month
|
32
|
-
from_time(
|
33
|
+
from_time(Reporting.configuration.start_of_business)
|
33
34
|
end
|
34
35
|
end
|
35
36
|
|
@@ -66,7 +67,7 @@ module DoubleEntry
|
|
66
67
|
end
|
67
68
|
|
68
69
|
def beginning_of_financial_year
|
69
|
-
first_month_of_financial_year =
|
70
|
+
first_month_of_financial_year = Reporting.configuration.first_month_of_financial_year
|
70
71
|
year = (month >= first_month_of_financial_year) ? @year : (@year - 1)
|
71
72
|
MonthRange.new(:year => year, :month => first_month_of_financial_year)
|
72
73
|
end
|
@@ -89,4 +90,5 @@ module DoubleEntry
|
|
89
90
|
start.strftime("%Y, %b")
|
90
91
|
end
|
91
92
|
end
|
93
|
+
end
|
92
94
|
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module DoubleEntry
|
3
|
+
module Reporting
|
3
4
|
class TimeRange
|
4
5
|
attr_reader :start, :finish
|
5
6
|
attr_reader :year, :month, :week, :day, :hour, :range_type
|
@@ -25,13 +26,13 @@ module DoubleEntry
|
|
25
26
|
def self.range_from_time_for_period(start_time, period_name)
|
26
27
|
case period_name
|
27
28
|
when 'month'
|
28
|
-
|
29
|
+
YearRange.from_time(start_time)
|
29
30
|
when 'week'
|
30
|
-
|
31
|
+
YearRange.from_time(start_time)
|
31
32
|
when 'day'
|
32
|
-
|
33
|
+
MonthRange.from_time(start_time)
|
33
34
|
when 'hour'
|
34
|
-
|
35
|
+
DayRange.from_time(start_time)
|
35
36
|
end
|
36
37
|
end
|
37
38
|
|
@@ -49,7 +50,8 @@ module DoubleEntry
|
|
49
50
|
end
|
50
51
|
|
51
52
|
def human_readable_name
|
52
|
-
self.class.name.gsub('DoubleEntry::', '').gsub('Range', '')
|
53
|
+
self.class.name.gsub('DoubleEntry::Reporting::', '').gsub('Range', '')
|
53
54
|
end
|
54
55
|
end
|
56
|
+
end
|
55
57
|
end
|