event_sourced_accounting 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +23 -0
- data/README.markdown +37 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/esa/application.js +15 -0
- data/app/assets/stylesheets/esa/application.css +13 -0
- data/app/assets/stylesheets/esa/main.css.scss +86 -0
- data/app/models/esa/account.rb +80 -0
- data/app/models/esa/accounts/asset.rb +22 -0
- data/app/models/esa/accounts/equity.rb +22 -0
- data/app/models/esa/accounts/expense.rb +22 -0
- data/app/models/esa/accounts/liability.rb +22 -0
- data/app/models/esa/accounts/revenue.rb +22 -0
- data/app/models/esa/amount.rb +27 -0
- data/app/models/esa/amounts/credit.rb +12 -0
- data/app/models/esa/amounts/debit.rb +12 -0
- data/app/models/esa/associations/amounts_extension.rb +79 -0
- data/app/models/esa/associations/events_extension.rb +33 -0
- data/app/models/esa/associations/flags_extension.rb +35 -0
- data/app/models/esa/associations/transactions_extension.rb +7 -0
- data/app/models/esa/chart.rb +41 -0
- data/app/models/esa/context.rb +218 -0
- data/app/models/esa/context_provider.rb +52 -0
- data/app/models/esa/context_providers/account_context_provider.rb +21 -0
- data/app/models/esa/context_providers/accountable_context_provider.rb +22 -0
- data/app/models/esa/context_providers/accountable_type_context_provider.rb +21 -0
- data/app/models/esa/context_providers/date_context_provider.rb +33 -0
- data/app/models/esa/contexts/account_context.rb +26 -0
- data/app/models/esa/contexts/accountable_context.rb +26 -0
- data/app/models/esa/contexts/accountable_type_context.rb +24 -0
- data/app/models/esa/contexts/created_at_context.rb +26 -0
- data/app/models/esa/contexts/date_context.rb +71 -0
- data/app/models/esa/contexts/empty_context.rb +19 -0
- data/app/models/esa/contexts/filter_context.rb +11 -0
- data/app/models/esa/contexts/open_close_context.rb +15 -0
- data/app/models/esa/event.rb +33 -0
- data/app/models/esa/filters/account_filter.rb +42 -0
- data/app/models/esa/filters/accountable_filter.rb +58 -0
- data/app/models/esa/filters/accountable_type_filter.rb +26 -0
- data/app/models/esa/filters/chart_filter.rb +17 -0
- data/app/models/esa/filters/context_filter.rb +35 -0
- data/app/models/esa/filters/date_time_filter.rb +52 -0
- data/app/models/esa/flag.rb +70 -0
- data/app/models/esa/ruleset.rb +175 -0
- data/app/models/esa/traits/accountable.rb +21 -0
- data/app/models/esa/traits/extendable.rb +93 -0
- data/app/models/esa/traits/or_scope.rb +35 -0
- data/app/models/esa/traits/union_scope.rb +24 -0
- data/app/models/esa/transaction.rb +74 -0
- data/config/backtrace_silencers.rb +7 -0
- data/config/database.yml +5 -0
- data/config/inflections.rb +10 -0
- data/config/mime_types.rb +5 -0
- data/config/routes.rb +6 -0
- data/config/secret_token.rb +7 -0
- data/config/session_store.rb +8 -0
- data/lib/esa/balance_checker.rb +17 -0
- data/lib/esa/blocking_processor.rb +158 -0
- data/lib/esa/config.rb +55 -0
- data/lib/esa/subcontext_checker.rb +15 -0
- data/lib/esa/version.rb +3 -0
- data/lib/esa.rb +8 -0
- data/lib/generators/esa/USAGE +11 -0
- data/lib/generators/esa/esa_generator.rb +26 -0
- data/lib/generators/esa/templates/migration.rb +142 -0
- data/spec/factories/account_factory.rb +51 -0
- data/spec/factories/amount_factory.rb +19 -0
- data/spec/factories/chart_factory.rb +7 -0
- data/spec/factories/transaction_factory.rb +11 -0
- data/spec/lib/esa_spec.rb +0 -0
- data/spec/models/account_spec.rb +31 -0
- data/spec/models/amount_spec.rb +8 -0
- data/spec/models/asset_spec.rb +9 -0
- data/spec/models/chart_spec.rb +52 -0
- data/spec/models/credit_amount_spec.rb +9 -0
- data/spec/models/debit_amount_spec.rb +9 -0
- data/spec/models/equity_spec.rb +9 -0
- data/spec/models/expense_spec.rb +9 -0
- data/spec/models/liability_spec.rb +9 -0
- data/spec/models/revenue_spec.rb +9 -0
- data/spec/models/transaction_spec.rb +118 -0
- data/spec/rcov.opts +2 -0
- data/spec/spec.opts +4 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/account_shared_examples.rb +57 -0
- data/spec/support/amount_shared_examples.rb +21 -0
- 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
|