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.
Files changed (40) hide show
  1. data/README.md +16 -14
  2. data/lib/double_entry.rb +9 -62
  3. data/lib/double_entry/account.rb +5 -1
  4. data/lib/double_entry/configuration.rb +21 -0
  5. data/lib/double_entry/reporting.rb +51 -0
  6. data/lib/double_entry/{aggregate.rb → reporting/aggregate.rb} +9 -7
  7. data/lib/double_entry/{aggregate_array.rb → reporting/aggregate_array.rb} +3 -1
  8. data/lib/double_entry/{day_range.rb → reporting/day_range.rb} +2 -0
  9. data/lib/double_entry/{hour_range.rb → reporting/hour_range.rb} +2 -0
  10. data/lib/double_entry/{line_aggregate.rb → reporting/line_aggregate.rb} +4 -2
  11. data/lib/double_entry/{month_range.rb → reporting/month_range.rb} +4 -2
  12. data/lib/double_entry/{time_range.rb → reporting/time_range.rb} +7 -5
  13. data/lib/double_entry/{time_range_array.rb → reporting/time_range_array.rb} +2 -0
  14. data/lib/double_entry/{week_range.rb → reporting/week_range.rb} +3 -1
  15. data/lib/double_entry/{year_range.rb → reporting/year_range.rb} +4 -3
  16. data/lib/double_entry/transfer.rb +4 -0
  17. data/lib/double_entry/validation.rb +1 -0
  18. data/lib/double_entry/{line_check.rb → validation/line_check.rb} +2 -0
  19. data/lib/double_entry/version.rb +1 -1
  20. data/script/jack_hammer +21 -16
  21. data/spec/double_entry/account_spec.rb +9 -0
  22. data/spec/double_entry/configuration_spec.rb +23 -0
  23. data/spec/double_entry/locking_spec.rb +24 -13
  24. data/spec/double_entry/{aggregate_array_spec.rb → reporting/aggregate_array_spec.rb} +2 -2
  25. data/spec/double_entry/reporting/aggregate_spec.rb +171 -0
  26. data/spec/double_entry/reporting/line_aggregate_spec.rb +10 -0
  27. data/spec/double_entry/{month_range_spec.rb → reporting/month_range_spec.rb} +23 -21
  28. data/spec/double_entry/{time_range_array_spec.rb → reporting/time_range_array_spec.rb} +41 -39
  29. data/spec/double_entry/{time_range_spec.rb → reporting/time_range_spec.rb} +10 -9
  30. data/spec/double_entry/{week_range_spec.rb → reporting/week_range_spec.rb} +26 -25
  31. data/spec/double_entry/reporting_spec.rb +24 -0
  32. data/spec/double_entry/transfer_spec.rb +17 -0
  33. data/spec/double_entry/{line_check_spec.rb → validation/line_check_spec.rb} +17 -16
  34. data/spec/double_entry_spec.rb +409 -0
  35. data/spec/support/accounts.rb +16 -17
  36. metadata +70 -35
  37. checksums.yaml +0 -15
  38. data/spec/double_entry/aggregate_spec.rb +0 -168
  39. data/spec/double_entry/double_entry_spec.rb +0 -391
  40. 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, account_a and account_b.
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.accounts = DoubleEntry::Account::Set.new.tap do |accounts|
146
- user_scope = lambda do |user_identifier|
147
- if user_identifier.is_a?(User)
148
- user_identifier.id
149
- else
150
- user_identifier
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
- accounts << DoubleEntry::Account.new(identifier: :savings, scope_identifier: user_scope, positive_only: true)
155
- accounts << DoubleEntry::Account.new(identifier: :checking, scope_identifier: user_scope)
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
- DoubleEntry.transfers = DoubleEntry::Transfer::Set.new.tap do |transfers|
159
- transfers << DoubleEntry::Transfer.new(from: :checking, to: :savings, code: :deposit)
160
- transfers << DoubleEntry::Transfer.new(from: :savings, to: :checking, code: :withdraw)
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 = @accounts.detect do |current_account|
64
- current_account.identifier == identifier and
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 = @transfers.find(from, to, code)
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
- @transfers.find(line.account, line.partner_account, line.code)
194
+ configuration.transfers.find(line.account, line.partner_account, line.code)
229
195
  else
230
- @transfers.find(line.partner_account, line.account, line.code)
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
@@ -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 detect { |a| a.identifier == account.identifier }
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 == DoubleEntry::YearRange
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 += DoubleEntry.aggregate(function, account, code,
82
- :range => DoubleEntry::MonthRange.new(:year => range.year, :month => month), :filter => filter)
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 = DoubleEntry.aggregate(:sum, account, code,
93
- :range => DoubleEntry::YearRange.new(:year => range.year), :filter => filter)
94
- count = DoubleEntry.aggregate(:count, account, code,
95
- :range => DoubleEntry::YearRange.new(:year => range.year), :filter => filter)
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] = DoubleEntry.aggregate(function, account, code, :filter => filter, :range => period)
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 DayRange < TimeRange
4
5
  attr_reader :year, :week, :day
5
6
 
@@ -35,4 +36,5 @@ module DoubleEntry
35
36
  start.strftime('%Y, %a %b %d')
36
37
  end
37
38
  end
39
+ end
38
40
  end
@@ -1,5 +1,6 @@
1
1
  # encoding: utf-8
2
2
  module DoubleEntry
3
+ module Reporting
3
4
  class HourRange < TimeRange
4
5
  attr_reader :year, :week, :day, :hour
5
6
 
@@ -37,4 +38,5 @@ module DoubleEntry
37
38
  "#{start.hour}:00:00 - #{start.hour}:59:59"
38
39
  end
39
40
  end
41
+ end
40
42
  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(DoubleEntry::Reporting.configuration.start_of_business)
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 = DoubleEntry::Reporting.configuration.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
- DoubleEntry::YearRange.from_time(start_time)
29
+ YearRange.from_time(start_time)
29
30
  when 'week'
30
- DoubleEntry::YearRange.from_time(start_time)
31
+ YearRange.from_time(start_time)
31
32
  when 'day'
32
- DoubleEntry::MonthRange.from_time(start_time)
33
+ MonthRange.from_time(start_time)
33
34
  when 'hour'
34
- DoubleEntry::DayRange.from_time(start_time)
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