double_entry 0.2.0 → 0.3.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 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