double_entry 0.10.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 54feac773a636f4df4556412c09a80698172e6ca
4
- data.tar.gz: 7373d84edcb61edab5d4bedc3da3792a6d3ef01e
3
+ metadata.gz: 2de3c5bcd2513815b3240072a87abed07c054176
4
+ data.tar.gz: 441671942df5faff21b7db426075a8dd26822fbc
5
5
  SHA512:
6
- metadata.gz: 2d62479172ef59645acda32f45231bc074bc2b46b9688932d3fc7aba336ea7aeb26fe71dfcd3f3e74a0015c30a3f6881f998374ab474214b7e284e87cef0b5a4
7
- data.tar.gz: 8cb7508387deacfcfb836a54fd16986ff6b47efee669082a8be40031e5c12eb3552c7795c11faea3d324debbcaae6ba3b0195992b3ddd5ce6f472d6e552eaf4e
6
+ metadata.gz: 88e504ea0d5504c1ce05e63a01ad5697b1cc045298c51a02b52b8bfafb969e5facf0235e727ba600f83b317483b72c9ce284d3dd6e5ac1227aefb40d339696f9
7
+ data.tar.gz: a669b5f44cc5e955f9c942301748bac1622f58e34082f892b9c12f38dd76aa8d16a85533d8e8e08ab0d2a6440498eba81b93c4cc2311a67538cc8fd5607cd3ae
data/.gitignore CHANGED
@@ -9,6 +9,9 @@
9
9
  /test/version_tmp/
10
10
  /tmp/
11
11
 
12
+ ## Performance profiling
13
+ /profiles/
14
+
12
15
  ## Documentation cache and generated files:
13
16
  /.yardoc/
14
17
  /_yardoc/
@@ -42,6 +42,9 @@ Style/ModuleFunction:
42
42
  Style/ParallelAssignment:
43
43
  Enabled: false
44
44
 
45
+ Style/SingleLineBlockParams:
46
+ Enabled: false
47
+
45
48
  Metrics/AbcSize:
46
49
  Max: 47 #15
47
50
 
@@ -55,7 +58,8 @@ Metrics/MethodLength:
55
58
  Max: 30 # 10
56
59
 
57
60
  Metrics/ModuleLength:
58
- Max: 221 #100
61
+ Exclude:
62
+ - spec/**/*
59
63
 
60
64
  Metrics/PerceivedComplexity:
61
65
  Max: 13 #7
data/README.md CHANGED
@@ -124,6 +124,19 @@ The possible transfers, and their codes, should be defined in the configuration.
124
124
 
125
125
  See **DoubleEntry::Transfer** for more info.
126
126
 
127
+ ### Metadata
128
+
129
+ You may associate arbitrary metadata with transfers, for example:
130
+
131
+ ```ruby
132
+ DoubleEntry.transfer(
133
+ Money.new(20_00),
134
+ :from => one_account,
135
+ :to => another_account,
136
+ :code => :a_business_code_for_this_type_of_transfer,
137
+ :metadata => {:key1 => 'value 1', :key2 => 'value 2'},
138
+ )
139
+ ```
127
140
 
128
141
  ### Locking
129
142
 
@@ -160,6 +173,9 @@ See **DoubleEntry::Line** for more info.
160
173
  AccountBalance records cache the current balance for each Account, and are used
161
174
  to perform database level locking.
162
175
 
176
+ Transfer metadata is stored as key/value pairs associated with both the source and destination lines of the transfer.
177
+ See **DoubleEntry::LineMetadata** for more info.
178
+
163
179
  ## Configuration
164
180
 
165
181
  A configuration file should be used to define a set of accounts, optional scopes on
@@ -17,6 +17,34 @@ Gem::Specification.new do |gem|
17
17
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
18
  gem.require_paths = ['lib']
19
19
 
20
+ gem.post_install_message = <<-'POSTINSTALLMESSAGE'
21
+ Please note the following changes in DoubleEntry:
22
+ - New table `double_entry_line_metadata` has been introduced and is *required* for
23
+ aggregate reporting filtering to work. Existing applications must manually manage
24
+ this change via a migration similar to the following:
25
+
26
+ class CreateDoubleEntryLineMetadata < ActiveRecord::Migration
27
+ def self.up
28
+ create_table "#{DoubleEntry.table_name_prefix}line_metadata", :force => true do |t|
29
+ t.integer "line_id", :null => false
30
+ t.string "key", :limit => 48, :null => false
31
+ t.string "value", :limit => 64, :null => false
32
+ t.timestamps :null => false
33
+ end
34
+
35
+ add_index "#{DoubleEntry.table_name_prefix}line_metadata",
36
+ ["line_id", "key", "value"],
37
+ :name => "lines_meta_line_id_key_value_idx"
38
+ end
39
+
40
+ def self.down
41
+ drop_table "#{DoubleEntry.table_name_prefix}line_metadata"
42
+ end
43
+ end
44
+
45
+ Please ensure that you update your database accordingly.
46
+ POSTINSTALLMESSAGE
47
+
20
48
  gem.add_dependency 'money', '>= 6.0.0'
21
49
  gem.add_dependency 'activerecord', '>= 3.2.0'
22
50
  gem.add_dependency 'activesupport', '>= 3.2.0'
@@ -35,4 +63,11 @@ Gem::Specification.new do |gem|
35
63
  gem.add_development_dependency 'machinist'
36
64
  gem.add_development_dependency 'timecop'
37
65
  gem.add_development_dependency 'rubocop', '~> 0.32.0'
66
+
67
+ gem.add_development_dependency 'pry'
68
+ gem.add_development_dependency 'pry-doc'
69
+ gem.add_development_dependency 'pry-byebug' if RUBY_VERSION >= '2.0.0'
70
+ gem.add_development_dependency 'pry-stack_explorer'
71
+ gem.add_development_dependency 'awesome_print'
72
+ gem.add_development_dependency 'ruby-prof'
38
73
  end
@@ -15,6 +15,7 @@ require 'double_entry/balance_calculator'
15
15
  require 'double_entry/locking'
16
16
  require 'double_entry/transfer'
17
17
  require 'double_entry/line'
18
+ require 'double_entry/line_metadata'
18
19
  require 'double_entry/reporting'
19
20
  require 'double_entry/validation'
20
21
 
@@ -56,6 +56,7 @@ module DoubleEntry
56
56
  #
57
57
  class Line < ActiveRecord::Base
58
58
  belongs_to :detail, :polymorphic => true
59
+ has_many :metadata, :class_name => 'DoubleEntry::LineMetadata'
59
60
 
60
61
  def amount
61
62
  self[:amount] && Money.new(self[:amount], currency)
@@ -0,0 +1,18 @@
1
+ module DoubleEntry
2
+ class LineMetadata < ActiveRecord::Base
3
+ class SymbolWrapper
4
+ def self.load(string)
5
+ return unless string
6
+ string.to_sym
7
+ end
8
+
9
+ def self.dump(symbol)
10
+ return unless symbol
11
+ symbol.to_s
12
+ end
13
+ end
14
+
15
+ belongs_to :line
16
+ serialize :key, SymbolWrapper
17
+ end
18
+ end
@@ -41,6 +41,13 @@ module DoubleEntry
41
41
  else
42
42
  lock.perform_lock(&Proc.new)
43
43
  end
44
+
45
+ rescue ActiveRecord::StatementInvalid => exception
46
+ if exception.message =~ /lock wait timeout/i
47
+ raise LockWaitTimeout
48
+ else
49
+ raise
50
+ end
44
51
  end
45
52
 
46
53
  # Return the account balance record for the given account name if there's a
@@ -177,5 +184,9 @@ module DoubleEntry
177
184
  # Raised if things go horribly, horribly wrong. This should never happen.
178
185
  class LockDisaster < RuntimeError
179
186
  end
187
+
188
+ # Raised if waiting for locks times out.
189
+ class LockWaitTimeout < RuntimeError
190
+ end
180
191
  end
181
192
  end
@@ -8,6 +8,7 @@ require 'double_entry/reporting/week_range'
8
8
  require 'double_entry/reporting/month_range'
9
9
  require 'double_entry/reporting/year_range'
10
10
  require 'double_entry/reporting/line_aggregate'
11
+ require 'double_entry/reporting/line_aggregate_filter'
11
12
  require 'double_entry/reporting/time_range_array'
12
13
 
13
14
  module DoubleEntry
@@ -32,19 +33,27 @@ module DoubleEntry
32
33
  # The transfers included in the calculation can be limited by time range
33
34
  # and provided custom filters.
34
35
  #
35
- # @example Find the sum for all $10 :save transfers in all :checking accounts in the current month (assume the date is January 30, 2014).
36
+ # @example Find the sum for all $10 :save transfers in all :checking accounts in the current month, made by Australian users (assume the date is January 30, 2014).
36
37
  # time_range = DoubleEntry::Reporting::TimeRange.make(2014, 1)
37
38
  #
38
39
  # DoubleEntry::Line.class_eval do
39
- # scope :ten_dollar_transfers, -> { where(:amount => 10_00) }
40
+ # scope :specific_transfer_amount, ->(amount) { where(:amount => amount.fractional) }
40
41
  # end
41
42
  #
42
43
  # DoubleEntry::Reporting.aggregate(
43
44
  # :sum,
44
45
  # :checking,
45
46
  # :save,
46
- # :range => time_range,
47
- # :filter => [ :ten_dollar_transfers ],
47
+ # time_range,
48
+ # :filter => [
49
+ # :scope => {
50
+ # :name => :specific_transfer_amount,
51
+ # :arguments => [Money.new(10_00)]
52
+ # },
53
+ # :metadata => {
54
+ # :user_location => 'AU'
55
+ # },
56
+ # ]
48
57
  # )
49
58
  # @param [Symbol] function The function to perform on the set of transfers.
50
59
  # Valid functions are :sum, :count, and :average
@@ -53,21 +62,24 @@ module DoubleEntry
53
62
  # @param [Symbol] code The application specific code for the type of
54
63
  # transfer to perform an aggregate calculation on. As specified in the
55
64
  # transfer configuration.
56
- # @option options :range [DoubleEntry::Reporting::TimeRange] Only include
57
- # transfers in the given time range in the calculation.
58
- # @option options :filter [Array<Symbol>, Array<Hash<Symbol, Object>>]
59
- # A custom filter to apply before performing the aggregate calculation.
60
- # Currently, filters must be monkey patched as scopes into the
61
- # DoubleEntry::Line class in order to be used as filters, as the example
62
- # shows. If the filter requires a parameter, it must be given in a Hash,
63
- # otherwise pass an array with the symbol names for the defined scopes.
65
+ # @param [DoubleEntry::Reporting::TimeRange] Only include transfers in the
66
+ # given time range in the calculation.
67
+ # @option options :filter [Array<Hash<Symbol,Hash<Symbol,Object>>>]
68
+ # An array of custom filter to apply before performing the aggregate
69
+ # calculation. Filters can be either scope filters, where the name must be
70
+ # specified, or they can be metadata filters, where the key/value pair to
71
+ # match on must be specified.
72
+ # Scope filters must be monkey patched as scopes into the DoubleEntry::Line
73
+ # class, as the example above shows. Scope filters may also take a list of
74
+ # arguments to pass into the monkey patched scope, and, if provided, must
75
+ # be contained within an array.
64
76
  # @return [Money, Fixnum] Returns a Money object for :sum and :average
65
77
  # calculations, or a Fixnum for :count calculations.
66
78
  # @raise [Reporting::AggregateFunctionNotSupported] The provided function
67
79
  # is not supported.
68
80
  #
69
- def aggregate(function, account, code, options = {})
70
- Aggregate.new(function, account, code, options).formatted_amount
81
+ def aggregate(function, account, code, range, options = {})
82
+ Aggregate.formatted_amount(function, account, code, range, options)
71
83
  end
72
84
 
73
85
  # Perform an aggregate calculation on a set of transfers for an account
@@ -2,16 +2,19 @@
2
2
  module DoubleEntry
3
3
  module Reporting
4
4
  class Aggregate
5
- attr_reader :function, :account, :code, :range, :options, :filter, :currency
5
+ attr_reader :function, :account, :code, :range, :filter, :currency
6
6
 
7
- def initialize(function, account, code, options)
7
+ def self.formatted_amount(function, account, code, range, options = {})
8
+ new(function, account, code, range, options).formatted_amount
9
+ end
10
+
11
+ def initialize(function, account, code, range, options = {})
8
12
  @function = function.to_s
9
13
  fail AggregateFunctionNotSupported unless %w(sum count average).include?(@function)
10
14
 
11
15
  @account = account
12
16
  @code = code ? code.to_s : nil
13
- @options = options
14
- @range = options[:range]
17
+ @range = range
15
18
  @filter = options[:filter]
16
19
  @currency = DoubleEntry::Account.currency(account)
17
20
  end
@@ -69,15 +72,8 @@ module DoubleEntry
69
72
  if function == 'average'
70
73
  calculate_yearly_average
71
74
  else
72
- zero = formatted_amount(0)
73
- result = (1..12).inject(zero) do |total, month|
74
- total + Reporting.aggregate(
75
- function,
76
- account,
77
- code,
78
- :range => MonthRange.new(:year => range.year, :month => month),
79
- :filter => filter,
80
- )
75
+ result = (1..12).inject(formatted_amount(0)) do |total, month|
76
+ total + Aggregate.new(function, account, code, MonthRange.new(:year => range.year, :month => month), :filter => filter).formatted_amount
81
77
  end
82
78
  result.is_a?(Money) ? result.cents : result
83
79
  end
@@ -86,8 +82,8 @@ module DoubleEntry
86
82
  def calculate_yearly_average
87
83
  # need this seperate function, because an average of averages is not the correct average
88
84
  year_range = YearRange.new(:year => range.year)
89
- sum = Reporting.aggregate(:sum, account, code, :range => year_range, :filter => filter)
90
- count = Reporting.aggregate(:count, account, code, :range => year_range, :filter => filter)
85
+ sum = Aggregate.new(:sum, account, code, year_range, :filter => filter).formatted_amount
86
+ count = Aggregate.new(:count, account, code, year_range, :filter => filter).formatted_amount
91
87
  (count == 0) ? 0 : (sum / count).cents
92
88
  end
93
89
 
@@ -39,7 +39,7 @@ module DoubleEntry
39
39
  # (this includes aggregates for the still-running period)
40
40
  all_periods.each do |period|
41
41
  unless @aggregates[period.key]
42
- @aggregates[period.key] = Reporting.aggregate(function, account, code, :filter => filter, :range => period)
42
+ @aggregates[period.key] = Aggregate.formatted_amount(function, account, code, period, :filter => filter)
43
43
  end
44
44
  end
45
45
  end
@@ -3,32 +3,11 @@ module DoubleEntry
3
3
  module Reporting
4
4
  class LineAggregate < ActiveRecord::Base
5
5
  def self.aggregate(function, account, code, range, named_scopes)
6
- collection = aggregate_collection(named_scopes)
7
- collection = collection.where(:account => account)
8
- collection = collection.where(:created_at => range.start..range.finish)
9
- collection = collection.where(:code => code) if code
6
+ collection_filter = LineAggregateFilter.new(account, code, range, named_scopes)
7
+ collection = collection_filter.filter
10
8
  collection.send(function, :amount)
11
9
  end
12
10
 
13
- # a lot of the trickier reports will use filters defined
14
- # in named_scopes to bring in data from other tables.
15
- def self.aggregate_collection(named_scopes)
16
- if named_scopes
17
- collection = DoubleEntry::Line
18
- named_scopes.each do |named_scope|
19
- if named_scope.is_a?(Hash)
20
- method_name = named_scope.keys[0]
21
- collection = collection.send(method_name, named_scope[method_name])
22
- else
23
- collection = collection.send(named_scope)
24
- end
25
- end
26
- collection
27
- else
28
- DoubleEntry::Line
29
- end
30
- end
31
-
32
11
  def key
33
12
  "#{year}:#{month}:#{week}:#{day}:#{hour}"
34
13
  end
@@ -0,0 +1,81 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class LineAggregateFilter
5
+ def initialize(account, code, range, filter_criteria)
6
+ @account = account
7
+ @code = code
8
+ @range = range
9
+ @filter_criteria = filter_criteria || []
10
+ end
11
+
12
+ def filter
13
+ @collection ||= apply_filters
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :account, :code, :range, :filter_criteria
19
+
20
+ def apply_filters
21
+ collection = apply_filter_criteria.
22
+ where(:account => account).
23
+ where(:created_at => range.start..range.finish)
24
+ collection = collection.where(:code => code) if code
25
+
26
+ collection
27
+ end
28
+
29
+ # a lot of the trickier reports will use filters defined
30
+ # in filter_criteria to bring in data from other tables.
31
+ # For example:
32
+ #
33
+ # filter_criteria = [
34
+ # # an example of calling a named scope called with arguments
35
+ # {
36
+ # :scope => {
37
+ # :name => :ten_dollar_purchases_by_category,
38
+ # :arguments => [:cat_videos, :cat_pictures]
39
+ # }
40
+ # },
41
+ # # an example of calling a named scope with no arguments
42
+ # {
43
+ # :scope => {
44
+ # :name => :ten_dollar_purchases
45
+ # }
46
+ # },
47
+ # # an example of providing a single metadatum criteria to filter on
48
+ # {
49
+ # :metadata => {
50
+ # :meme => :business_cat
51
+ # }
52
+ # }
53
+ # ]
54
+ def apply_filter_criteria
55
+ filter_criteria.reduce(DoubleEntry::Line) do |collection, filter|
56
+ if filter[:scope].present?
57
+ filter_by_scope(collection, filter[:scope])
58
+ elsif filter[:metadata].present?
59
+ filter_by_metadata(collection, filter[:metadata])
60
+ else
61
+ collection
62
+ end
63
+ end
64
+ end
65
+
66
+ def filter_by_scope(collection, scope)
67
+ collection.public_send(scope[:name], *scope[:arguments])
68
+ end
69
+
70
+ def filter_by_metadata(collection, metadata)
71
+ metadata.reduce(collection.joins(:metadata)) do |filtered_collection, (key, value)|
72
+ filtered_collection.where(metadata_table => { :key => key, :value => value })
73
+ end
74
+ end
75
+
76
+ def metadata_table
77
+ DoubleEntry::LineMetadata.table_name.to_sym
78
+ end
79
+ end
80
+ end
81
+ end
@@ -17,11 +17,10 @@ module DoubleEntry
17
17
  # @api private
18
18
  def transfer(amount, options = {})
19
19
  fail TransferIsNegative if amount < Money.zero
20
- from = options[:from]
21
- to = options[:to]
20
+ from_account = options[:from]
21
+ to_account = options[:to]
22
22
  code = options[:code]
23
- detail = options[:detail]
24
- transfers.find!(from, to, code).process(amount, from, to, code, detail)
23
+ transfers.find!(from_account, to_account, code).process(amount, options)
25
24
  end
26
25
  end
27
26
 
@@ -72,34 +71,52 @@ module DoubleEntry
72
71
  end
73
72
  end
74
73
 
75
- def process(amount, from, to, code, detail)
76
- if from.scope_identity == to.scope_identity && from.identifier == to.identifier
77
- fail TransferNotAllowed, 'from and to are identical'
74
+ def process(amount, options)
75
+ from_account = options[:from]
76
+ to_account = options[:to]
77
+ code = options[:code]
78
+ detail = options[:detail]
79
+ metadata = options[:metadata]
80
+ if from_account.scope_identity == to_account.scope_identity && from_account.identifier == to_account.identifier
81
+ fail TransferNotAllowed, 'from account and to account are identical'
78
82
  end
79
- if to.currency != from.currency
80
- fail MismatchedCurrencies, "Missmatched currency (#{to.currency} <> #{from.currency})"
83
+ if to_account.currency != from_account.currency
84
+ fail MismatchedCurrencies, "Mismatched currency (#{to_account.currency} <> #{from_account.currency})"
81
85
  end
82
- Locking.lock_accounts(from, to) do
83
- credit, debit = Line.new, Line.new
86
+ Locking.lock_accounts(from_account, to_account) do
87
+ credit, debit = create_lines(amount, code, detail, from_account, to_account)
88
+ create_line_metadata(credit, debit, metadata) if metadata
89
+ end
90
+ end
91
+
92
+ def create_lines(amount, code, detail, from_account, to_account)
93
+ credit, debit = Line.new, Line.new
84
94
 
85
- credit_balance = Locking.balance_for_locked_account(from)
86
- debit_balance = Locking.balance_for_locked_account(to)
95
+ credit_balance = Locking.balance_for_locked_account(from_account)
96
+ debit_balance = Locking.balance_for_locked_account(to_account)
87
97
 
88
- credit_balance.update_attribute :balance, credit_balance.balance - amount
89
- debit_balance.update_attribute :balance, debit_balance.balance + amount
98
+ credit_balance.update_attribute :balance, credit_balance.balance - amount
99
+ debit_balance.update_attribute :balance, debit_balance.balance + amount
90
100
 
91
- credit.amount, debit.amount = -amount, amount
92
- credit.account, debit.account = from, to
93
- credit.code, debit.code = code, code
94
- credit.detail, debit.detail = detail, detail
95
- credit.balance, debit.balance = credit_balance.balance, debit_balance.balance
101
+ credit.amount, debit.amount = -amount, amount
102
+ credit.account, debit.account = from_account, to_account
103
+ credit.code, debit.code = code, code
104
+ credit.detail, debit.detail = detail, detail
105
+ credit.balance, debit.balance = credit_balance.balance, debit_balance.balance
96
106
 
97
- credit.partner_account, debit.partner_account = to, from
107
+ credit.partner_account, debit.partner_account = to_account, from_account
108
+
109
+ credit.save!
110
+ debit.partner_id = credit.id
111
+ debit.save!
112
+ credit.update_attribute :partner_id, debit.id
113
+ [credit, debit]
114
+ end
98
115
 
99
- credit.save!
100
- debit.partner_id = credit.id
101
- debit.save!
102
- credit.update_attribute :partner_id, debit.id
116
+ def create_line_metadata(credit, debit, metadata)
117
+ metadata.each_pair do |key, value|
118
+ LineMetadata.create!(:line => credit, :key => key, :value => value)
119
+ LineMetadata.create!(:line => debit, :key => key, :value => value)
103
120
  end
104
121
  end
105
122
  end