event_sourced_accounting 0.1.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.
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