event_sourced_accounting 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +23 -0
  3. data/README.markdown +37 -0
  4. data/Rakefile +11 -0
  5. data/app/assets/javascripts/esa/application.js +15 -0
  6. data/app/assets/stylesheets/esa/application.css +13 -0
  7. data/app/assets/stylesheets/esa/main.css.scss +86 -0
  8. data/app/models/esa/account.rb +80 -0
  9. data/app/models/esa/accounts/asset.rb +22 -0
  10. data/app/models/esa/accounts/equity.rb +22 -0
  11. data/app/models/esa/accounts/expense.rb +22 -0
  12. data/app/models/esa/accounts/liability.rb +22 -0
  13. data/app/models/esa/accounts/revenue.rb +22 -0
  14. data/app/models/esa/amount.rb +27 -0
  15. data/app/models/esa/amounts/credit.rb +12 -0
  16. data/app/models/esa/amounts/debit.rb +12 -0
  17. data/app/models/esa/associations/amounts_extension.rb +79 -0
  18. data/app/models/esa/associations/events_extension.rb +33 -0
  19. data/app/models/esa/associations/flags_extension.rb +35 -0
  20. data/app/models/esa/associations/transactions_extension.rb +7 -0
  21. data/app/models/esa/chart.rb +41 -0
  22. data/app/models/esa/context.rb +218 -0
  23. data/app/models/esa/context_provider.rb +52 -0
  24. data/app/models/esa/context_providers/account_context_provider.rb +21 -0
  25. data/app/models/esa/context_providers/accountable_context_provider.rb +22 -0
  26. data/app/models/esa/context_providers/accountable_type_context_provider.rb +21 -0
  27. data/app/models/esa/context_providers/date_context_provider.rb +33 -0
  28. data/app/models/esa/contexts/account_context.rb +26 -0
  29. data/app/models/esa/contexts/accountable_context.rb +26 -0
  30. data/app/models/esa/contexts/accountable_type_context.rb +24 -0
  31. data/app/models/esa/contexts/created_at_context.rb +26 -0
  32. data/app/models/esa/contexts/date_context.rb +71 -0
  33. data/app/models/esa/contexts/empty_context.rb +19 -0
  34. data/app/models/esa/contexts/filter_context.rb +11 -0
  35. data/app/models/esa/contexts/open_close_context.rb +15 -0
  36. data/app/models/esa/event.rb +33 -0
  37. data/app/models/esa/filters/account_filter.rb +42 -0
  38. data/app/models/esa/filters/accountable_filter.rb +58 -0
  39. data/app/models/esa/filters/accountable_type_filter.rb +26 -0
  40. data/app/models/esa/filters/chart_filter.rb +17 -0
  41. data/app/models/esa/filters/context_filter.rb +35 -0
  42. data/app/models/esa/filters/date_time_filter.rb +52 -0
  43. data/app/models/esa/flag.rb +70 -0
  44. data/app/models/esa/ruleset.rb +175 -0
  45. data/app/models/esa/traits/accountable.rb +21 -0
  46. data/app/models/esa/traits/extendable.rb +93 -0
  47. data/app/models/esa/traits/or_scope.rb +35 -0
  48. data/app/models/esa/traits/union_scope.rb +24 -0
  49. data/app/models/esa/transaction.rb +74 -0
  50. data/config/backtrace_silencers.rb +7 -0
  51. data/config/database.yml +5 -0
  52. data/config/inflections.rb +10 -0
  53. data/config/mime_types.rb +5 -0
  54. data/config/routes.rb +6 -0
  55. data/config/secret_token.rb +7 -0
  56. data/config/session_store.rb +8 -0
  57. data/lib/esa/balance_checker.rb +17 -0
  58. data/lib/esa/blocking_processor.rb +158 -0
  59. data/lib/esa/config.rb +55 -0
  60. data/lib/esa/subcontext_checker.rb +15 -0
  61. data/lib/esa/version.rb +3 -0
  62. data/lib/esa.rb +8 -0
  63. data/lib/generators/esa/USAGE +11 -0
  64. data/lib/generators/esa/esa_generator.rb +26 -0
  65. data/lib/generators/esa/templates/migration.rb +142 -0
  66. data/spec/factories/account_factory.rb +51 -0
  67. data/spec/factories/amount_factory.rb +19 -0
  68. data/spec/factories/chart_factory.rb +7 -0
  69. data/spec/factories/transaction_factory.rb +11 -0
  70. data/spec/lib/esa_spec.rb +0 -0
  71. data/spec/models/account_spec.rb +31 -0
  72. data/spec/models/amount_spec.rb +8 -0
  73. data/spec/models/asset_spec.rb +9 -0
  74. data/spec/models/chart_spec.rb +52 -0
  75. data/spec/models/credit_amount_spec.rb +9 -0
  76. data/spec/models/debit_amount_spec.rb +9 -0
  77. data/spec/models/equity_spec.rb +9 -0
  78. data/spec/models/expense_spec.rb +9 -0
  79. data/spec/models/liability_spec.rb +9 -0
  80. data/spec/models/revenue_spec.rb +9 -0
  81. data/spec/models/transaction_spec.rb +118 -0
  82. data/spec/rcov.opts +2 -0
  83. data/spec/spec.opts +4 -0
  84. data/spec/spec_helper.rb +16 -0
  85. data/spec/support/account_shared_examples.rb +57 -0
  86. data/spec/support/amount_shared_examples.rb +21 -0
  87. metadata +306 -0
@@ -0,0 +1,58 @@
1
+ module ESA
2
+ module Filters
3
+ module AccountableFilter
4
+ def self.make_union_query(definitions = {})
5
+ fragments = definitions.map do |type,accountable|
6
+ make_fragments(type, accountable)
7
+ end.flatten
8
+
9
+ if fragments.count > 0
10
+ fragments.join(' UNION ')
11
+ else
12
+ "SELECT -1 AS id, 'Nothing' AS type"
13
+ end
14
+ end
15
+
16
+ def self.make_fragments(type, accountable)
17
+ if accountable.is_a? ActiveRecord::Relation
18
+ [accountable.select("`#{accountable.table_name}`.`#{accountable.primary_key}` AS id, '#{type}' AS type").to_sql.squish]
19
+ elsif accountable.is_a? ActiveRecord::Base
20
+ ["SELECT #{accountable.id} AS id, '#{type}' AS type"]
21
+ elsif accountable.is_a? Integer
22
+ ["SELECT #{accountable} AS id, '#{type}' AS type"]
23
+ elsif accountable.respond_to? :each
24
+ accountable.map{|a| make_fragments(type, a)}.flatten
25
+ else
26
+ []
27
+ end
28
+ end
29
+
30
+ module TransactionAccountable
31
+ extend ActiveSupport::Concern
32
+
33
+ included do
34
+ scope :with_accountable, lambda { |accountable,type|
35
+ joins(:transaction).where(esa_transactions: {accountable_id: accountable, accountable_type: type})
36
+ }
37
+ scope :with_accountable_def, lambda { |definitions| joins(:transaction).joins("INNER JOIN (#{ESA::Filters::AccountableFilter.make_union_query(definitions)}) `accountables-#{(hash ^ definitions.hash).to_s(36)}` ON `esa_transactions`.`accountable_id` = `accountables-#{(hash ^ definitions.hash).to_s(36)}`.`id` AND `esa_transactions`.`accountable_type` = `accountables-#{(hash ^ definitions.hash).to_s(36)}`.`type`") }
38
+ end
39
+ end
40
+
41
+ module ObjectAccountable
42
+ extend ActiveSupport::Concern
43
+
44
+ included do
45
+ scope :with_accountable, lambda { |accountable,type|
46
+ where(accountable_id: accountable, accountable_type: type)
47
+ }
48
+ scope :with_accountable_def, lambda { |definitions| joins("INNER JOIN (#{ESA::Filters::AccountableFilter.make_union_query(definitions)}) `accountables-#{(hash ^ definitions.hash).to_s(36)}` ON `#{table_name}`.`accountable_id` = `accountables-#{(hash ^ definitions.hash).to_s(36)}`.`id` AND `#{table_name}`.`accountable_type` = `accountables-#{(hash ^ definitions.hash).to_s(36)}`.`type`") }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ ESA::Amount.send :include, ESA::Filters::AccountableFilter::TransactionAccountable
56
+ ESA::Event.send :include, ESA::Filters::AccountableFilter::ObjectAccountable
57
+ ESA::Flag.send :include, ESA::Filters::AccountableFilter::ObjectAccountable
58
+ ESA::Transaction.send :include, ESA::Filters::AccountableFilter::ObjectAccountable
@@ -0,0 +1,26 @@
1
+ module ESA
2
+ module Filters
3
+ module AccountableTypeFilter
4
+ module TransactionAccountableType
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ scope :with_accountable_type, lambda { |type| joins(:transaction).where(esa_transactions: {accountable_type: type}) }
9
+ end
10
+ end
11
+
12
+ module ObjectAccountableType
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ scope :with_accountable_type, lambda { |type| where(accountable_type: type) }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ ESA::Amount.send :include, ESA::Filters::AccountableTypeFilter::TransactionAccountableType
24
+ ESA::Event.send :include, ESA::Filters::AccountableTypeFilter::ObjectAccountableType
25
+ ESA::Flag.send :include, ESA::Filters::AccountableTypeFilter::ObjectAccountableType
26
+ ESA::Transaction.send :include, ESA::Filters::AccountableTypeFilter::ObjectAccountableType
@@ -0,0 +1,17 @@
1
+ module ESA
2
+ module Filters
3
+ module ChartFilter
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ scope :with_chart, lambda { |chart| with_account(Account.where(chart_id: chart)) }
8
+ scope :with_chart_name, lambda { |name| with_chart(Chart.where(name: name)) }
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ ESA::Amount.send :include, ESA::Filters::ChartFilter
15
+ ESA::Event.send :include, ESA::Filters::ChartFilter
16
+ ESA::Flag.send :include, ESA::Filters::ChartFilter
17
+ ESA::Transaction.send :include, ESA::Filters::ChartFilter
@@ -0,0 +1,35 @@
1
+ module ESA
2
+ module Filters
3
+ module ContextFilter
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ scope :with_context, lambda { |*contexts|
8
+ contexts.flatten.uniq.
9
+ map do |ctx|
10
+ if ctx.is_a? Integer or ctx.is_a? String
11
+ ESA::Context.find(ctx.to_i) rescue nil
12
+ else
13
+ ctx
14
+ end
15
+ end.
16
+ inject(where([])) do |relation,ctx|
17
+ if not ctx.nil? and ctx.respond_to? :apply
18
+ # good, the context can be applied directly
19
+ ctx.apply(relation)
20
+ else
21
+ # context not found, or cannot be applied
22
+ # either way, make sure we dont return results
23
+ relation.where("1=0")
24
+ end
25
+ end
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ ESA::Amount.send :include, ESA::Filters::ContextFilter
33
+ ESA::Event.send :include, ESA::Filters::ContextFilter
34
+ ESA::Flag.send :include, ESA::Filters::ContextFilter
35
+ ESA::Transaction.send :include, ESA::Filters::ContextFilter
@@ -0,0 +1,52 @@
1
+ module ESA
2
+ module Filters
3
+ module DateTimeFilter
4
+ module TransactionDate
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ scope :between_dates, lambda { |date1,date2| joins(:transaction).where(esa_transactions: {time: date1.midnight..date2.end_of_day}) }
9
+ scope :with_date, lambda { |date| joins(:transaction).where(esa_transactions: {time: date.midnight..date.end_of_day}) }
10
+
11
+ scope :with_date_lt, lambda { |date| with_time_lt(date.midnight) }
12
+ scope :with_date_gt, lambda { |date| with_time_gt(date.end_of_day) }
13
+ scope :with_date_lte, lambda { |date| with_time_lte(date.end_of_day) }
14
+ scope :with_date_gte, lambda { |date| with_time_gte(date.midnight) }
15
+
16
+ scope :with_time_lt, lambda { |time| joins(:transaction).where(ESA::Transaction.arel_table[:time].lt( time)) }
17
+ scope :with_time_gt, lambda { |time| joins(:transaction).where(ESA::Transaction.arel_table[:time].gt( time)) }
18
+ scope :with_time_lte, lambda { |time| joins(:transaction).where(ESA::Transaction.arel_table[:time].lteq(time)) }
19
+ scope :with_time_gte, lambda { |time| joins(:transaction).where(ESA::Transaction.arel_table[:time].gteq(time)) }
20
+
21
+ scope :created_before, lambda { |time| joins(:transaction).where(ESA::Transaction.arel_table[:created_at].lt(time)) }
22
+ end
23
+ end
24
+
25
+ module ObjectDate
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ scope :between_dates, lambda { |date1,date2| where(time: date1.midnight..date2.end_of_day) }
30
+ scope :with_date, lambda { |date| where(time: date.midnight..date.end_of_day) }
31
+
32
+ scope :with_date_lt, lambda { |date| with_time_lt(date.midnight) }
33
+ scope :with_date_gt, lambda { |date| with_time_gt(date.end_of_day) }
34
+ scope :with_date_lte, lambda { |date| with_time_lte(date.end_of_day) }
35
+ scope :with_date_gte, lambda { |date| with_time_gte(date.midnight) }
36
+
37
+ scope :with_time_lt, lambda { |time| where(arel_table[:time].lt( time)) }
38
+ scope :with_time_gt, lambda { |time| where(arel_table[:time].gt( time)) }
39
+ scope :with_time_lte, lambda { |time| where(arel_table[:time].lteq(time)) }
40
+ scope :with_time_gte, lambda { |time| where(arel_table[:time].gteq(time)) }
41
+
42
+ scope :created_before, lambda { |time| where(arel_table[:created_at].lt(time)) }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ ESA::Amount.send :include, ESA::Filters::DateTimeFilter::TransactionDate
50
+ ESA::Event.send :include, ESA::Filters::DateTimeFilter::ObjectDate
51
+ ESA::Flag.send :include, ESA::Filters::DateTimeFilter::ObjectDate
52
+ ESA::Transaction.send :include, ESA::Filters::DateTimeFilter::ObjectDate
@@ -0,0 +1,70 @@
1
+ module ESA
2
+ # The Flag class represents a change of known state of an Accountable
3
+ # and it is used record differences of state caused by Events.
4
+ #
5
+ # A Flag with an UP transition creates normal Transactions according to
6
+ # the rules specified in a Ruleset. Flags with a DOWN transition revert
7
+ # Transactions created earlier by the corresponding UP transition.
8
+ #
9
+ # @author Lenno Nagel
10
+ class Flag < ActiveRecord::Base
11
+ include Traits::Extendable
12
+ extend ::Enumerize
13
+
14
+ attr_accessible :nature, :state, :event, :time, :accountable, :type, :ruleset
15
+ attr_readonly :nature, :state, :event, :time, :accountable, :type, :ruleset
16
+
17
+ belongs_to :accountable, :polymorphic => true
18
+ belongs_to :event
19
+ belongs_to :ruleset
20
+ has_many :transactions
21
+ has_many :amounts, :through => :transactions, :extend => Associations::AmountsExtension
22
+
23
+ enumerize :nature, in: [:unknown]
24
+
25
+ after_initialize :default_values
26
+ validates_presence_of :nature, :event, :time, :accountable, :ruleset
27
+ validates_inclusion_of :state, :in => [true, false]
28
+ validates_inclusion_of :processed, :in => [true, false]
29
+ validates_inclusion_of :adjusted, :in => [true, false]
30
+ validate :validate_transition
31
+
32
+ def is_set?
33
+ self.state == true
34
+ end
35
+
36
+ def is_unset?
37
+ self.state == false
38
+ end
39
+
40
+ def became_set?
41
+ self.transition == 1
42
+ end
43
+
44
+ def became_unset?
45
+ self.transition == -1
46
+ end
47
+
48
+ private
49
+
50
+ def validate_transition
51
+ if self.processed and not self.transition.in? [-1, 0, 1]
52
+ errors[:processed] = "The transition must be in? [-1, 0, 1] before processed can be set to true"
53
+ end
54
+ if self.adjusted and not self.transition.in? [-1, 0, 1]
55
+ errors[:adjusted] = "The transition must be in? [-1, 0, 1] before adjusted can be set to true"
56
+ end
57
+ end
58
+
59
+ def default_values
60
+ if not self.event_id.nil?
61
+ self.time ||= self.event.time if self.time.nil?
62
+ self.accountable ||= self.event.accountable if self.accountable_id.nil?
63
+ self.ruleset ||= self.event.ruleset if self.ruleset_id.nil?
64
+ end
65
+
66
+ self.processed ||= false
67
+ self.adjusted ||= false
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,175 @@
1
+ module ESA
2
+ # The Ruleset class contains the business logic and rules of accounting.
3
+ #
4
+ # @author Lenno Nagel
5
+ class Ruleset < ActiveRecord::Base
6
+ include Traits::Extendable
7
+
8
+ attr_accessible :name, :type, :chart
9
+ attr_readonly :name, :type, :chart
10
+
11
+ belongs_to :chart
12
+ has_many :events
13
+ has_many :flags
14
+
15
+ after_initialize :default_values
16
+ validates_presence_of :type, :chart
17
+
18
+ # accountable
19
+
20
+ def accountables_updated_at(timespec)
21
+ []
22
+ end
23
+
24
+ # events
25
+
26
+ def stateful_events(accountable)
27
+ []
28
+ end
29
+
30
+ def stateful_events_as_attributes(accountable)
31
+ stateful_events(accountable).
32
+ sort_by{|event| event[:time]}.
33
+ map do |event|
34
+ event[:accountable] ||= accountable
35
+ event[:ruleset] ||= self
36
+ event
37
+ end
38
+ end
39
+
40
+ def unrecorded_events_as_attributes(accountable)
41
+ stateful = stateful_events_as_attributes(accountable)
42
+
43
+ recorded = accountable.esa_events.pluck([:nature, :time]).
44
+ map{|nature,time| [nature, time.to_i]}
45
+
46
+ stateful.reject{|s| [s[:nature].to_s, s[:time].to_i].in? recorded}
47
+ end
48
+
49
+ def is_adjustment_event_needed?(accountable)
50
+ flags_needing_adjustment(accountable).count > 0
51
+ end
52
+
53
+ # flags
54
+
55
+ def event_flags(event)
56
+ {}
57
+ end
58
+
59
+ def event_flags_as_attributes(event)
60
+ event_flags(event).map do |nature,state|
61
+ {
62
+ :accountable => event.accountable,
63
+ :nature => nature,
64
+ :state => state,
65
+ :event => event,
66
+ }
67
+ end
68
+ end
69
+
70
+ def flags_needing_adjustment(accountable)
71
+ natures = accountable.esa_flags.pluck(:nature).uniq.map{|nature| nature.to_sym}
72
+
73
+ most_recent_flags = natures.map do |nature|
74
+ accountable.esa_flags.joins(:event).readonly(false).
75
+ where("esa_events.nature = 'adjustment' OR esa_flags.transition != 0").
76
+ where(nature: nature).
77
+ order('time DESC, created_at DESC').
78
+ first
79
+ end.compact
80
+
81
+ most_recent_flags.select(&:is_set?).reject do |flag|
82
+ attributes = flag_transactions_as_attributes(flag)
83
+
84
+ flag.transactions.map do |tx|
85
+ tx_attrs = attributes.find{|a| a[:description] == tx.description}
86
+ tx_attrs_amounts = (tx_attrs[:credits] + tx_attrs[:debits]).map{|a| [a[:account], a[:amount]]}
87
+ tx_amounts = tx.amounts.map{|a| [a.account, a.amount]}
88
+ (tx_attrs_amounts - tx_amounts).empty?
89
+ end.all?
90
+ end
91
+ end
92
+
93
+ # transactions
94
+
95
+ def flag_transactions_when_set(flag)
96
+ []
97
+ end
98
+
99
+ def flag_transactions_when_unset(flag)
100
+ self.flag_transactions_when_set(flag).each do |tx|
101
+ description = tx[:description] + " / reversed"
102
+ debits = tx[:credits] # swap
103
+ credits = tx[:debits] # swap
104
+ tx[:description] = description
105
+ tx[:debits] = debits
106
+ tx[:credits] = credits
107
+ end
108
+ end
109
+
110
+ def flag_transactions_when_adjusted(flag)
111
+ flag.transactions.map do |tx|
112
+ if tx.valid?
113
+ [
114
+ {
115
+ :time => flag.time,
116
+ :description => tx.description,
117
+ :credits => tx.amounts.credits.map{|a| {:account => a.account, :amount => a.amount}},
118
+ :debits => tx.amounts.debits.map{|a| {:account => a.account, :amount => a.amount}},
119
+ },
120
+ {
121
+ :time => flag.adjustment_time,
122
+ :description => tx.description + " / adjusted",
123
+ :debits => tx.amounts.credits.map{|a| {:account => a.account, :amount => a.amount}}, # swap
124
+ :credits => tx.amounts.debits.map{|a| {:account => a.account, :amount => a.amount}}, # swap
125
+ }
126
+ ]
127
+ end
128
+ end.compact.flatten
129
+ end
130
+
131
+ def flag_transactions_as_attributes(flag)
132
+ if flag.adjusted?
133
+ transactions = self.flag_transactions_when_adjusted(flag)
134
+ elsif flag.became_set? or (flag.is_set? and flag.event.present? and flag.event.nature.adjustment?)
135
+ transactions = self.flag_transactions_when_set(flag)
136
+ elsif flag.became_unset?
137
+ transactions = self.flag_transactions_when_unset(flag)
138
+ else
139
+ transactions = []
140
+ end
141
+
142
+ transactions.map do |tx|
143
+ tx[:time] ||= flag.time
144
+ tx[:accountable] ||= flag.accountable
145
+ tx[:flag] ||= flag
146
+
147
+ amounts = (tx[:debits] + tx[:credits]).map{|a| a[:amount]}
148
+
149
+ if amounts.map{|a| a <= BigDecimal(0)}.all?
150
+ debits = tx[:credits].map{|a| a[:amount] = BigDecimal(0) - a[:amount]; a } # swap
151
+ credits = tx[:debits].map{|a| a[:amount] = BigDecimal(0) - a[:amount]; a } # swap
152
+ tx[:debits] = debits
153
+ tx[:credits] = credits
154
+ end
155
+
156
+ tx
157
+ end
158
+ end
159
+
160
+ def find_account(type, name)
161
+ if self.chart.present? and Account.valid_type?(type)
162
+ self.chart.accounts.
163
+ where(:type => Account.namespaced_type(type), :name => name).
164
+ first_or_create
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ def default_values
171
+ self.chart ||= Chart.extension_instance(self) if self.chart_id.nil?
172
+ self.name ||= "#{self.chart.name} #{self.class.name.demodulize}" if self.name.nil? and self.chart_id.present?
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,21 @@
1
+ module ESA
2
+ module Traits
3
+ module Accountable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_many :esa_events, :as => :accountable, :class_name => ESA::Event.extension_name(self), :extend => ESA::Associations::EventsExtension
8
+ has_many :esa_flags, :as => :accountable, :class_name => ESA::Flag.extension_name(self), :extend => ESA::Associations::FlagsExtension
9
+ has_many :esa_transactions, :as => :accountable, :class_name => ESA::Transaction.extension_name(self), :extend => ESA::Associations::TransactionsExtension
10
+
11
+ def esa_ruleset
12
+ ESA::Ruleset.extension_instance(self)
13
+ end
14
+
15
+ def esa_chart
16
+ self.esa_ruleset.chart
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,93 @@
1
+ module ESA
2
+ module Traits
3
+ module Extendable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do |base|
7
+ cattr_accessor :esa_extensions
8
+ self.esa_extensions = {}
9
+
10
+ def self.register_extension(expression, extension)
11
+ self.esa_extensions[expression] = extension
12
+ end
13
+
14
+ def self.lookup_extension(name)
15
+ if self.esa_extensions.present?
16
+ matches = self.esa_extensions.map do |expr,extension|
17
+ if expr.is_a? Regexp and expr.match(name).present?
18
+ extension
19
+ elsif expr.is_a? String and expr == name
20
+ extension
21
+ else
22
+ nil
23
+ end
24
+ end.compact
25
+
26
+ if matches.present?
27
+ matches.first
28
+ else
29
+ nil
30
+ end
31
+ else
32
+ self.name
33
+ end
34
+ end
35
+
36
+ def self.extension_name(accountable)
37
+ if accountable.is_a? Class
38
+ if accountable.respond_to? :accountable_name
39
+ lookup_extension(accountable.accountable_name)
40
+ else
41
+ lookup_extension(accountable.name)
42
+ end
43
+ elsif accountable.is_a? Object and not accountable.is_a? String
44
+ extension_name(accountable.class)
45
+ else
46
+ lookup_extension(accountable)
47
+ end
48
+ end
49
+
50
+ def self.extension_class(accountable)
51
+ if extension_name(accountable).present?
52
+ extension_name(accountable).constantize
53
+ else
54
+ nil
55
+ end
56
+ end
57
+
58
+ def self.extension_instance(accountable)
59
+ if extension_class(accountable).present?
60
+ extension_class(accountable).instance_for(accountable)
61
+ else
62
+ nil
63
+ end
64
+ end
65
+
66
+ def self.instance_for(accountable)
67
+ self.first_or_create
68
+ end
69
+
70
+ def self.accountable_name(extension=self)
71
+ registered_keys_for(extension).find{|k| k.is_a? String}
72
+ end
73
+
74
+ def self.registered_keys_for(extension=self)
75
+ if extension.is_a? Class
76
+ self.esa_extensions.select{|k,v| v == extension.name}.keys
77
+ elsif extension.is_a? Object and not extension.is_a? String
78
+ registered_keys_for(extension.class)
79
+ else
80
+ self.esa_extensions.select{|k,v| v == extension}.keys
81
+ end
82
+ end
83
+
84
+ def self.list_extensions
85
+ self.esa_extensions.each do |accountable, extension|
86
+ puts "#{accountable} --> #{extension}"
87
+ end
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,35 @@
1
+ module ESA
2
+ module Traits
3
+ #
4
+ # https://gist.github.com/tlowrimore/5257360
5
+ #
6
+ # OR's together provided scopes, returning an ActiveRecord::Relation.
7
+ # This implementation is smart enough to not only handle your _where_
8
+ # clauses, but it also takes care of your joins!
9
+ #
10
+ module OrScope
11
+ def self.included(base)
12
+ base.send :extend, ClassMethods
13
+ end
14
+
15
+ module ClassMethods
16
+ def or_scope(*scopes)
17
+ conditions =
18
+ scopes
19
+ .map { |scope| "(#{scope.where_clauses.map{ |clause| "(#{clause})"}.join(" AND ")})" }
20
+ .join(" OR ")
21
+
22
+ relationships =
23
+ scopes
24
+ .map { |scope| scope.joins_values }
25
+ .flatten
26
+ .uniq
27
+
28
+ joins(*relationships).where(conditions)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ ActiveRecord::Base.send :include, ESA::Traits::OrScope
@@ -0,0 +1,24 @@
1
+ module ESA
2
+ module Traits
3
+ #
4
+ # https://gist.github.com/tlowrimore/5162327
5
+ #
6
+ # Unions multiple scopes on a model, and returns an instance of ActiveRecord::Relation.
7
+ #
8
+ module UnionScope
9
+ def self.included(base)
10
+ base.send :extend, ClassMethods
11
+ end
12
+
13
+ module ClassMethods
14
+ def union_scope(*scopes)
15
+ id_column = "#{table_name}.id"
16
+ sub_query = scopes.map { |s| s.select(id_column).to_sql }.join(" UNION ")
17
+ where "#{id_column} IN (#{sub_query})"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ ActiveRecord::Base.send :include, ESA::Traits::UnionScope
@@ -0,0 +1,74 @@
1
+ module ESA
2
+ # Transactions are the recording of debits and credits to various accounts.
3
+ # This table can be thought of as a traditional accounting Journal.
4
+ #
5
+ # Transactions are created from transitions in the corresponding Flag.
6
+ #
7
+ # @author Lenno Nagel, Michael Bulat
8
+ class Transaction < ActiveRecord::Base
9
+ include Traits::Extendable
10
+
11
+ attr_accessible :description, :accountable, :flag, :time
12
+ attr_readonly :description, :accountable, :flag, :time
13
+
14
+ belongs_to :accountable, :polymorphic => true
15
+ belongs_to :flag
16
+ has_many :amounts, :extend => Associations::AmountsExtension
17
+ has_many :accounts, :through => :amounts, :source => :account, :uniq => true
18
+
19
+ after_initialize :default_values
20
+
21
+ validates_presence_of :time, :description
22
+ validate :has_credit_amounts?
23
+ validate :has_debit_amounts?
24
+ validate :accounts_of_the_same_chart?
25
+ validate :amounts_cancel?
26
+
27
+ attr_accessible :credits, :debits
28
+
29
+ def credits=(*attributes)
30
+ attributes.flatten.each do |attrs|
31
+ attrs[:transaction] = self
32
+ self.amounts << Amounts::Credit.new(attrs)
33
+ end
34
+ end
35
+
36
+ def debits=(*attributes)
37
+ attributes.flatten.each do |attrs|
38
+ attrs[:transaction] = self
39
+ self.amounts << Amounts::Debit.new(attrs)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def default_values
46
+ self.time ||= Time.zone.now
47
+ end
48
+
49
+ def has_credit_amounts?
50
+ errors[:base] << "Transaction must have at least one credit amount" if self.amounts.find{|a| a.is_credit?}.nil?
51
+ end
52
+
53
+ def has_debit_amounts?
54
+ errors[:base] << "Transaction must have at least one debit amount" if self.amounts.find{|a| a.is_debit?}.nil?
55
+ end
56
+
57
+ def accounts_of_the_same_chart?
58
+ if self.new_record?
59
+ chart_ids = self.amounts.map{|a| if a.account.present? then a.account.chart_id else nil end}
60
+ else
61
+ chart_ids = self.accounts.pluck(:chart_id)
62
+ end
63
+
64
+ if not chart_ids.all? or chart_ids.uniq.count != 1
65
+ errors[:base] << "Transaction must take place between accounts of the same Chart " + chart_ids.to_s
66
+ end
67
+ end
68
+
69
+ def amounts_cancel?
70
+ balance = self.amounts.iterated_balance
71
+ errors[:base] << "The credit and debit amounts are not equal" if balance.nil? or balance != 0
72
+ end
73
+ end
74
+ end