event_sourced_accounting 0.1.6 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d898cf40cfd5525402b244a8ebde093e0e9948fa
4
- data.tar.gz: 4a925d9356d3aeee2a08790ad1b2ea28838dfc9c
3
+ metadata.gz: 725485c3fb1eb0802b7e7f3553a0fdb598f0c927
4
+ data.tar.gz: 9a5c5aef5200ebd25e0a1aadc0e791d213bac61a
5
5
  SHA512:
6
- metadata.gz: 6e6c5f2d437c040f89af829811cc5b0f92b9da413c03a380479c03b6c9f2089d4ad7d7ea424833ea99a53be8f1eb112a8b6bbcd11bbd7ab3969a8f7951c4c740
7
- data.tar.gz: 4d4c47371255499bcda065a9c78213a09a3ccb983388058057d4fa4db9fb60812a3e03a0686feac878b4024c527df1ea8308d95cb82a67b124b26e09a8be3d26
6
+ metadata.gz: b7229fe63379704b0388f1a97971a630c24f0bd2faa28fa9b89d1aca1a3887c92a9d76068cd1aa8ee0759c0e25d0c24f0a6351d82619d41593a40539bd63795b
7
+ data.tar.gz: 5c177694686252945b7823a5163e7f7e6023827118e2797f82918affcec077ae5aefad2710349239c0e00c94d172c4900a53fe3a35a12ad4d0fd7384ef1c751f
data/README.markdown CHANGED
@@ -27,10 +27,187 @@ Installation
27
27
 
28
28
  - run migrations `rake db:migrate`
29
29
 
30
- - add `include ESA::Traits::Accountable` to relevant models
31
30
 
32
- - implement the corresponding Event, Flag, Ruleset and Transaction classes for relevant models
31
+ Integration
32
+ ============
33
+
34
+ First, configure the gem by creating `config/initializers/accounting.rb`.
35
+ ```
36
+ require 'esa'
37
+
38
+ ESA.configure do |config|
39
+ config.processor = ESA::BlockingProcessor # default
40
+ config.extension_namespace = 'Accounting' # default
41
+ config.register('BankTransaction')
42
+ ...
43
+ end
44
+ ```
45
+
46
+ Then add `include ESA::Traits::Accountable` to the registered models.
47
+ ```
48
+ class BankTransaction < ActiveRecord::Base
49
+ include ESA::Traits::Accountable
50
+ ...
51
+ end
52
+ ```
53
+
54
+ Implement the corresponding Event, Flag, Ruleset and Transaction classes for the registered models.
55
+ ```
56
+ # app/models/accounting/events/bank_transaction_event.rb
57
+ module Accounting
58
+ module Events
59
+ class BankTransactionEvent < ESA::Event
60
+ enumerize :nature, in: [
61
+ :adjustment, # mandatory
62
+ :confirm, # example
63
+ :revoke, # example
64
+ ]
65
+ end
66
+ end
67
+ end
68
+ ```
69
+
70
+ ```
71
+ # app/models/accounting/flags/bank_transaction_flag.rb
72
+ module Accounting
73
+ module Flags
74
+ class BankTransactionFlag < ESA::Flag
75
+ enumerize :nature, in: [
76
+ :complete, # example
77
+ ]
78
+ end
79
+ end
80
+ end
81
+ ```
82
+
83
+ ```
84
+ # app/models/accounting/transactions/bank_transaction_transaction.rb
85
+ module Accounting
86
+ module Transactions
87
+ class BankTransactionTransaction < ESA::Transaction
88
+ # this relation definition is optional
89
+ has_one :bank_transaction, :through => :flag, :source => :accountable, :source_type => "BankTransaction"
90
+ end
91
+ end
92
+ end
93
+ ```
94
+
95
+ ```
96
+ # app/models/accounting/rulesets/bank_transaction_ruleset.rb
97
+ module Accounting
98
+ module Rulesets
99
+ class BankTransactionRuleset < ESA::Ruleset
100
+ # events that have happened according to the current state
101
+ def event_times(bank_transaction)
102
+ {
103
+ confirm: bank_transaction.confirm_time,
104
+ revoke: bank_transaction.revoke_time,
105
+ }
106
+ end
107
+
108
+ # flags to be changed when events occur
109
+ def event_nature_flags
110
+ {
111
+ confirm: {complete: true},
112
+ revoke: {complete: false},
113
+ }
114
+ end
115
+
116
+ # transaction for when the :complete flag is switched to true
117
+ def flag_complete_transactions(bank_transaction)
118
+ {
119
+ :description => 'BankTransaction completed',
120
+ :debits => [
121
+ {
122
+ :account => find_account('Asset', 'Bank'),
123
+ :amount => bank_transaction.transferred_amount
124
+ }
125
+ ],
126
+ :credits => [
127
+ {
128
+ :account => find_account('Asset', 'Bank Transit'),
129
+ :amount => bank_transaction.transferred_amount
130
+ }
131
+ ],
132
+ }
133
+ end
134
+ end
135
+ end
136
+ end
137
+ ```
138
+
139
+ Usage
140
+ ============
141
+
142
+ In order to create events and transactions, the accountable objects
143
+ have to pass through a processor, which will register the necessary
144
+ Events, Flags & Transactions in the database.
145
+
146
+ You can use the provided processor implementation, or inherit from
147
+ the base implementation and provide your own class (e.g. to implement
148
+ delayed or scheduled processing).
149
+
150
+ ```
151
+ >> bank_transaction = BankTransaction.find(..)
152
+ >> bank_transaction.confirm_time = Time.now
153
+ >> bank_transaction.save
154
+ true
155
+
156
+ >> ESA.configuration.processor.enqueue(bank_transaction)
157
+
158
+ >> bank_transaction.esa_events.count
159
+ 1
160
+
161
+ >> bank_transaction.esa_flags.count
162
+ 1
163
+
164
+ >> bank_transaction.esa_transactions.count
165
+ 1
166
+ ```
167
+
168
+ Reporting
169
+ ============
33
170
 
171
+ There are many different reporting and filtering implementations available.
172
+ For a simple example, let's look at a report that only involves the transaction.
173
+
174
+ The following commands initialize the report and update the persisted values
175
+ to the depth of 1, which includes the creation of sub-reports per each account
176
+ involved in the transactions of that BankAccount.
177
+
178
+ ```
179
+ >> report = ESA::Contexts::AccountableContext.create(chart: ESA::Chart.first, accountable: bank_transaction)
180
+ >> report.check_freshness(1)
181
+ ```
182
+
183
+ Complex reports can be constructed automatically using the context provider
184
+ functionality. Reports, filters and context providers are available for:
185
+
186
+ - account
187
+ - accountable object (e.g. a single BankTransaction)
188
+ - accountable type (e.g. all known BankTransactions)
189
+ - date periods (year, month, date, custom)
190
+
191
+ Please refer to the source code for examples.
192
+
193
+ Subreport structure and context providers need to be configured:
194
+
195
+ ```
196
+ ESA.configure do |config|
197
+ ...
198
+ config.context_providers['bank_account'] = Accounting::ContextProviders::BankAccountContextProvider
199
+
200
+ config.context_tree = {
201
+ 'month' => {
202
+ 'account' => {
203
+ 'bank_account' => {},
204
+ 'date' => {},
205
+ },
206
+ },
207
+ }
208
+ ...
209
+ end
210
+ ```
34
211
 
35
212
  Development
36
213
  ============
@@ -31,7 +31,7 @@ module ESA
31
31
 
32
32
  enumerize :normal_balance, in: [:none, :debit, :credit]
33
33
 
34
- after_initialize :default_values
34
+ after_initialize :initialize_defaults
35
35
 
36
36
  before_validation :update_normal_balance
37
37
  validates_presence_of :type, :name, :chart, :normal_balance
@@ -69,7 +69,7 @@ module ESA
69
69
 
70
70
  private
71
71
 
72
- def default_values
72
+ def initialize_defaults
73
73
  self.chart ||= Chart.where(:name => 'Chart of Accounts').first_or_create if self.chart_id.nil?
74
74
  self.normal_balance ||= :none
75
75
  end
@@ -18,7 +18,7 @@ module ESA
18
18
  has_many :transactions, :through => :accounts, :uniq => true
19
19
  has_many :amounts, :through => :accounts, :uniq => true, :extend => ESA::Associations::AmountsExtension
20
20
 
21
- after_initialize :default_values
21
+ after_initialize :initialize_defaults
22
22
 
23
23
  validates_presence_of :name
24
24
  validates_uniqueness_of :name
@@ -37,7 +37,7 @@ module ESA
37
37
 
38
38
  private
39
39
 
40
- def default_values
40
+ def initialize_defaults
41
41
  self.name ||= "Chart of Accounts"
42
42
  end
43
43
  end
@@ -21,7 +21,7 @@ module ESA
21
21
  belongs_to :parent, :class_name => "Context"
22
22
  has_many :subcontexts, :class_name => "Context", :foreign_key => "parent_id", :dependent => :destroy
23
23
 
24
- after_initialize :default_values, :initialize_filters
24
+ after_initialize :initialize_defaults, :initialize_filters
25
25
  before_validation :update_name, :update_position
26
26
  validates_presence_of :chart, :name
27
27
  validate :validate_parent
@@ -75,9 +75,13 @@ module ESA
75
75
  self.freshness.present? and (self.freshness + ESA.configuration.context_freshness_threshold) > time
76
76
  end
77
77
 
78
- def check_freshness(depth=0)
79
- if self.is_update_needed?
80
- self.update!
78
+ def has_subcontext_namespaces?(*namespaces)
79
+ (namespaces.flatten.compact - subcontext_namespaces).none?
80
+ end
81
+
82
+ def check_freshness(depth=0, options = {})
83
+ if self.is_update_needed? or not has_subcontext_namespaces?(options[:namespace])
84
+ self.update!(options)
81
85
  else
82
86
  self.update_freshness_timestamp!
83
87
  end
@@ -103,12 +107,12 @@ module ESA
103
107
  end
104
108
  end
105
109
 
106
- def update!
110
+ def update!(options = {})
107
111
  self.freshness = Time.zone.now
108
112
 
109
113
  ESA.configuration.context_checkers.each do |checker|
110
114
  if checker.respond_to? :check
111
- checker.check(self)
115
+ checker.check(self, options)
112
116
  end
113
117
  end
114
118
 
@@ -178,16 +182,21 @@ module ESA
178
182
  end
179
183
  end
180
184
 
181
- def default_values
185
+ def initialize_defaults
182
186
  self.chart ||= self.parent.chart if self.chart_id.nil? and not self.parent_id.nil?
183
- self.namespace ||= (self.type || self.class.name).demodulize.underscore.gsub(/_context$/, '')
187
+ self.namespace ||= self.default_namespace
188
+ self.position ||= self.default_position
189
+ end
190
+
191
+ def default_namespace
192
+ (self.type || self.class.name).demodulize.underscore.gsub(/_context$/, '')
184
193
  end
185
194
 
186
195
  def update_name
187
- self.name = self.create_name
196
+ self.name = self.default_name
188
197
  end
189
198
 
190
- def create_name
199
+ def default_name
191
200
  if self.type.nil?
192
201
  self.chart.name unless self.chart.nil?
193
202
  else
@@ -196,10 +205,10 @@ module ESA
196
205
  end
197
206
 
198
207
  def update_position
199
- self.position = self.create_position
208
+ self.position = self.default_position
200
209
  end
201
210
 
202
- def create_position
211
+ def default_position
203
212
  nil
204
213
  end
205
214
 
@@ -10,11 +10,11 @@ module ESA
10
10
 
11
11
  protected
12
12
 
13
- def create_name
13
+ def default_name
14
14
  self.account.name unless self.account.nil?
15
15
  end
16
16
 
17
- def create_position
17
+ def default_position
18
18
  self.account.code.gsub(/[^0-9]/, '').to_i unless self.account.nil? or self.account.code.nil?
19
19
  end
20
20
 
@@ -10,7 +10,7 @@ module ESA
10
10
 
11
11
  protected
12
12
 
13
- def create_name
13
+ def default_name
14
14
  "#{self.accountable_type} #{self.accountable_id}" unless self.accountable_type.nil?
15
15
  end
16
16
 
@@ -8,7 +8,7 @@ module ESA
8
8
 
9
9
  protected
10
10
 
11
- def create_name
11
+ def default_name
12
12
  "#{self.accountable_type} accountables" unless self.accountable_type.nil?
13
13
  end
14
14
 
@@ -33,7 +33,27 @@ module ESA
33
33
  end
34
34
  end
35
35
 
36
- def create_name
36
+ def default_namespace
37
+ if self.start_date.present? and self.end_date.present?
38
+ if self.start_date == self.end_date
39
+ "date"
40
+ elsif self.start_date == self.start_date.beginning_of_month and
41
+ self.end_date == self.end_date.end_of_month and
42
+ self.end_date == self.start_date.end_of_month then
43
+ "month"
44
+ elsif self.start_date == self.start_date.beginning_of_year and
45
+ self.end_date == self.end_date.end_of_year and
46
+ self.end_date == self.start_date.end_of_year then
47
+ "year"
48
+ else
49
+ "period"
50
+ end
51
+ else
52
+ "period"
53
+ end
54
+ end
55
+
56
+ def default_name
37
57
  if self.start_date.present? and self.end_date.present?
38
58
  if self.start_date == self.end_date
39
59
  "#{self.start_date.to_s}"
@@ -47,7 +67,7 @@ module ESA
47
67
  end
48
68
  end
49
69
 
50
- def create_position
70
+ def default_position
51
71
  if self.start_date.present?
52
72
  self.start_date.to_time.to_i
53
73
  elsif self.end_date.present?
@@ -7,7 +7,7 @@ module ESA
7
7
 
8
8
  protected
9
9
 
10
- def create_name
10
+ def default_name
11
11
  "Empty"
12
12
  end
13
13
 
@@ -21,13 +21,13 @@ module ESA
21
21
 
22
22
  enumerize :nature, in: [:unknown, :adjustment]
23
23
 
24
- after_initialize :default_values
24
+ after_initialize :initialize_defaults
25
25
  validates_presence_of :time, :nature, :accountable, :ruleset
26
26
  validates_inclusion_of :processed, :in => [true, false]
27
27
 
28
28
  private
29
29
 
30
- def default_values
30
+ def initialize_defaults
31
31
  self.time ||= Time.zone.now if self.time.nil?
32
32
  self.ruleset ||= Ruleset.extension_instance(self.accountable) if self.ruleset_id.nil? and not self.accountable_id.nil?
33
33
  self.processed ||= false
@@ -27,7 +27,7 @@ module ESA
27
27
 
28
28
  enumerize :nature, in: [:unknown]
29
29
 
30
- after_initialize :default_values
30
+ after_initialize :initialize_defaults
31
31
  validates_presence_of :nature, :event, :time, :accountable, :ruleset
32
32
  validates_inclusion_of :state, :in => [true, false]
33
33
  validates_inclusion_of :processed, :in => [true, false]
@@ -77,7 +77,7 @@ module ESA
77
77
  end
78
78
  end
79
79
 
80
- def default_values
80
+ def initialize_defaults
81
81
  if not self.event_id.nil?
82
82
  self.time ||= self.event.time if self.time.nil?
83
83
  self.accountable ||= self.event.accountable if self.accountable_id.nil?
@@ -14,7 +14,7 @@ module ESA
14
14
  has_many :events
15
15
  has_many :flags
16
16
 
17
- after_initialize :default_values
17
+ after_initialize :initialize_defaults
18
18
  validates_presence_of :type, :chart
19
19
 
20
20
  # accountable
@@ -25,18 +25,36 @@ module ESA
25
25
 
26
26
  # events
27
27
 
28
+ def event_times(accountable)
29
+ {}
30
+ end
31
+
28
32
  def stateful_events(accountable)
29
- []
33
+ self.event_times(accountable).map do |nature,times|
34
+ if times.present? and times.is_a? Time
35
+ {nature: nature, time: times}
36
+ elsif times.present? and times.respond_to? :each
37
+ times.map do |t|
38
+ if t.is_a? Time
39
+ {nature: nature, time: t}
40
+ else
41
+ nil
42
+ end
43
+ end.compact
44
+ else
45
+ nil
46
+ end
47
+ end.flatten.compact
30
48
  end
31
49
 
32
50
  def stateful_events_as_attributes(accountable)
33
- stateful_events(accountable).
34
- sort_by{|event| event[:time]}.
35
- map do |event|
36
- event[:accountable] ||= accountable
37
- event[:ruleset] ||= self
38
- event
39
- end
51
+ defaults = {
52
+ accountable: accountable,
53
+ ruleset: self,
54
+ }
55
+ stateful_events(accountable).map do |event|
56
+ defaults.merge(event)
57
+ end
40
58
  end
41
59
 
42
60
  def unrecorded_events_as_attributes(accountable)
@@ -48,18 +66,32 @@ module ESA
48
66
  stateful.reject{|s| [s[:nature].to_s, s[:time].to_i].in? recorded}
49
67
  end
50
68
 
69
+ def addable_unrecorded_events_as_attributes(accountable)
70
+ flag_times_max = accountable.esa_flags.group(:nature).maximum(:time)
71
+
72
+ unrecorded_events_as_attributes(accountable).select do |event|
73
+ event_flags = event_nature_flags[event[:nature]] || {}
74
+ flag_times = flag_times_max.slice(*event_flags.keys.map(&:to_s))
75
+
76
+ # allow when the event flags have not been used before or
77
+ # when all the currently used flag times are before the new event
78
+ flag_times.values.none? || flag_times.values.max <= event[:time]
79
+ end
80
+ end
81
+
51
82
  def is_adjustment_event_needed?(accountable)
52
83
  flags_needing_adjustment(accountable).count > 0
53
84
  end
54
85
 
55
86
  # flags
56
87
 
57
- def event_flags(event)
88
+ def event_nature_flags
58
89
  {}
59
90
  end
60
91
 
61
92
  def event_flags_as_attributes(event)
62
- event_flags(event).map do |nature,state|
93
+ flags = self.event_nature_flags[event.nature.to_sym] || {}
94
+ flags.map do |nature,state|
63
95
  {
64
96
  :accountable => event.accountable,
65
97
  :nature => nature,
@@ -76,24 +108,42 @@ module ESA
76
108
  accountable.esa_flags.transitioning.most_recent(nature)
77
109
  end.compact
78
110
 
79
- set_flags = most_recent_flags.select(&:is_set?)
80
-
81
- set_flags.reject do |flag|
82
- specs = flag_transactions_as_attributes(flag)
83
- flag.transactions_match_specs?(specs)
111
+ most_recent_flags.select do |flag|
112
+ flag.is_set? and not flag_transactions_match_specs?(flag)
84
113
  end
85
114
  end
86
115
 
87
116
  # transactions
88
117
 
118
+ def flag_transactions_spec(accountable, flag_nature)
119
+ function_name = "flag_#{flag_nature}_transactions"
120
+
121
+ if self.respond_to? function_name
122
+ transactions = self.send(function_name, accountable)
123
+
124
+ if transactions.is_a? Hash
125
+ [transactions]
126
+ elsif transactions.is_a? Array
127
+ transactions
128
+ else
129
+ []
130
+ end
131
+ else
132
+ []
133
+ end
134
+ end
135
+
89
136
  def flag_transactions_when_set(flag)
90
- []
137
+ flag_transactions_spec(flag.accountable, flag.nature)
91
138
  end
92
139
 
93
140
  def flag_transactions_when_unset(flag)
94
- self.flag_transactions_when_set(flag).each do |tx|
95
- tx[:description] = "#{tx[:description]} / reversed"
96
- tx[:debits], tx[:credits] = tx[:credits], tx[:debits] # swap
141
+ self.flag_transactions_when_set(flag).map do |tx|
142
+ tx.merge({
143
+ description: "#{tx[:description]} / reversed",
144
+ debits: tx[:credits],
145
+ credits: tx[:debits]
146
+ })
97
147
  end
98
148
  end
99
149
 
@@ -129,17 +179,33 @@ module ESA
129
179
  end
130
180
 
131
181
  def flag_transactions_as_attributes(flag)
182
+ defaults = {
183
+ time: flag.time,
184
+ accountable: flag.accountable,
185
+ flag: flag,
186
+ }
132
187
  flag_transactions(flag).map do |tx|
133
- tx[:time] ||= flag.time
134
- tx[:accountable] ||= flag.accountable
135
- tx[:flag] ||= flag
188
+ attrs = defaults.merge(tx)
189
+ ensure_positive_amounts(attrs)
190
+ end
191
+ end
136
192
 
137
- amounts = (tx[:debits] + tx[:credits]).map{|a| a[:amount]}
138
- if amounts.map{|a| a <= BigDecimal(0)}.all?
139
- tx[:debits], tx[:credits] = inverted(tx[:credits]), inverted(tx[:debits]) # invert & swap
140
- end
193
+ def flag_transactions_match_specs?(flag)
194
+ specs = flag_transactions_as_attributes(flag)
195
+ flag.transactions_match_specs?(specs)
196
+ end
197
+
198
+ def ensure_positive_amounts(attrs)
199
+ amounts = attrs[:debits] + attrs[:credits]
200
+ nonpositives = amounts.map{|a| a[:amount] <= BigDecimal(0)}
141
201
 
142
- tx
202
+ if nonpositives.all?
203
+ attrs.merge({
204
+ debits: inverted(attrs[:credits]),
205
+ credits: inverted(attrs[:debits]),
206
+ })
207
+ else
208
+ attrs
143
209
  end
144
210
  end
145
211
 
@@ -157,7 +223,7 @@ module ESA
157
223
 
158
224
  private
159
225
 
160
- def default_values
226
+ def initialize_defaults
161
227
  self.chart ||= Chart.extension_instance(self) if self.chart_id.nil?
162
228
  self.name ||= "#{self.chart.name} #{self.class.name.demodulize}" if self.name.nil? and self.chart_id.present?
163
229
  end
@@ -19,7 +19,7 @@ module ESA
19
19
  has_many :amounts, :extend => ESA::Associations::AmountsExtension
20
20
  has_many :accounts, :through => :amounts, :source => :account, :uniq => true
21
21
 
22
- after_initialize :default_values
22
+ after_initialize :initialize_defaults
23
23
 
24
24
  validates_presence_of :time, :description
25
25
  validate :has_credit_amounts?
@@ -71,7 +71,7 @@ module ESA
71
71
 
72
72
  private
73
73
 
74
- def default_values
74
+ def initialize_defaults
75
75
  self.time ||= Time.zone.now
76
76
  end
77
77
 
@@ -1,6 +1,6 @@
1
1
  module ESA
2
2
  class BalanceChecker
3
- def self.check(context)
3
+ def self.check(context, options = {})
4
4
  if context.can_be_persisted? and not context.freshness.nil?
5
5
  #context.event_count = context.events.created_before(context.freshness).count
6
6
  #context.flag_count = context.flags.created_before(context.freshness).count