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