double_entry 1.0.1 → 2.0.0.beta1

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.
Files changed (67) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +36 -9
  4. data/double_entry.gemspec +20 -48
  5. data/lib/active_record/locking_extensions.rb +3 -3
  6. data/lib/active_record/locking_extensions/log_subscriber.rb +1 -1
  7. data/lib/double_entry/account.rb +38 -45
  8. data/lib/double_entry/account_balance.rb +18 -1
  9. data/lib/double_entry/errors.rb +13 -13
  10. data/lib/double_entry/line.rb +3 -2
  11. data/lib/double_entry/reporting.rb +26 -38
  12. data/lib/double_entry/reporting/aggregate.rb +43 -23
  13. data/lib/double_entry/reporting/aggregate_array.rb +16 -13
  14. data/lib/double_entry/reporting/line_aggregate.rb +3 -2
  15. data/lib/double_entry/reporting/line_aggregate_filter.rb +8 -10
  16. data/lib/double_entry/reporting/line_metadata_filter.rb +33 -0
  17. data/lib/double_entry/transfer.rb +33 -27
  18. data/lib/double_entry/validation.rb +1 -0
  19. data/lib/double_entry/validation/account_fixer.rb +36 -0
  20. data/lib/double_entry/validation/line_check.rb +22 -40
  21. data/lib/double_entry/version.rb +1 -1
  22. data/lib/generators/double_entry/install/install_generator.rb +7 -1
  23. data/lib/generators/double_entry/install/templates/migration.rb +27 -25
  24. metadata +33 -243
  25. data/.gitignore +0 -32
  26. data/.rspec +0 -2
  27. data/.travis.yml +0 -29
  28. data/.yardopts +0 -2
  29. data/Gemfile +0 -2
  30. data/Rakefile +0 -15
  31. data/script/jack_hammer +0 -210
  32. data/script/setup.sh +0 -8
  33. data/spec/active_record/locking_extensions_spec.rb +0 -110
  34. data/spec/double_entry/account_balance_spec.rb +0 -7
  35. data/spec/double_entry/account_spec.rb +0 -130
  36. data/spec/double_entry/balance_calculator_spec.rb +0 -88
  37. data/spec/double_entry/configuration_spec.rb +0 -50
  38. data/spec/double_entry/line_spec.rb +0 -80
  39. data/spec/double_entry/locking_spec.rb +0 -214
  40. data/spec/double_entry/performance/double_entry_performance_spec.rb +0 -32
  41. data/spec/double_entry/performance/reporting/aggregate_performance_spec.rb +0 -50
  42. data/spec/double_entry/reporting/aggregate_array_spec.rb +0 -123
  43. data/spec/double_entry/reporting/aggregate_spec.rb +0 -205
  44. data/spec/double_entry/reporting/line_aggregate_filter_spec.rb +0 -90
  45. data/spec/double_entry/reporting/line_aggregate_spec.rb +0 -39
  46. data/spec/double_entry/reporting/month_range_spec.rb +0 -139
  47. data/spec/double_entry/reporting/time_range_array_spec.rb +0 -169
  48. data/spec/double_entry/reporting/time_range_spec.rb +0 -45
  49. data/spec/double_entry/reporting/week_range_spec.rb +0 -103
  50. data/spec/double_entry/reporting_spec.rb +0 -181
  51. data/spec/double_entry/transfer_spec.rb +0 -93
  52. data/spec/double_entry/validation/line_check_spec.rb +0 -99
  53. data/spec/double_entry_spec.rb +0 -428
  54. data/spec/generators/double_entry/install/install_generator_spec.rb +0 -30
  55. data/spec/spec_helper.rb +0 -118
  56. data/spec/support/accounts.rb +0 -21
  57. data/spec/support/blueprints.rb +0 -43
  58. data/spec/support/database.example.yml +0 -21
  59. data/spec/support/database.travis.yml +0 -24
  60. data/spec/support/double_entry_spec_helper.rb +0 -27
  61. data/spec/support/gemfiles/Gemfile.rails-3.2.x +0 -8
  62. data/spec/support/gemfiles/Gemfile.rails-4.1.x +0 -6
  63. data/spec/support/gemfiles/Gemfile.rails-4.2.x +0 -5
  64. data/spec/support/gemfiles/Gemfile.rails-5.0.x +0 -5
  65. data/spec/support/performance_helper.rb +0 -26
  66. data/spec/support/reporting_configuration.rb +0 -6
  67. data/spec/support/schema.rb +0 -74
@@ -2,21 +2,29 @@
2
2
  module DoubleEntry
3
3
  module Reporting
4
4
  class Aggregate
5
- attr_reader :function, :account, :code, :range, :filter, :currency
5
+ attr_reader :function, :account, :partner_account, :code, :range, :filter, :currency
6
6
 
7
- def self.formatted_amount(function, account, code, range, options = {})
8
- new(function, account, code, range, options).formatted_amount
7
+ def self.formatted_amount(function:, account:, code:, range:, partner_account: nil, filter: nil)
8
+ new(
9
+ function: function,
10
+ account: account,
11
+ code: code,
12
+ range: range,
13
+ partner_account: partner_account,
14
+ filter: filter,
15
+ ).formatted_amount
9
16
  end
10
17
 
11
- def initialize(function, account, code, range, options = {})
18
+ def initialize(function:, account:, code:, range:, partner_account: nil, filter: nil)
12
19
  @function = function.to_s
13
20
  fail AggregateFunctionNotSupported unless %w(sum count average).include?(@function)
14
21
 
15
- @account = account
16
- @code = code ? code.to_s : nil
17
- @range = range
18
- @filter = options[:filter]
19
- @currency = DoubleEntry::Account.currency(account)
22
+ @account = account
23
+ @code = code.try(:to_s)
24
+ @range = range
25
+ @partner_account = partner_account
26
+ @filter = filter
27
+ @currency = DoubleEntry::Account.currency(account)
20
28
  end
21
29
 
22
30
  def amount(force_recalculation = false)
@@ -52,7 +60,14 @@ module DoubleEntry
52
60
  if range.class == YearRange
53
61
  aggregate = calculate_yearly_aggregate
54
62
  else
55
- aggregate = LineAggregate.aggregate(function, account, code, range, filter)
63
+ aggregate = LineAggregate.aggregate(
64
+ function: function,
65
+ account: account,
66
+ partner_account: partner_account,
67
+ code: code,
68
+ range: range,
69
+ named_scopes: filter
70
+ )
56
71
  end
57
72
 
58
73
  if range_is_complete?
@@ -73,7 +88,9 @@ module DoubleEntry
73
88
  calculate_yearly_average
74
89
  else
75
90
  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
91
+ total + Aggregate.new(function: function, account: account, code: code,
92
+ range: MonthRange.new(:year => range.year, :month => month),
93
+ partner_account: partner_account, filter: filter).formatted_amount
77
94
  end
78
95
  result.is_a?(Money) ? result.cents : result
79
96
  end
@@ -82,8 +99,10 @@ module DoubleEntry
82
99
  def calculate_yearly_average
83
100
  # need this seperate function, because an average of averages is not the correct average
84
101
  year_range = YearRange.new(:year => range.year)
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
102
+ sum = Aggregate.new(function: :sum, account: account, code: code, range: year_range,
103
+ partner_account: partner_account, filter: filter).formatted_amount
104
+ count = Aggregate.new(function: :count, account: account, code: code, range: year_range,
105
+ partner_account: partner_account, filter: filter).formatted_amount
87
106
  (count == 0) ? 0 : (sum / count).cents
88
107
  end
89
108
 
@@ -93,16 +112,17 @@ module DoubleEntry
93
112
 
94
113
  def field_hash
95
114
  {
96
- :function => function,
97
- :account => account,
98
- :code => code,
99
- :year => range.year,
100
- :month => range.month,
101
- :week => range.week,
102
- :day => range.day,
103
- :hour => range.hour,
104
- :filter => filter.inspect,
105
- :range_type => range.range_type.to_s,
115
+ :function => function,
116
+ :account => account,
117
+ :partner_account => partner_account,
118
+ :code => code,
119
+ :year => range.year,
120
+ :month => range.month,
121
+ :week => range.week,
122
+ :day => range.day,
123
+ :hour => range.hour,
124
+ :filter => filter.inspect,
125
+ :range_type => range.range_type.to_s,
106
126
  }
107
127
  end
108
128
  end
@@ -9,17 +9,18 @@ module DoubleEntry
9
9
  #
10
10
  # For example, you could request all sales
11
11
  # broken down by month and it would return an array of values
12
- attr_reader :function, :account, :code, :filter, :range_type, :start, :finish, :currency
12
+ attr_reader :function, :account, :partner_account, :code, :filter, :range_type, :start, :finish, :currency
13
13
 
14
- def initialize(function, account, code, options)
15
- @function = function.to_s
16
- @account = account
17
- @code = code
18
- @filter = options[:filter]
19
- @range_type = options[:range_type]
20
- @start = options[:start]
21
- @finish = options[:finish]
22
- @currency = DoubleEntry::Account.currency(account)
14
+ def initialize(function:, account:, code:, partner_account: nil, filter: nil, range_type: nil, start: nil, finish: nil)
15
+ @function = function.to_s
16
+ @account = account
17
+ @code = code
18
+ @partner_account = partner_account
19
+ @filter = filter
20
+ @range_type = range_type
21
+ @start = start
22
+ @finish = finish
23
+ @currency = DoubleEntry::Account.currency(account)
23
24
 
24
25
  retrieve_aggregates
25
26
  fill_in_missing_aggregates
@@ -39,7 +40,8 @@ module DoubleEntry
39
40
  # (this includes aggregates for the still-running period)
40
41
  all_periods.each do |period|
41
42
  unless @aggregates[period.key]
42
- @aggregates[period.key] = Aggregate.formatted_amount(function, account, code, period, :filter => filter)
43
+ @aggregates[period.key] = Aggregate.formatted_amount(function: function, account: account, code: code,
44
+ range: period, partner_account: partner_account, filter: filter)
43
45
  end
44
46
  end
45
47
  end
@@ -50,8 +52,9 @@ module DoubleEntry
50
52
  scope = LineAggregate.
51
53
  where(:function => function).
52
54
  where(:range_type => 'normal').
53
- where(:account => account.to_s).
54
- where(:code => code.to_s).
55
+ where(:account => account.try(:to_s)).
56
+ where(:partner_account => partner_account.try(:to_s)).
57
+ where(:code => code.try(:to_s)).
55
58
  where(:filter => filter.inspect).
56
59
  where(LineAggregate.arel_table[range_type].not_eq(nil))
57
60
  @aggregates = scope.each_with_object({}) do |result, hash|
@@ -2,8 +2,9 @@
2
2
  module DoubleEntry
3
3
  module Reporting
4
4
  class LineAggregate < ActiveRecord::Base
5
- def self.aggregate(function, account, code, range, named_scopes)
6
- collection_filter = LineAggregateFilter.new(account, code, range, named_scopes)
5
+ def self.aggregate(function:, account:, partner_account:, code:, range:, named_scopes:)
6
+ collection_filter = LineAggregateFilter.new(account: account, partner_account: partner_account,
7
+ code: code, range: range, filter_criteria: named_scopes)
7
8
  collection = collection_filter.filter
8
9
  collection.send(function, :amount)
9
10
  end
@@ -2,8 +2,10 @@
2
2
  module DoubleEntry
3
3
  module Reporting
4
4
  class LineAggregateFilter
5
- def initialize(account, code, range, filter_criteria)
5
+
6
+ def initialize(account:, partner_account:, code:, range:, filter_criteria:)
6
7
  @account = account
8
+ @partner_account = partner_account
7
9
  @code = code
8
10
  @range = range
9
11
  @filter_criteria = filter_criteria || []
@@ -20,6 +22,7 @@ module DoubleEntry
20
22
  where(:account => @account).
21
23
  where(:created_at => @range.start..@range.finish)
22
24
  collection = collection.where(:code => @code) if @code
25
+ collection = collection.where(:partner_account => @partner_account) if @partner_account
23
26
 
24
27
  collection
25
28
  end
@@ -42,10 +45,11 @@ module DoubleEntry
42
45
  # :name => :ten_dollar_purchases
43
46
  # }
44
47
  # },
45
- # # an example of providing a single metadatum criteria to filter on
48
+ # # an example of providing multiple metadatum criteria to filter on
46
49
  # {
47
50
  # :metadata => {
48
- # :meme => :business_cat
51
+ # :meme => :business_cat,
52
+ # :category => :fun_times,
49
53
  # }
50
54
  # }
51
55
  # ]
@@ -66,13 +70,7 @@ module DoubleEntry
66
70
  end
67
71
 
68
72
  def filter_by_metadata(collection, metadata)
69
- metadata.reduce(collection.joins(:metadata)) do |filtered_collection, (key, value)|
70
- filtered_collection.where(metadata_table => { :key => key, :value => value })
71
- end
72
- end
73
-
74
- def metadata_table
75
- DoubleEntry::LineMetadata.table_name.to_sym
73
+ DoubleEntry::Reporting::LineMetadataFilter.filter(collection: collection, metadata: metadata)
76
74
  end
77
75
  end
78
76
  end
@@ -0,0 +1,33 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class LineMetadataFilter
5
+
6
+ def self.filter(collection:, metadata:)
7
+ table_alias_index = 0
8
+
9
+ metadata.reduce(collection) do |filtered_collection, (key, value)|
10
+ table_alias = "m#{table_alias_index}"
11
+ table_alias_index += 1
12
+
13
+ filtered_collection.
14
+ joins("INNER JOIN #{line_metadata_table} as #{table_alias} ON #{table_alias}.line_id = #{lines_table}.id").
15
+ where("#{table_alias}.key = ? AND #{table_alias}.value = ?", key, value)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def self.line_metadata_table
22
+ DoubleEntry::LineMetadata.table_name
23
+ end
24
+ private_class_method :line_metadata_table
25
+
26
+ def self.lines_table
27
+ DoubleEntry::Line.table_name
28
+ end
29
+ private_class_method :lines_table
30
+
31
+ end
32
+ end
33
+ end
@@ -1,19 +1,17 @@
1
1
  # encoding: utf-8
2
+ require 'forwardable'
3
+
2
4
  module DoubleEntry
3
5
  class Transfer
4
6
  class << self
5
- attr_writer :transfers, :code_max_length
7
+ attr_accessor :code_max_length
8
+ attr_writer :transfers
6
9
 
7
10
  # @api private
8
11
  def transfers
9
12
  @transfers ||= Set.new
10
13
  end
11
14
 
12
- # @api private
13
- def code_max_length
14
- @code_max_length ||= 47
15
- end
16
-
17
15
  # @api private
18
16
  def transfer(amount, options = {})
19
17
  fail TransferIsNegative if amount.negative?
@@ -25,37 +23,43 @@ module DoubleEntry
25
23
  end
26
24
 
27
25
  # @api private
28
- class Set < Array
26
+ class Set
27
+ extend Forwardable
28
+ delegate [:each, :map] => :all
29
+
29
30
  def define(attributes)
30
- self << Transfer.new(attributes)
31
+ Transfer.new(attributes).tap do |transfer|
32
+ key = [transfer.from, transfer.to, transfer.code]
33
+ if _find(*key)
34
+ fail DuplicateTransfer
35
+ else
36
+ backing_collection[key] = transfer
37
+ end
38
+ end
31
39
  end
32
40
 
33
- def find(from, to, code)
34
- _find(from.identifier, to.identifier, code)
41
+ def find(from_account, to_account, code)
42
+ _find(from_account.identifier, to_account.identifier, code)
35
43
  end
36
44
 
37
- def find!(from, to, code)
38
- find(from, to, code).tap do |transfer|
39
- fail TransferNotAllowed, [from.identifier, to.identifier, code].inspect unless transfer
45
+ def find!(from_account, to_account, code)
46
+ find(from_account, to_account, code).tap do |transfer|
47
+ fail TransferNotAllowed, [from_account.identifier, to_account.identifier, code].inspect unless transfer
40
48
  end
41
49
  end
42
50
 
43
- def <<(transfer)
44
- if _find(transfer.from, transfer.to, transfer.code)
45
- fail DuplicateTransfer
46
- else
47
- super(transfer)
48
- end
51
+ def all
52
+ backing_collection.values
49
53
  end
50
54
 
51
55
  private
52
56
 
57
+ def backing_collection
58
+ @backing_collection ||= Hash.new
59
+ end
60
+
53
61
  def _find(from, to, code)
54
- detect do |transfer|
55
- transfer.from == from &&
56
- transfer.to == to &&
57
- transfer.code == code
58
- end
62
+ backing_collection[[from, to, code]]
59
63
  end
60
64
  end
61
65
 
@@ -65,7 +69,7 @@ module DoubleEntry
65
69
  @code = attributes[:code]
66
70
  @from = attributes[:from]
67
71
  @to = attributes[:to]
68
- if code.length > Transfer.code_max_length
72
+ if Transfer.code_max_length && code.length > Transfer.code_max_length
69
73
  fail TransferCodeTooLongError,
70
74
  "transfer code '#{code}' is too long. Please limit it to #{Transfer.code_max_length} characters."
71
75
  end
@@ -115,8 +119,10 @@ module DoubleEntry
115
119
 
116
120
  def create_line_metadata(credit, debit, metadata)
117
121
  metadata.each_pair do |key, value|
118
- LineMetadata.create!(:line => credit, :key => key, :value => value)
119
- LineMetadata.create!(:line => debit, :key => key, :value => value)
122
+ Array(value).each do |each_value|
123
+ LineMetadata.create!(:line => credit, :key => key, :value => each_value)
124
+ LineMetadata.create!(:line => debit, :key => key, :value => each_value)
125
+ end
120
126
  end
121
127
  end
122
128
  end
@@ -1 +1,2 @@
1
+ require 'double_entry/validation/account_fixer'
1
2
  require 'double_entry/validation/line_check'
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DoubleEntry
4
+ module Validation
5
+ class AccountFixer
6
+ def recalculate_account(account)
7
+ DoubleEntry.lock_accounts(account) do
8
+ recalculated_balance = Money.zero(account.currency)
9
+
10
+ lines_for_account(account).each do |line|
11
+ recalculated_balance += line.amount
12
+ if line.balance != recalculated_balance
13
+ line.update_attribute(:balance, recalculated_balance)
14
+ end
15
+ end
16
+
17
+ update_balance_for_account(account, recalculated_balance)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def lines_for_account(account)
24
+ Line.where(
25
+ account: account.identifier.to_s,
26
+ scope: account.scope_identity.to_s
27
+ ).order(:id)
28
+ end
29
+
30
+ def update_balance_for_account(account, balance)
31
+ account_balance = Locking.balance_for_locked_account(account)
32
+ account_balance.update_attribute(:balance, balance)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -4,13 +4,16 @@ require 'set'
4
4
  module DoubleEntry
5
5
  module Validation
6
6
  class LineCheck < ActiveRecord::Base
7
- default_scope -> { order('created_at') }
8
7
 
9
- def self.perform!
10
- new.perform
8
+ def self.last_line_id_checked
9
+ order('created_at DESC').limit(1).pluck(:last_line_id).first || 0
11
10
  end
12
11
 
13
- def perform
12
+ def self.perform!(fixer: nil)
13
+ new.perform(fixer: fixer)
14
+ end
15
+
16
+ def perform(fixer: nil)
14
17
  log = ''
15
18
  current_line_id = nil
16
19
 
@@ -24,10 +27,10 @@ module DoubleEntry
24
27
  end
25
28
 
26
29
  active_accounts.each do |account|
27
- incorrect_accounts << account unless cached_balance_correct?(account)
30
+ incorrect_accounts << account unless cached_balance_correct?(account, log)
28
31
  end
29
32
 
30
- incorrect_accounts.each { |account| recalculate_account(account) }
33
+ incorrect_accounts.each(&fixer.method(:recalculate_account)) if fixer
31
34
 
32
35
  unless active_accounts.empty?
33
36
  LineCheck.create!(
@@ -40,13 +43,8 @@ module DoubleEntry
40
43
 
41
44
  private
42
45
 
43
- def last_run_line_id
44
- latest = LineCheck.last
45
- latest ? latest.last_line_id : 0
46
- end
47
-
48
46
  def new_lines_since_last_run
49
- Line.where('id > ?', last_run_line_id)
47
+ Line.with_id_greater_than(LineCheck.last_line_id_checked)
50
48
  end
51
49
 
52
50
  def running_balance_correct?(line, log)
@@ -88,39 +86,23 @@ module DoubleEntry
88
86
  #{previous_line.inspect}
89
87
  #{line.inspect}
90
88
 
91
- END_OF_MESSAGE
92
- end
93
-
94
- def cached_balance_correct?(account)
95
- DoubleEntry.lock_accounts(account) do
96
- return AccountBalance.find_by_account(account).balance == account.balance
97
- end
89
+ END_OF_MESSAGE
98
90
  end
99
91
 
100
- def recalculate_account(account)
92
+ def cached_balance_correct?(account, log)
101
93
  DoubleEntry.lock_accounts(account) do
102
- recalculated_balance = Money.zero(account.currency)
103
-
104
- lines_for_account(account).each do |line|
105
- recalculated_balance += line.amount
106
- line.update_attribute(:balance, recalculated_balance) if line.balance != recalculated_balance
107
- end
108
-
109
- update_balance_for_account(account, recalculated_balance)
94
+ cached_balance = AccountBalance.find_by_account(account).balance
95
+ running_balance = account.balance
96
+ correct = (cached_balance == running_balance)
97
+ log << <<~MESSAGE unless correct
98
+ *********************************
99
+ Error on account #{account}: #{cached_balance} (cached balance) != #{running_balance} (running balance)
100
+ *********************************
101
+
102
+ MESSAGE
103
+ return correct
110
104
  end
111
105
  end
112
-
113
- def lines_for_account(account)
114
- Line.where(
115
- :account => account.identifier.to_s,
116
- :scope => account.scope_identity.to_s,
117
- ).order(:id)
118
- end
119
-
120
- def update_balance_for_account(account, balance)
121
- account_balance = Locking.balance_for_locked_account(account)
122
- account_balance.update_attribute(:balance, balance)
123
- end
124
106
  end
125
107
  end
126
108
  end