double_entry 0.10.3 → 1.0.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.
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