double_entry 0.1.0 → 0.2.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 +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
|