double_entry 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -66,7 +66,9 @@ than scoped accounts due to lock contention.
66
66
 
67
67
  To get a particular account:
68
68
 
69
- account = DoubleEntry.account(:spending, :scope => user)
69
+ ```ruby
70
+ account = DoubleEntry.account(:spending, :scope => user)
71
+ ```
70
72
 
71
73
  (This actually returns an Account::Instance object.)
72
74
 
@@ -77,7 +79,9 @@ See **DoubleEntry::Account** for more info.
77
79
 
78
80
  Calling:
79
81
 
80
- account.balance
82
+ ```ruby
83
+ account.balance
84
+ ```
81
85
 
82
86
  will return the current balance for an account as a Money object.
83
87
 
@@ -86,7 +90,9 @@ will return the current balance for an account as a Money object.
86
90
 
87
91
  To transfer money between accounts:
88
92
 
89
- DoubleEntry.transfer(20.dollars, :from => account_a, :to => account_b, :code => :purchase)
93
+ ```ruby
94
+ DoubleEntry.transfer(20.dollars, :from => account_a, :to => account_b, :code => :purchase)
95
+ ```
90
96
 
91
97
  The possible transfers, and their codes, should be defined in the configuration.
92
98
 
@@ -99,10 +105,12 @@ If you're doing more than one transfer in a single financial transaction, or
99
105
  you're doing other database operations along with the transfer, you'll need to
100
106
  manually lock the accounts you're using:
101
107
 
102
- DoubleEntry.lock_accounts(account_a, account_b) do
103
- # Do some other stuff in here...
104
- DoubleEntry.transfer(20.dollars, :from => account_a, :to => account_b, :code => :purchase)
105
- end
108
+ ```ruby
109
+ DoubleEntry.lock_accounts(account_a, account_b) do
110
+ # Do some other stuff in here...
111
+ DoubleEntry.transfer(20.dollars, :from => account_a, :to => account_b, :code => :purchase)
112
+ end
113
+ ```
106
114
 
107
115
  The lock_accounts call generates a database transaction, which must be the
108
116
  outermost transaction.
@@ -152,13 +160,13 @@ DoubleEntry.configure do |config|
152
160
  end
153
161
  end
154
162
 
155
- accounts.define(identifier: :savings, scope_identifier: user_scope, positive_only: true)
156
- accounts.define(identifier: :checking, scope_identifier: user_scope)
163
+ accounts.define(:identifier => :savings, :scope_identifier => user_scope, :positive_only => true)
164
+ accounts.define(:identifier => :checking, :scope_identifier => user_scope)
157
165
  end
158
166
 
159
167
  config.define_transfers do |transfers|
160
- transfers.define(from: :checking, to: :savings, code: :deposit)
161
- transfers.define(from: :savings, to: :checking, code: :withdraw)
168
+ transfers.define(:from => :checking, :to => :savings, :code => :deposit)
169
+ transfers.define(:from => :savings, :to => :checking, :code => :withdraw)
162
170
  end
163
171
  end
164
172
  ```
data/lib/double_entry.rb CHANGED
@@ -6,10 +6,12 @@ require 'money'
6
6
  require 'encapsulate_as_money'
7
7
 
8
8
  require 'double_entry/version'
9
+ require 'double_entry/errors'
9
10
  require 'double_entry/configurable'
10
11
  require 'double_entry/configuration'
11
12
  require 'double_entry/account'
12
13
  require 'double_entry/account_balance'
14
+ require 'double_entry/balance_calculator'
13
15
  require 'double_entry/locking'
14
16
  require 'double_entry/transfer'
15
17
  require 'double_entry/line'
@@ -22,15 +24,6 @@ require 'double_entry/validation'
22
24
  # transferring money around the system.
23
25
  module DoubleEntry
24
26
 
25
- class UnknownAccount < RuntimeError; end
26
- class TransferNotAllowed < RuntimeError; end
27
- class TransferIsNegative < RuntimeError; end
28
- class RequiredMetaMissing < RuntimeError; end
29
- class DuplicateAccount < RuntimeError; end
30
- class DuplicateTransfer < RuntimeError; end
31
- class UserAccountNotLocked < RuntimeError; end
32
- class AccountWouldBeSentNegative < RuntimeError; end
33
-
34
27
  class << self
35
28
 
36
29
  # Get the particular account instance with the provided identifier and
@@ -47,16 +40,7 @@ module DoubleEntry
47
40
  # configured. It is unknown.
48
41
  #
49
42
  def account(identifier, options = {})
50
- account = configuration.accounts.detect do |current_account|
51
- current_account.identifier == identifier &&
52
- (options[:scope] ? current_account.scoped? : !current_account.scoped?)
53
- end
54
-
55
- if account
56
- DoubleEntry::Account::Instance.new(:account => account, :scope => options[:scope])
57
- else
58
- raise UnknownAccount.new("account: #{identifier} scope: #{options[:scope]}")
59
- end
43
+ Account.account(configuration.accounts, identifier, options)
60
44
  end
61
45
 
62
46
  # Transfer money from one account to another.
@@ -83,7 +67,7 @@ module DoubleEntry
83
67
  # of this account.
84
68
  # @option options :to [DoubleEntry::Account::Instance] Transfer money into
85
69
  # this account.
86
- # @option options :code [Symbol] Your application specific code for this
70
+ # @option options :code [Symbol] The application specific code for this
87
71
  # type of transfer. As specified in the transfer configuration.
88
72
  # @option options :meta [String] Metadata to associate with this transfer.
89
73
  # @option options :detail [ActiveRecord::Base] ActiveRecord model
@@ -93,20 +77,13 @@ module DoubleEntry
93
77
  # accounts with the provided code is not allowed. Check configuration.
94
78
  #
95
79
  def transfer(amount, options = {})
96
- raise TransferIsNegative if amount < Money.new(0)
97
- from, to, code, meta, detail = options[:from], options[:to], options[:code], options[:meta], options[:detail]
98
- transfer = configuration.transfers.find(from, to, code)
99
- if transfer
100
- transfer.process!(amount, from, to, code, meta, detail)
101
- else
102
- raise TransferNotAllowed.new([from.identifier, to.identifier, code].inspect)
103
- end
80
+ Transfer.transfer(configuration.transfers, amount, options)
104
81
  end
105
82
 
106
- # Get the current balance of an account, as a Money object.
83
+ # Get the current or historic balance of an account.
107
84
  #
108
85
  # @param account [DoubleEntry::Account:Instance, Symbol]
109
- # @option options :scope [Symbol]
86
+ # @option options :scope [Object, String]
110
87
  # @option options :from [Time]
111
88
  # @option options :to [Time]
112
89
  # @option options :at [Time]
@@ -114,56 +91,7 @@ module DoubleEntry
114
91
  # @option options :codes [Array<Symbol>]
115
92
  # @return [Money]
116
93
  def balance(account, options = {})
117
- scope_arg = options[:scope] ? options[:scope].id.to_s : nil
118
- scope = (account.is_a?(Symbol) ? scope_arg : account.scope_identity)
119
- account = (account.is_a?(Symbol) ? account : account.identifier).to_s
120
- from, to, at = options[:from], options[:to], options[:at]
121
- code, codes = options[:code], options[:codes]
122
-
123
- # time based scoping
124
- conditions = if at
125
- # lookup method could use running balance, with a order by limit one clause
126
- # (unless it's a reporting call, i.e. account == symbol and not an instance)
127
- ['account = ? and created_at <= ?', account, at] # index this??
128
- elsif from and to
129
- ['account = ? and created_at >= ? and created_at <= ?', account, from, to] # index this??
130
- else
131
- # lookup method could use running balance, with a order by limit one clause
132
- # (unless it's a reporting call, i.e. account == symbol and not an instance)
133
- ['account = ?', account]
134
- end
135
-
136
- # code based scoping
137
- if code
138
- conditions[0] << ' and code = ?' # index this??
139
- conditions << code.to_s
140
- elsif codes
141
- conditions[0] << ' and code in (?)' # index this??
142
- conditions << codes.collect { |c| c.to_s }
143
- end
144
-
145
- # account based scoping
146
- if scope
147
- conditions[0] << ' and scope = ?'
148
- conditions << scope
149
-
150
- # This is to work around a MySQL 5.1 query optimiser bug that causes the ORDER BY
151
- # on the query to fail in some circumstances, resulting in an old balance being
152
- # returned. This was biting us intermittently in spec runs.
153
- # See http://bugs.mysql.com/bug.php?id=51431
154
- if Line.connection.adapter_name.match /mysql/i
155
- use_index = "USE INDEX (lines_scope_account_id_idx)"
156
- end
157
- end
158
-
159
- if (from and to) or (code or codes)
160
- # from and to or code lookups have to be done via sum
161
- Money.new(Line.where(conditions).sum(:amount))
162
- else
163
- # all other lookups can be performed with running balances
164
- line = Line.select("id, balance").from("#{Line.quoted_table_name} #{use_index}").where(conditions).order('id desc').first
165
- line ? line.balance : Money.empty
166
- end
94
+ BalanceCalculator.calculate(account, options)
167
95
  end
168
96
 
169
97
  # Lock accounts in preparation for transfers.
@@ -183,33 +111,10 @@ module DoubleEntry
183
111
  # The transaction must be the outermost database transaction
184
112
  #
185
113
  def lock_accounts(*accounts, &block)
186
- DoubleEntry::Locking.lock_accounts(*accounts, &block)
114
+ Locking.lock_accounts(*accounts, &block)
187
115
  end
188
116
 
189
117
  # @api private
190
- def describe(line)
191
- # make sure we have a test for this refactoring, the test
192
- # conditions are: i forget... but it's important!
193
- if line.credit?
194
- configuration.transfers.find(line.account, line.partner_account, line.code)
195
- else
196
- configuration.transfers.find(line.partner_account, line.account, line.code)
197
- end.description.call(line)
198
- end
199
-
200
- # This is used by the concurrency test script.
201
- #
202
- # @api private
203
- # @return [Boolean] true if all the amounts for an account add up to the final balance,
204
- # which they always should.
205
- def reconciled?(account)
206
- scoped_lines = Line.where(:account => "#{account.identifier}", :scope => "#{account.scope}")
207
- sum_of_amounts = scoped_lines.sum(:amount)
208
- final_balance = scoped_lines.order(:id).last[:balance]
209
- cached_balance = AccountBalance.find_by_account(account)[:balance]
210
- final_balance == sum_of_amounts && final_balance == cached_balance
211
- end
212
-
213
118
  def table_name_prefix
214
119
  'double_entry_'
215
120
  end
@@ -1,11 +1,27 @@
1
1
  # encoding: utf-8
2
2
  module DoubleEntry
3
3
  class Account
4
+
5
+ # @api private
6
+ def self.account(defined_accounts, identifier, options = {})
7
+ account = defined_accounts.find(identifier, options[:scope].present?)
8
+ DoubleEntry::Account::Instance.new(:account => account, :scope => options[:scope])
9
+ end
10
+
11
+ # @api private
4
12
  class Set < Array
5
13
  def define(attributes)
6
14
  self << Account.new(attributes)
7
15
  end
8
16
 
17
+ def find(identifier, scoped)
18
+ account = detect do |account|
19
+ account.identifier == identifier && account.scoped? == scoped
20
+ end
21
+ raise UnknownAccount.new("account: #{identifier} scoped?: #{scoped}") unless account
22
+ return account
23
+ end
24
+
9
25
  def <<(account)
10
26
  if any? { |a| a.identifier == account.identifier }
11
27
  raise DuplicateAccount.new
@@ -34,8 +50,17 @@ module DoubleEntry
34
50
  scope_identifier.call(scope).to_s if scoped?
35
51
  end
36
52
 
37
- def balance(args = {})
38
- DoubleEntry.balance(self, args)
53
+ # Get the current or historic balance of this account.
54
+ #
55
+ # @option options :from [Time]
56
+ # @option options :to [Time]
57
+ # @option options :at [Time]
58
+ # @option options :code [Symbol]
59
+ # @option options :codes [Array<Symbol>]
60
+ # @return [Money]
61
+ #
62
+ def balance(options = {})
63
+ BalanceCalculator.calculate(self, options)
39
64
  end
40
65
 
41
66
  include Comparable
@@ -0,0 +1,105 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module BalanceCalculator
4
+ extend self
5
+
6
+ # Get the current or historic balance of an account.
7
+ #
8
+ # @param account [DoubleEntry::Account:Instance, Symbol]
9
+ # @option args :scope [Object, String]
10
+ # @option args :from [Time]
11
+ # @option args :to [Time]
12
+ # @option args :at [Time]
13
+ # @option args :code [Symbol]
14
+ # @option args :codes [Array<Symbol>]
15
+ # @return [Money]
16
+ #
17
+ def calculate(account, args = {})
18
+ options = Options.new(account, args)
19
+ relations = RelationBuilder.new(options)
20
+ lines = relations.build
21
+
22
+ if options.between? || options.code?
23
+ # from and to or code lookups have to be done via sum
24
+ Money.new(lines.sum(:amount))
25
+ else
26
+ # all other lookups can be performed with running balances
27
+ result = lines.
28
+ from(lines_table_name(options)).
29
+ order('id DESC').
30
+ limit(1).
31
+ pluck(:balance)
32
+ result.empty? ? Money.empty : Money.new(result.first)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def lines_table_name(options)
39
+ "#{Line.quoted_table_name}#{' USE INDEX (lines_scope_account_id_idx)' if force_index?(options)}"
40
+ end
41
+
42
+ def force_index?(options)
43
+ # This is to work around a MySQL 5.1 query optimiser bug that causes the ORDER BY
44
+ # on the query to fail in some circumstances, resulting in an old balance being
45
+ # returned. This was biting us intermittently in spec runs.
46
+ # See http://bugs.mysql.com/bug.php?id=51431
47
+ options.scope? && Line.connection.adapter_name.match(/mysql/i)
48
+ end
49
+
50
+ # @api private
51
+ class Options
52
+ attr_reader :account, :scope, :from, :to, :at, :codes
53
+
54
+ def initialize(account, args = {})
55
+ if account.is_a? Symbol
56
+ @account = account.to_s
57
+ @scope = args[:scope].present? ? args[:scope].id.to_s : nil
58
+ else
59
+ @account = account.identifier.to_s
60
+ @scope = account.scope_identity
61
+ end
62
+ @codes = (args[:codes].to_a << args[:code]).compact
63
+ @from = args[:from]
64
+ @to = args[:to]
65
+ @at = args[:at]
66
+ end
67
+
68
+ def at?
69
+ !!at
70
+ end
71
+
72
+ def between?
73
+ !!(from && to && !at?)
74
+ end
75
+
76
+ def code?
77
+ codes.present?
78
+ end
79
+
80
+ def scope?
81
+ !!scope
82
+ end
83
+ end
84
+
85
+ # @api private
86
+ class RelationBuilder
87
+ attr_reader :options
88
+ delegate :account, :scope, :scope?, :from, :to, :between?, :at, :at?, :codes, :code?, :to => :options
89
+
90
+ def initialize(options)
91
+ @options = options
92
+ end
93
+
94
+ def build
95
+ lines = Line.where(:account => account)
96
+ lines = lines.where('created_at <= ?', at) if at?
97
+ lines = lines.where(:created_at => from..to) if between?
98
+ lines = lines.where(:code => codes) if code?
99
+ lines = lines.where(:scope => scope) if scope?
100
+ lines
101
+ end
102
+ end
103
+
104
+ end
105
+ end
@@ -0,0 +1,13 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+
4
+ class UnknownAccount < RuntimeError; end
5
+ class TransferNotAllowed < RuntimeError; end
6
+ class TransferIsNegative < RuntimeError; end
7
+ class RequiredMetaMissing < RuntimeError; end
8
+ class DuplicateAccount < RuntimeError; end
9
+ class DuplicateTransfer < RuntimeError; end
10
+ class UserAccountNotLocked < RuntimeError; end
11
+ class AccountWouldBeSentNegative < RuntimeError; end
12
+
13
+ end
@@ -122,10 +122,6 @@ module DoubleEntry
122
122
  amount > Money.empty
123
123
  end
124
124
 
125
- def description
126
- DoubleEntry.describe(self)
127
- end
128
-
129
125
  # Query out just the id and created_at fields for lines, without
130
126
  # instantiating any ActiveRecord objects.
131
127
  def self.find_id_and_created_at(options)
@@ -2,13 +2,13 @@
2
2
  require 'double_entry/reporting/aggregate'
3
3
  require 'double_entry/reporting/aggregate_array'
4
4
  require 'double_entry/reporting/time_range'
5
- require 'double_entry/reporting/time_range_array'
6
5
  require 'double_entry/reporting/day_range'
7
6
  require 'double_entry/reporting/hour_range'
8
7
  require 'double_entry/reporting/week_range'
9
8
  require 'double_entry/reporting/month_range'
10
9
  require 'double_entry/reporting/year_range'
11
10
  require 'double_entry/reporting/line_aggregate'
11
+ require 'double_entry/reporting/time_range_array'
12
12
 
13
13
  module DoubleEntry
14
14
 
@@ -26,10 +26,82 @@ module DoubleEntry
26
26
  end
27
27
  end
28
28
 
29
+ class AggregateFunctionNotSupported < RuntimeError; end
30
+
31
+ # Perform an aggregate calculation on a set of transfers for an account.
32
+ #
33
+ # The transfers included in the calculation can be limited by time range
34
+ # and provided custom filters.
35
+ #
36
+ # @example Find the sum for all $10 :save transfers in all :checking accounts in the current month (assume the date is January 30, 2014).
37
+ # time_range = DoubleEntry::TimeRange.make(2014, 1)
38
+ # class ::DoubleEntry::Line
39
+ # scope :ten_dollar_transfers, -> { where(:amount => 10_00) }
40
+ # end
41
+ # DoubleEntry.aggregate(:sum, :checking, :save, range: time_range, filter: [:ten_dollar_transfers])
42
+ # @param function [Symbol] The function to perform on the set of transfers.
43
+ # Valid functions are :sum, :count, and :average
44
+ # @param account [Symbol] The symbol identifying the account to perform
45
+ # the aggregate calculation on. As specified in the account configuration.
46
+ # @param code [Symbol] The application specific code for the type of
47
+ # transfer to perform an aggregate calculation on. As specified in the
48
+ # transfer configuration.
49
+ # @option options :range [DoubleEntry::TimeRange] Only include transfers
50
+ # in the given time range in the calculation.
51
+ # @option options :filter [Array[Symbol], or Array[Hash<Symbol,Parameter>]]
52
+ # A custom filter to apply before performing the aggregate calculation.
53
+ # Currently, filters must be monkey patched as scopes into the DoubleEntry::Line
54
+ # class in order to be used as filters, as the example shows.
55
+ # If the filter requires a parameter, it must be given in a Hash, otherwise
56
+ # pass an array with the symbol names for the defined scopes.
57
+ # @return Returns a Money object for :sum and :average calculations, or a
58
+ # Fixnum for :count calculations.
59
+ # @raise [Reporting::AggregateFunctionNotSupported] The provided function
60
+ # is not supported.
61
+ #
29
62
  def aggregate(function, account, code, options = {})
30
63
  Aggregate.new(function, account, code, options).formatted_amount
31
64
  end
32
65
 
66
+ # Perform an aggregate calculation on a set of transfers for an account
67
+ # and return the results in an array partitioned by a time range type.
68
+ #
69
+ # The transfers included in the calculation can be limited by a time range
70
+ # and provided custom filters.
71
+ #
72
+ # @example Find the number of all $10 :save transfers in all :checking accounts per month for the entire year (Assume the year is 2014).
73
+ # DoubleEntry.aggregate_array(:sum, :checking, :save, range_type: 'month', start: '2014-01-01', finish: '2014-12-31')
74
+ # @param function [Symbol] The function to perform on the set of transfers.
75
+ # Valid functions are :sum, :count, and :average
76
+ # @param account [Symbol] The symbol identifying the account to perform
77
+ # the aggregate calculation on. As specified in the account configuration.
78
+ # @param code [Symbol] The application specific code for the type of
79
+ # transfer to perform an aggregate calculation on. As specified in the
80
+ # transfer configuration.
81
+ # @option options :filter [Array[Symbol], or Array[Hash<Symbol,Parameter>]]
82
+ # A custom filter to apply before performing the aggregate calculation.
83
+ # Currently, filters must be monkey patched as scopes into the DoubleEntry::Line
84
+ # class in order to be used as filters, as the example shows.
85
+ # If the filter requires a parameter, it must be given in a Hash, otherwise
86
+ # pass an array with the symbol names for the defined scopes.
87
+ # @option options :range_type [String] The type of time range to return data
88
+ # for. For example, specifying 'month' will return an array of the resulting
89
+ # aggregate calculation for each month.
90
+ # Valid range_types are 'hour', 'day', 'week', 'month', and 'year'
91
+ # @option options :start [String] The start date for the time range to perform
92
+ # calculations in. The default start date is the start_of_business (can
93
+ # be specified in configuration).
94
+ # The format of the string must be as follows: 'YYYY-mm-dd'
95
+ # @option options :finish [String] The finish (or end) date for the time range
96
+ # to perform calculations in. The default finish date is the current date.
97
+ # The format of the string must be as follows: 'YYYY-mm-dd'
98
+ # @return [Array[Money/Fixnum]] Returns an array of Money objects for :sum
99
+ # and :average calculations, or an array of Fixnum for :count calculations.
100
+ # The array is indexed by the range_type. For example, if range_type is
101
+ # specified as 'month', each index in the array will represent a month.
102
+ # @raise [Reporting::AggregateFunctionNotSupported] The provided function
103
+ # is not supported.
104
+ #
33
105
  def aggregate_array(function, account, code, options = {})
34
106
  AggregateArray.new(function, account, code, options)
35
107
  end
@@ -55,6 +127,19 @@ module DoubleEntry
55
127
  SQL
56
128
  end
57
129
 
130
+ # This is used by the concurrency test script.
131
+ #
132
+ # @api private
133
+ # @return [Boolean] true if all the amounts for an account add up to the final balance,
134
+ # which they always should.
135
+ def reconciled?(account)
136
+ scoped_lines = Line.where(:account => "#{account.identifier}", :scope => "#{account.scope}")
137
+ sum_of_amounts = scoped_lines.sum(:amount)
138
+ final_balance = scoped_lines.order(:id).last[:balance]
139
+ cached_balance = AccountBalance.find_by_account(account)[:balance]
140
+ final_balance == sum_of_amounts && final_balance == cached_balance
141
+ end
142
+
58
143
  private
59
144
 
60
145
  delegate :connection, :to => ActiveRecord::Base
@@ -2,16 +2,15 @@
2
2
  module DoubleEntry
3
3
  module Reporting
4
4
  class Aggregate
5
- attr_reader :function, :account, :code, :scope, :range, :options, :filter
5
+ attr_reader :function, :account, :code, :range, :options, :filter
6
6
 
7
7
  def initialize(function, account, code, options)
8
8
  @function = function.to_s
9
- raise "Function not supported" unless %w[sum count average].include?(@function)
9
+ raise AggregateFunctionNotSupported unless %w[sum count average].include?(@function)
10
10
 
11
11
  @account = account.to_s
12
12
  @code = code ? code.to_s : nil
13
13
  @options = options
14
- @scope = options[:scope]
15
14
  @range = options[:range]
16
15
  @filter = options[:filter]
17
16
  end
@@ -55,7 +54,7 @@ module DoubleEntry
55
54
  if range.class == YearRange
56
55
  aggregate = calculate_yearly_aggregate
57
56
  else
58
- aggregate = LineAggregate.aggregate(function, account, code, nil, range, filter)
57
+ aggregate = LineAggregate.aggregate(function, account, code, range, filter)
59
58
  end
60
59
 
61
60
  if range_is_complete?
@@ -4,7 +4,7 @@ module DoubleEntry
4
4
  class LineAggregate < ActiveRecord::Base
5
5
  extend EncapsulateAsMoney
6
6
 
7
- def self.aggregate(function, account, code, scope, range, named_scopes)
7
+ def self.aggregate(function, account, code, range, named_scopes)
8
8
  collection = aggregate_collection(named_scopes)
9
9
  collection = collection.where(:account => account)
10
10
  collection = collection.where(:created_at => range.start..range.finish)
@@ -1,45 +1,42 @@
1
1
  # encoding: utf-8
2
2
  module DoubleEntry
3
- module Reporting
4
- class TimeRangeArray
5
- class << self
3
+ module Reporting
4
+ class TimeRangeArray
6
5
 
7
- def make(range_type, start, finish = nil)
8
- raise "Must specify range for #{range_type}-by-#{range_type} reports" if start == nil
6
+ attr_reader :type, :require_start
7
+ alias_method :require_start?, :require_start
9
8
 
10
- case range_type
11
- when 'hour'
12
- make_array HourRange, start, finish
13
- when 'day'
14
- make_array DayRange, start, finish
15
- when 'week'
16
- make_array WeekRange, start, finish
17
- when 'month'
18
- make_array MonthRange, start, finish
19
- when 'year'
20
- make_array YearRange, start
21
- else
22
- raise ArgumentError.new("Invalid range type '#{range_type}'")
23
- end
9
+ def initialize(options = {})
10
+ @type = options[:type]
11
+ @require_start = options[:require_start]
24
12
  end
25
13
 
26
- private
27
-
28
- def make_array(type, start, finish = nil)
29
- start = type.from_time(Time.parse(start))
30
- finish = type.from_time(Time.parse(finish)) if finish
31
-
32
- loop = start
33
- last = finish || type.current
34
- results = [loop]
35
- while(loop != last) do
36
- loop = loop.next
37
- results << loop
14
+ def make(start = nil, finish = nil)
15
+ raise "Must specify start of range" if start == nil && require_start?
16
+ start = type.from_time(Time.parse(start || Reporting.configuration.start_of_business))
17
+ finish = finish ? type.from_time(Time.parse(finish)) : type.current
18
+ [ start ].tap do |array|
19
+ while start != finish
20
+ start = start.next
21
+ array << start
22
+ end
38
23
  end
24
+ end
25
+
26
+ FACTORIES = {
27
+ 'hour' => new(:type => HourRange, :require_start => true),
28
+ 'day' => new(:type => DayRange, :require_start => true),
29
+ 'week' => new(:type => WeekRange, :require_start => true),
30
+ 'month' => new(:type => MonthRange, :require_start => false),
31
+ 'year' => new(:type => YearRange, :require_start => false),
32
+ }
39
33
 
40
- results
34
+ def self.make(range_type, start = nil, finish = nil)
35
+ factory = FACTORIES[range_type]
36
+ raise ArgumentError.new("Invalid range type '#{range_type}'") unless factory
37
+ factory.make(start, finish)
41
38
  end
39
+
42
40
  end
43
41
  end
44
- end
45
42
  end
@@ -1,6 +1,17 @@
1
1
  # encoding: utf-8
2
2
  module DoubleEntry
3
3
  class Transfer
4
+
5
+ # @api private
6
+ def self.transfer(defined_transfers, amount, options = {})
7
+ raise TransferIsNegative if amount < Money.empty
8
+ from, to, code, meta, detail = options[:from], options[:to], options[:code], options[:meta], options[:detail]
9
+ defined_transfers.
10
+ find!(from, to, code).
11
+ process!(amount, from, to, code, meta, detail)
12
+ end
13
+
14
+ # @api private
4
15
  class Set < Array
5
16
  def define(attributes)
6
17
  self << Transfer.new(attributes)
@@ -10,6 +21,12 @@ module DoubleEntry
10
21
  _find(from.identifier, to.identifier, code)
11
22
  end
12
23
 
24
+ def find!(from, to, code)
25
+ transfer = find(from, to, code)
26
+ raise TransferNotAllowed.new([from.identifier, to.identifier, code].inspect) unless transfer
27
+ return transfer
28
+ end
29
+
13
30
  def <<(transfer)
14
31
  if _find(transfer.from, transfer.to, transfer.code)
15
32
  raise DuplicateTransfer.new
@@ -1,5 +1,5 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  module DoubleEntry
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/script/jack_hammer CHANGED
@@ -156,11 +156,11 @@ def reconcile
156
156
  error_count += 1
157
157
  end
158
158
 
159
- if $accounts.all? {|account| DoubleEntry.reconciled?(account) }
159
+ if $accounts.all? {|account| DoubleEntry::Reporting.reconciled?(account) }
160
160
  puts "All accounts reconciled, FTW!"
161
161
  else
162
162
  $accounts.each do |account|
163
- if !DoubleEntry.reconciled?(account)
163
+ if !DoubleEntry::Reporting.reconciled?(account)
164
164
  puts "Account #{account.identifier} failed to reconcile. :("
165
165
 
166
166
  # See http://bugs.mysql.com/bug.php?id=51431
@@ -0,0 +1,131 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe DoubleEntry::BalanceCalculator do
5
+
6
+ describe '#calculate' do
7
+ let(:account) { double.as_null_object }
8
+ let(:scope) { nil }
9
+ let(:from) { nil }
10
+ let(:to) { nil }
11
+ let(:at) { nil }
12
+ let(:code) { nil }
13
+ let(:codes) { nil }
14
+ let(:relation) { double.as_null_object }
15
+
16
+ before do
17
+ allow(DoubleEntry::Line).to receive(:where).and_return(relation)
18
+ DoubleEntry::BalanceCalculator.calculate(
19
+ account,
20
+ :scope => scope,
21
+ :from => from,
22
+ :to => to,
23
+ :at => at,
24
+ :code => code,
25
+ :codes => codes,
26
+ )
27
+ end
28
+
29
+ describe 'what happens with different accounts' do
30
+ context 'when the given account is a symbol' do
31
+ let(:account) { :account }
32
+
33
+ it 'scopes the lines summed by the account symbol' do
34
+ expect(DoubleEntry::Line).to have_received(:where).with(:account => 'account')
35
+ end
36
+
37
+ context 'with a scopeable entity provided' do
38
+ let(:scope) { double(:id => 'scope') }
39
+
40
+ it 'scopes the lines summed by the scope of the scopeable entity...scope' do
41
+ expect(relation).to have_received(:where).with(:scope => 'scope')
42
+ end
43
+ end
44
+
45
+ context 'with no scope provided' do
46
+ it 'does not scope the lines summed by the given scope' do
47
+ expect(relation).to_not have_received(:where).with(:scope => 'scope')
48
+ end
49
+ end
50
+ end
51
+
52
+ context 'when the given account is DoubleEntry::Account-like' do
53
+ let(:account) do
54
+ DoubleEntry::Account::Instance.new(
55
+ :account => DoubleEntry::Account.new(
56
+ :identifier => 'account_identity',
57
+ :scope_identifier => lambda { |scope_id| scope_id },
58
+ ),
59
+ :scope => 'account_scope_identity'
60
+ )
61
+ end
62
+
63
+ it 'scopes the lines summed by the accounts identifier and its scope identity' do
64
+ expect(DoubleEntry::Line).to have_received(:where).with(:account => 'account_identity')
65
+ expect(relation).to have_received(:where).with(:scope => 'account_scope_identity')
66
+ end
67
+ end
68
+ end
69
+
70
+ describe 'what happens with different times' do
71
+ context 'when we want to sum the lines before a given created_at date' do
72
+ let(:at) { Time.parse('2014-06-19 15:09:18 +1000') }
73
+
74
+ it 'scopes the lines summed to times before (or at) the given time' do
75
+ expect(relation).to have_received(:where).with(
76
+ 'created_at <= ?', Time.parse('2014-06-19 15:09:18 +1000')
77
+ )
78
+ end
79
+
80
+ context 'when a time range is also specified' do
81
+ let(:from) { Time.parse('2014-06-19 10:09:18 +1000') }
82
+ let(:to) { Time.parse('2014-06-19 20:09:18 +1000') }
83
+
84
+ it 'ignores the time range when summing the lines' do
85
+ expect(relation).to_not have_received(:where).with(
86
+ :created_at => Time.parse('2014-06-19 10:09:18 +1000')..Time.parse('2014-06-19 20:09:18 +1000')
87
+ )
88
+ expect(relation).to_not have_received(:sum)
89
+ end
90
+ end
91
+ end
92
+
93
+ context 'when we want to sum the lines between a given range' do
94
+ let(:from) { Time.parse('2014-06-19 10:09:18 +1000') }
95
+ let(:to) { Time.parse('2014-06-19 20:09:18 +1000') }
96
+
97
+ it 'scopes the lines summed to times within the given range' do
98
+ expect(relation).to have_received(:where).with(
99
+ :created_at => Time.parse('2014-06-19 10:09:18 +1000')..Time.parse('2014-06-19 20:09:18 +1000')
100
+ )
101
+ expect(relation).to have_received(:sum).with(:amount)
102
+ end
103
+ end
104
+ end
105
+
106
+ context 'when a single code is provided' do
107
+ let(:code) { 'code1' }
108
+
109
+ it 'scopes the lines summed by the given code' do
110
+ expect(relation).to have_received(:where).with(:code => ['code1'])
111
+ expect(relation).to have_received(:sum).with(:amount)
112
+ end
113
+ end
114
+
115
+ context 'when a list of codes is provided' do
116
+ let(:codes) { ['code1', 'code2'] }
117
+
118
+ it 'scopes the lines summed by the given codes' do
119
+ expect(relation).to have_received(:where).with(:code => ['code1', 'code2'])
120
+ expect(relation).to have_received(:sum).with(:amount)
121
+ end
122
+ end
123
+
124
+ context 'when no codes are provided' do
125
+ it 'does not scope the lines summed by any code' do
126
+ expect(relation).to_not have_received(:where).with(:code => anything)
127
+ expect(relation).to_not have_received(:sum).with(:amount)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -1,75 +1,89 @@
1
1
  require 'spec_helper'
2
- describe DoubleEntry::Reporting::AggregateArray do
2
+ module DoubleEntry
3
+ module Reporting
4
+ describe AggregateArray do
3
5
 
4
- let(:user) { User.make! }
5
- let(:start) { nil }
6
- let(:finish) { nil }
7
- let(:range_type) { 'year' }
6
+ let(:user) { User.make! }
7
+ let(:start) { nil }
8
+ let(:finish) { nil }
9
+ let(:range_type) { 'year' }
10
+ let(:function) { :sum }
8
11
 
9
- subject(:aggregate_array) {
10
- DoubleEntry::Reporting.aggregate_array(
11
- :sum,
12
- :savings,
13
- :bonus,
14
- :range_type => range_type,
15
- :start => start,
16
- :finish => finish,
17
- )
18
- }
12
+ subject(:aggregate_array) {
13
+ Reporting.aggregate_array(
14
+ function,
15
+ :savings,
16
+ :bonus,
17
+ :range_type => range_type,
18
+ :start => start,
19
+ :finish => finish,
20
+ )
21
+ }
19
22
 
20
- context 'given a deposit was made in 2007 and 2008' do
21
- before do
22
- Timecop.travel(Time.local(2007)) do
23
- perform_deposit user, 10_00
24
- end
25
- Timecop.travel(Time.local(2008)) do
26
- perform_deposit user, 20_00
27
- end
28
- end
23
+ context 'given a deposit was made in 2007 and 2008' do
24
+ before do
25
+ Timecop.travel(Time.local(2007)) do
26
+ perform_deposit user, 10_00
27
+ end
28
+ Timecop.travel(Time.local(2008)) do
29
+ perform_deposit user, 20_00
30
+ end
31
+ end
29
32
 
30
- context 'given the date is 2009-03-19' do
31
- before { Timecop.travel(Time.local(2009, 3, 19)) }
33
+ context 'given the date is 2009-03-19' do
34
+ before { Timecop.travel(Time.local(2009, 3, 19)) }
32
35
 
33
- context 'when called with range type of "year"' do
34
- let(:range_type) { 'year' }
35
- let(:start) { '2006-08-03' }
36
- it { should eq [ Money.new(0), Money.new(10_00), Money.new(20_00), Money.new(0) ] }
36
+ context 'when called with range type of "year"' do
37
+ let(:range_type) { 'year' }
38
+ let(:start) { '2006-08-03' }
39
+ it { should eq [ Money.new(0), Money.new(10_00), Money.new(20_00), Money.new(0) ] }
40
+ end
41
+ end
37
42
  end
38
- end
39
- end
40
43
 
41
- context 'given a deposit was made in October and December 2006' do
42
- before do
43
- Timecop.travel(Time.local(2006, 10)) do
44
- perform_deposit user, 10_00
45
- end
46
- Timecop.travel(Time.local(2006, 12)) do
47
- perform_deposit user, 20_00
48
- end
49
- end
44
+ context 'given a deposit was made in October and December 2006' do
45
+ before do
46
+ Timecop.travel(Time.local(2006, 10)) do
47
+ perform_deposit user, 10_00
48
+ end
49
+ Timecop.travel(Time.local(2006, 12)) do
50
+ perform_deposit user, 20_00
51
+ end
52
+ end
50
53
 
51
- context 'when called with range type of "month", a start of "2006-09-01", and finish of "2007-01-02"' do
52
- let(:range_type) { 'month' }
53
- let(:start) { '2006-09-01' }
54
- let(:finish) { '2007-01-02' }
55
- it { should eq [ Money.new(0), Money.new(10_00), Money.new(0), Money.new(20_00), Money.new(0), ] }
56
- end
54
+ context 'when called with range type of "month", a start of "2006-09-01", and finish of "2007-01-02"' do
55
+ let(:range_type) { 'month' }
56
+ let(:start) { '2006-09-01' }
57
+ let(:finish) { '2007-01-02' }
58
+ it { should eq [ Money.new(0), Money.new(10_00), Money.new(0), Money.new(20_00), Money.new(0), ] }
59
+ end
60
+
61
+ context 'given the date is 2007-02-02' do
62
+ before { Timecop.travel(Time.local(2007, 2, 2)) }
63
+
64
+ context 'when called with range type of "month"' do
65
+ let(:range_type) { 'month' }
66
+ let(:start) { '2006-08-03' }
67
+ it { should eq [ Money.new(0), Money.new(0), Money.new(10_00), Money.new(0), Money.new(20_00), Money.new(0), Money.new(0) ] }
68
+ end
69
+ end
70
+ end
57
71
 
58
- context 'given the date is 2007-02-02' do
59
- before { Timecop.travel(Time.local(2007, 2, 2)) }
72
+ context 'when called with range type of "invalid_and_should_not_work"' do
73
+ let(:range_type) { 'invalid_and_should_not_work' }
74
+ it 'raises an argument error' do
75
+ expect { aggregate_array }.to raise_error ArgumentError, "Invalid range type 'invalid_and_should_not_work'"
76
+ end
77
+ end
60
78
 
61
- context 'when called with range type of "month"' do
79
+ context 'when an invalid function is provided' do
62
80
  let(:range_type) { 'month' }
63
81
  let(:start) { '2006-08-03' }
64
- it { should eq [ Money.new(0), Money.new(0), Money.new(10_00), Money.new(0), Money.new(20_00), Money.new(0), Money.new(0) ] }
82
+ let(:function) { :invalid_function }
83
+ it 'raises an AggregateFunctionNotSupported error' do
84
+ expect{ aggregate_array }.to raise_error AggregateFunctionNotSupported
85
+ end
65
86
  end
66
87
  end
67
88
  end
68
-
69
- context 'when called with range type of "invalid_and_should_not_work"' do
70
- let(:range_type) { 'invalid_and_should_not_work' }
71
- it 'should raise an argument error' do
72
- expect { aggregate_array }.to raise_error ArgumentError, "Invalid range type 'invalid_and_should_not_work'"
73
- end
74
- end
75
89
  end
@@ -5,6 +5,12 @@ module DoubleEntry
5
5
  describe Aggregate do
6
6
 
7
7
  let(:user) { User.make! }
8
+ let(:expected_weekly_average) do
9
+ (Money.new(20_00) + Money.new(40_00) + Money.new(50_00)) / 3
10
+ end
11
+ let(:expected_monthly_average) do
12
+ (Money.new(20_00) + Money.new(40_00) + Money.new(50_00) + Money.new(40_00) + Money.new(50_00)) / 5
13
+ end
8
14
 
9
15
  before do
10
16
  # Thursday
@@ -107,12 +113,42 @@ module DoubleEntry
107
113
  ).to eq Money.new(200_00)
108
114
  end
109
115
 
116
+ it 'calculates the average monthly all_time ranges correctly' do
117
+ expect(
118
+ Reporting.aggregate(:average, :savings, :bonus, :range => TimeRange.make(:year => 2009, :month => 12, :range_type => :all_time))
119
+ ).to eq expected_monthly_average
120
+ end
121
+
122
+ it 'returns the correct count for weekly all_time ranges correctly' do
123
+ expect(
124
+ Reporting.aggregate(:count, :savings, :bonus, :range => TimeRange.make(:year => 2009, :month => 12, :range_type => :all_time))
125
+ ).to eq 5
126
+ end
127
+
110
128
  it 'should calculate weekly all_time ranges correctly' do
111
129
  expect(
112
130
  Reporting.aggregate(:sum, :savings, :bonus, :range => TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
113
131
  ).to eq Money.new(110_00)
114
132
  end
115
133
 
134
+ it 'calculates the average weekly all_time ranges correctly' do
135
+ expect(
136
+ Reporting.aggregate(:average, :savings, :bonus, :range => TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
137
+ ).to eq expected_weekly_average
138
+ end
139
+
140
+ it 'returns the correct count for weekly all_time ranges correctly' do
141
+ expect(
142
+ Reporting.aggregate(:count, :savings, :bonus, :range => TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
143
+ ).to eq 3
144
+ end
145
+
146
+ it "raises an AggregateFunctionNotSupported exception" do
147
+ expect{
148
+ Reporting.aggregate(:not_supported_calculation, :savings, :bonus, :range => TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time))
149
+ }.to raise_error(AggregateFunctionNotSupported)
150
+ end
151
+
116
152
  context 'filters' do
117
153
 
118
154
  let(:range) { TimeRange.make(:year => 2011, :month => 10) }
@@ -21,7 +21,7 @@ module DoubleEntry::Reporting
21
21
  context 'given start and finish are nil' do
22
22
  it 'should raise an error' do
23
23
  expect { TimeRangeArray.make(range_type, nil, nil) }.
24
- to raise_error 'Must specify range for hour-by-hour reports'
24
+ to raise_error 'Must specify start of range'
25
25
  end
26
26
  end
27
27
  end
@@ -44,7 +44,7 @@ module DoubleEntry::Reporting
44
44
  context 'given start and finish are nil' do
45
45
  it 'should raise an error' do
46
46
  expect { TimeRangeArray.make(range_type, nil, nil) }.
47
- to raise_error 'Must specify range for day-by-day reports'
47
+ to raise_error 'Must specify start of range'
48
48
  end
49
49
  end
50
50
  end
@@ -66,7 +66,7 @@ module DoubleEntry::Reporting
66
66
  context 'given start and finish are nil' do
67
67
  it 'should raise an error' do
68
68
  expect { TimeRangeArray.make(range_type, nil, nil) }.
69
- to raise_error 'Must specify range for week-by-week reports'
69
+ to raise_error 'Must specify start of range'
70
70
  end
71
71
  end
72
72
  end
@@ -117,11 +117,10 @@ module DoubleEntry::Reporting
117
117
  context 'and the date is "2009-11-23"' do
118
118
  before { Timecop.freeze(Time.new(2009, 11, 23)) }
119
119
 
120
- it 'takes no notice of start and finish' do
120
+ it 'takes notice of start and finish' do
121
121
  should eq [
122
122
  YearRange.from_time(Time.new(2007)),
123
123
  YearRange.from_time(Time.new(2008)),
124
- YearRange.from_time(Time.new(2009)),
125
124
  ]
126
125
  end
127
126
 
@@ -215,11 +215,6 @@ describe DoubleEntry do
215
215
  expect(debit_line).to_not be_credit
216
216
  end
217
217
 
218
- it 'can describe itself' do
219
- expect(credit_line.description).to eq 'Money goes out: $-10.00'
220
- expect(debit_line.description).to eq 'Money goes in: $10.00'
221
- end
222
-
223
218
  it 'can reference its partner' do
224
219
  expect(credit_line.partner).to eq debit_line
225
220
  expect(debit_line.partner).to eq credit_line
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: double_entry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -16,7 +16,7 @@ authors:
16
16
  autorequire:
17
17
  bindir: bin
18
18
  cert_chain: []
19
- date: 2014-06-28 00:00:00.000000000 Z
19
+ date: 2014-07-11 00:00:00.000000000 Z
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
22
22
  name: money
@@ -272,8 +272,10 @@ files:
272
272
  - lib/double_entry.rb
273
273
  - lib/double_entry/account.rb
274
274
  - lib/double_entry/account_balance.rb
275
+ - lib/double_entry/balance_calculator.rb
275
276
  - lib/double_entry/configurable.rb
276
277
  - lib/double_entry/configuration.rb
278
+ - lib/double_entry/errors.rb
277
279
  - lib/double_entry/line.rb
278
280
  - lib/double_entry/locking.rb
279
281
  - lib/double_entry/reporting.rb
@@ -298,6 +300,7 @@ files:
298
300
  - spec/active_record/locking_extensions_spec.rb
299
301
  - spec/double_entry/account_balance_spec.rb
300
302
  - spec/double_entry/account_spec.rb
303
+ - spec/double_entry/balance_calculator_spec.rb
301
304
  - spec/double_entry/configuration_spec.rb
302
305
  - spec/double_entry/line_spec.rb
303
306
  - spec/double_entry/locking_spec.rb
@@ -349,6 +352,7 @@ test_files:
349
352
  - spec/active_record/locking_extensions_spec.rb
350
353
  - spec/double_entry/account_balance_spec.rb
351
354
  - spec/double_entry/account_spec.rb
355
+ - spec/double_entry/balance_calculator_spec.rb
352
356
  - spec/double_entry/configuration_spec.rb
353
357
  - spec/double_entry/line_spec.rb
354
358
  - spec/double_entry/locking_spec.rb