morty 0.0.1 → 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 (134) hide show
  1. checksums.yaml +5 -5
  2. data/.github/dependabot.yml +12 -0
  3. data/.github/workflows/ci.yml +107 -0
  4. data/.gitignore +17 -3
  5. data/.rubocop.yml +20 -0
  6. data/Appraisals +24 -0
  7. data/Gemfile +28 -1
  8. data/LICENSE +21 -0
  9. data/README.md +37 -7
  10. data/Rakefile +37 -0
  11. data/app/models/morty/account.rb +37 -0
  12. data/app/models/morty/account_type.rb +7 -0
  13. data/app/models/morty/activity.rb +147 -0
  14. data/app/models/morty/activity_type.rb +7 -0
  15. data/app/models/morty/application_record.rb +6 -0
  16. data/app/models/morty/entry.rb +24 -0
  17. data/app/models/morty/entry_type.rb +23 -0
  18. data/app/models/morty/ledger.rb +8 -0
  19. data/config/routes.rb +2 -0
  20. data/config.ru +7 -0
  21. data/cucumber.yml +2 -0
  22. data/db/migrate/20260224063053_create_morty_schema.rb +17 -0
  23. data/db/seeds.rb +18 -0
  24. data/db/sql/create_morty_schema.sql +479 -0
  25. data/features/accountant.feature +47 -0
  26. data/features/adjustment.feature +79 -0
  27. data/features/cancel.feature +130 -0
  28. data/features/daily.feature +42 -0
  29. data/features/default.feature +33 -0
  30. data/features/ledger.feature +57 -0
  31. data/features/retroactive.feature +92 -0
  32. data/features/return.feature +112 -0
  33. data/features/reversal.feature +57 -0
  34. data/features/simulation.feature +128 -0
  35. data/features/support/accountants/adjusting_accountant.rb +34 -0
  36. data/features/support/accountants/daily_accountant.rb +13 -0
  37. data/features/support/accountants/default_accountant.rb +2 -0
  38. data/features/support/accountants/defaulting_accountant.rb +32 -0
  39. data/features/support/accountants/multiple_ledgers_accountant.rb +51 -0
  40. data/features/support/accountants/simulating_accountant.rb +36 -0
  41. data/features/support/accountants/sourceless_accountant.rb +2 -0
  42. data/features/support/accountants/waterfalling_accountant.rb +15 -0
  43. data/features/support/env.rb +17 -0
  44. data/features/waterfall.feature +34 -0
  45. data/gemfiles/rails_7.0.gemfile +30 -0
  46. data/gemfiles/rails_7.0.gemfile.lock +494 -0
  47. data/gemfiles/rails_7.1.gemfile +30 -0
  48. data/gemfiles/rails_7.1.gemfile.lock +543 -0
  49. data/gemfiles/rails_7.2.gemfile +30 -0
  50. data/gemfiles/rails_7.2.gemfile.lock +539 -0
  51. data/gemfiles/rails_8.0.gemfile +30 -0
  52. data/gemfiles/rails_8.0.gemfile.lock +536 -0
  53. data/gemfiles/rails_8.1.gemfile +30 -0
  54. data/gemfiles/rails_8.1.gemfile.lock +538 -0
  55. data/lib/morty/accountant.rb +332 -0
  56. data/lib/morty/adjustment.rb +64 -0
  57. data/lib/morty/book.rb +54 -0
  58. data/lib/morty/context/activity.rb +52 -0
  59. data/lib/morty/context/daily.rb +23 -0
  60. data/lib/morty/context/simulation.rb +26 -0
  61. data/lib/morty/cucumber/helpers.rb +27 -0
  62. data/lib/morty/cucumber/steps.rb +191 -0
  63. data/lib/morty/diff.rb +71 -0
  64. data/lib/morty/dsl.rb +86 -0
  65. data/lib/morty/engine.rb +21 -0
  66. data/lib/morty/error.rb +3 -0
  67. data/lib/morty/event.rb +27 -0
  68. data/lib/morty/list/activity.rb +57 -0
  69. data/lib/morty/rate.rb +59 -0
  70. data/lib/morty/schedule.rb +36 -0
  71. data/lib/morty/seed.rb +60 -0
  72. data/lib/morty/source.rb +19 -0
  73. data/lib/morty/tasks/morty_tasks.rake +4 -0
  74. data/lib/morty/version.rb +1 -1
  75. data/lib/morty.rb +27 -1
  76. data/morty.gemspec +22 -19
  77. data/spec/dummy/Rakefile +6 -0
  78. data/spec/dummy/app/assets/images/.keep +0 -0
  79. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  80. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  81. data/spec/dummy/app/controllers/concerns/.keep +0 -0
  82. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  83. data/spec/dummy/app/jobs/application_job.rb +7 -0
  84. data/spec/dummy/app/models/application_record.rb +3 -0
  85. data/spec/dummy/app/models/concerns/.keep +0 -0
  86. data/spec/dummy/app/views/layouts/application.html.erb +28 -0
  87. data/spec/dummy/app/views/pwa/manifest.json.erb +22 -0
  88. data/spec/dummy/app/views/pwa/service-worker.js +26 -0
  89. data/spec/dummy/bin/ci +6 -0
  90. data/spec/dummy/bin/dev +2 -0
  91. data/spec/dummy/bin/rails +4 -0
  92. data/spec/dummy/bin/rake +4 -0
  93. data/spec/dummy/bin/setup +35 -0
  94. data/spec/dummy/config/application.rb +48 -0
  95. data/spec/dummy/config/boot.rb +5 -0
  96. data/spec/dummy/config/cable.yml +10 -0
  97. data/spec/dummy/config/ci.rb +15 -0
  98. data/spec/dummy/config/database.yml +15 -0
  99. data/spec/dummy/config/environment.rb +5 -0
  100. data/spec/dummy/config/environments/development.rb +47 -0
  101. data/spec/dummy/config/environments/test.rb +53 -0
  102. data/spec/dummy/config/initializers/content_security_policy.rb +29 -0
  103. data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  104. data/spec/dummy/config/initializers/inflections.rb +16 -0
  105. data/spec/dummy/config/locales/en.yml +31 -0
  106. data/spec/dummy/config/puma.rb +39 -0
  107. data/spec/dummy/config/routes.rb +3 -0
  108. data/spec/dummy/config/storage.yml +27 -0
  109. data/spec/dummy/config.ru +6 -0
  110. data/spec/dummy/db/seeds.rb +52 -0
  111. data/spec/dummy/log/.keep +0 -0
  112. data/spec/dummy/public/400.html +135 -0
  113. data/spec/dummy/public/404.html +135 -0
  114. data/spec/dummy/public/406-unsupported-browser.html +135 -0
  115. data/spec/dummy/public/422.html +135 -0
  116. data/spec/dummy/public/500.html +135 -0
  117. data/spec/dummy/public/icon.png +0 -0
  118. data/spec/dummy/public/icon.svg +3 -0
  119. data/spec/lib/accountant_spec.rb +236 -0
  120. data/spec/lib/book_spec.rb +91 -0
  121. data/spec/lib/diff_spec.rb +102 -0
  122. data/spec/lib/event_spec.rb +53 -0
  123. data/spec/lib/list/activity_spec.rb +117 -0
  124. data/spec/lib/schedule_spec.rb +106 -0
  125. data/spec/lib/source_spec.rb +31 -0
  126. data/spec/models/account_spec.rb +48 -0
  127. data/spec/models/activity_spec.rb +139 -0
  128. data/spec/models/entry_spec.rb +41 -0
  129. data/spec/models/entry_type_spec.rb +43 -0
  130. data/spec/rate_spec.rb +83 -0
  131. data/spec/spec_helper.rb +36 -0
  132. data/spec/support/test_helpers.rb +25 -0
  133. metadata +193 -16
  134. data/LICENSE.txt +0 -22
@@ -0,0 +1,332 @@
1
+ require "bigdecimal"
2
+
3
+ module Morty
4
+ # An Accountant manages a set of Books, each with its own Ledger and Account balances.
5
+ #
6
+ # Accounting for things correctly and efficiently is hard. Morty makes no warranties
7
+ # about the correctness of its implementation, and it is up to you to ensure that your
8
+ # implementation of the Accountant class is correct for your domain. Morty provides a set of
9
+ # tools to help you do that, but it is your responsibility to use them correctly.
10
+ #
11
+ # If you use any class to manage the tables in the morty schema, other than the Accountant,
12
+ # you do so at your own risk. The Accountant is the sole class be responsible for
13
+ # creating and applying Activities, and for managing the balances of Accounts.
14
+ #
15
+ # Double-entry transactions (activities and entries)
16
+ #
17
+ # Activity | DR | CR | Amount
18
+ # -----------------------------------------
19
+ # issue | principal | cash | 5000
20
+ # interest | interest | cash | 50
21
+ # payment | cash | interest | 50
22
+ # | cash | principal | 950
23
+ #
24
+ # Single-entry transactions (details view)
25
+ #
26
+ # Activity | Account | Amount
27
+ # --------------------------------
28
+ # issue | principal | 5000
29
+ # interest | interest | 50
30
+ # payment | cash | -1000
31
+ # | principal | -950
32
+ # | interest | -50
33
+ #
34
+ # Some Martin Fowler ideas that could be useful
35
+ #
36
+ # Accounting Patterns https://martinfowler.com/apsupp/accounting.pdf
37
+ #
38
+ # Retroactive Event https://martinfowler.com/eaaDev/RetroactiveEvent.html
39
+ # Parallel Model https://martinfowler.com/eaaDev/ParallelModel.html
40
+ # Temporal Object https://martinfowler.com/eaaDev/TemporalObject.html
41
+ # Temporal Property https://martinfowler.com/eaaDev/TemporalProperty.html
42
+ # Time Point https://martinfowler.com/eaaDev/TimePoint.html
43
+ # Effectivity Period https://martinfowler.com/eaaDev/Effectivity.html
44
+ # Snapshot https://martinfowler.com/eaaDev/Snapshot.html
45
+ # Proposed Object https://martinfowler.com/eaaDev/ProposedObject.html
46
+ # Audit Log https://martinfowler.com/eaaDev/AuditLog.html
47
+ # Reversal Adjustment
48
+ # Difference Adjustment
49
+ # Posting Rule
50
+ #
51
+ # It implements these, in a very specific way
52
+ #
53
+ # Account https://martinfowler.com/eaaDev/Account.html
54
+ # Accounting Transaction https://martinfowler.com/eaaDev/AccountingTransaction.html
55
+ #
56
+ # We do not implement:
57
+ #
58
+ # Replacement Adjustment
59
+ #
60
+ class Accountant
61
+ # List::Activity
62
+ attr_reader :activities
63
+
64
+ attr_reader :books
65
+ attr_reader :ledgers
66
+
67
+ attr_reader :date
68
+
69
+ attr_reader :rates
70
+
71
+ attr_reader :schedule
72
+
73
+ attr_reader :source
74
+ attr_reader :source_name
75
+
76
+ attr_reader :start_date
77
+
78
+ attr_reader :simulated_to
79
+
80
+ def self.inherited(base)
81
+ base.extend DSL
82
+ end
83
+
84
+ def initialize
85
+ @accounts = Hash.new { |hash, key| hash[key] = Hash.new { |h, k| h[k] = 0.to_d } }
86
+ @books = ledgers.to_h { |name| [name, Book.new(name, accountant: self)] }
87
+
88
+ # default to an schedule
89
+ @schedule = Schedule.new(self, nil)
90
+ end
91
+
92
+ def accounts(ledger = :default)
93
+ @accounts[ledger.to_sym]
94
+ end
95
+
96
+ def activities=(list)
97
+ @activities = List::Activity.new(list || [])
98
+
99
+ @activities.each do |activity|
100
+ books.each do |name, book|
101
+ book.apply activity
102
+ end
103
+ end
104
+ end
105
+
106
+ def activity(type, date = nil, amount = nil, effective_date: nil, idempotent_uuid: nil)
107
+ type = type.to_sym
108
+ date = date.try(:to_date) || self.date
109
+ amount = amount.try :to_d
110
+
111
+ check_setup
112
+
113
+ activity = build_activity(type) do |a|
114
+ a.idempotent_uuid = idempotent_uuid
115
+
116
+ a.accounting_date = date
117
+ a.effective_date = effective_date.try(:to_date) if effective_date
118
+ a.amount = amount
119
+ end
120
+
121
+ if activity.retroactive?
122
+ adjust(effective_date, with: activity)
123
+ else
124
+ books.each do |name, book|
125
+ raise "missing activity" unless activity_procs[name].key?(type)
126
+
127
+ Context::Activity.new(book, activity).tap do |ctx|
128
+ ctx.instance_exec(&activity_procs[name][type])
129
+ end
130
+ end
131
+
132
+ activities.push activity
133
+ end
134
+
135
+ activity
136
+ end
137
+
138
+ # @return [Morty::Activity]
139
+ def adjust(past_date, with: nil)
140
+ Adjustment.new(self, past_date, date).adjust(with)
141
+ end
142
+
143
+ # @return [Morty::Accountant]
144
+ def adjusting_accountant(**kwargs)
145
+ options = { rates:, source:, schedule:, start_date: }.merge(kwargs.compact)
146
+
147
+ self.class.new.tap do |adjusting|
148
+ adjusting.rates = options[:rates]
149
+ adjusting.source = options[:source]
150
+ adjusting.schedule = options[:schedule]
151
+ adjusting.start_date = options[:start_date]
152
+ end
153
+ end
154
+
155
+ # Apply a list of activities to the books
156
+ def apply(list)
157
+ Array(list).each do |activity|
158
+ books.each do |name, book|
159
+ book.apply activity
160
+ end
161
+
162
+ activities.push(activity)
163
+ end
164
+
165
+ list
166
+ end
167
+
168
+ def balances(ledger = :default)
169
+ books[ledger.to_sym].balances
170
+ end
171
+
172
+ def build_activity(type, attributes = {})
173
+ defaults = {
174
+ accounting_date: date,
175
+ source_id: source.id,
176
+ type: type
177
+ }
178
+
179
+ Activity.new(defaults.merge(attributes)) do |activity|
180
+ yield activity if block_given?
181
+ end
182
+ end
183
+
184
+ # Cancel an activity
185
+ #
186
+ # @param incorrect [Morty::Activity] Activity to cancel
187
+ #
188
+ # @return [Morty::Activity] cancelling activity
189
+ def cancel(incorrect, type = "cancel", idempotent_uuid: nil)
190
+ activity = incorrect.cancel(date, type)
191
+
192
+ activity.idempotent_uuid = idempotent_uuid
193
+
194
+ idx = activities.index { |a| a.object_id == incorrect.object_id } ||
195
+ incorrect.persisted? && activities.index { |a| a.id == incorrect.id } ||
196
+ activities.index(incorrect)
197
+
198
+ activities[idx] = incorrect
199
+
200
+ apply(activity)
201
+
202
+ adjust(activity.effective_date)
203
+ activity
204
+ end
205
+
206
+ def check_setup
207
+ raise Error, "missing source" unless source
208
+ raise Error, "missing start_date" unless start_date
209
+ end
210
+
211
+ def daily(date = nil)
212
+ @date = date.to_date if date
213
+
214
+ ctx = Context::Daily.new(self)
215
+ ctx.instance_exec(&daily_proc) if ctx.instance_exec(&daily_guard_proc)
216
+ end
217
+
218
+ def daily_schedule
219
+ schedule.for(date).each do |event|
220
+ activity event.type, event.date, event.amount
221
+ end
222
+
223
+ @simulated_to = date
224
+ end
225
+
226
+ # @note dsl could expose a method to define a domain-specific synonym for "cancel"
227
+ def return(incorrect, idempotent_uuid: nil)
228
+ cancel(incorrect, "return", idempotent_uuid:)
229
+ end
230
+
231
+ def rates
232
+ @rates.to_h
233
+ end
234
+
235
+ def rates=(list)
236
+ raise Error, "rates already set" if @rates.present?
237
+
238
+ @rates = list.to_h.map { |date, rate| [date.to_date, Rate.new(rate)] }.sort.reverse
239
+ end
240
+
241
+ def rate_for(date = self.start_date)
242
+ @rates.detect { |eff_date, _| date >= eff_date }&.last
243
+ end
244
+
245
+ def reverse(prior_activity, type = "reversal")
246
+ apply prior_activity.reverse(date, type)
247
+ end
248
+
249
+ def save
250
+ raise Error, "missing source" unless source
251
+
252
+ ApplicationRecord.transaction(requires_new: true) do
253
+ activities.each(&:save!)
254
+ end
255
+ end
256
+
257
+ def schedule=(list)
258
+ @schedule = case list
259
+ when Schedule then list
260
+ else
261
+ Schedule.new(self, list)
262
+ end
263
+ end
264
+
265
+ def source=(obj)
266
+ return unless obj
267
+
268
+ raise Error, "multiple sources" if source
269
+
270
+ # wrap the incoming object
271
+ @source = obj.is_a?(Source) ? obj : Source.new(obj)
272
+ end
273
+
274
+ def start_date=(date)
275
+ raise Error, "invalid date" unless date.respond_to?(:to_date)
276
+ raise Error, "start_date already set" if start_date
277
+
278
+ @date = @start_date = date.to_date
279
+
280
+ raise Error, "future start_date" if start_date > Date.current
281
+
282
+ load_activities
283
+ end
284
+
285
+ def simulate(&block)
286
+ raise Error, "missing start_date" unless start_date
287
+
288
+ Context::Simulation.new(self).tap do |ctx|
289
+ ctx.instance_exec(&block) if block_given?
290
+ end
291
+ end
292
+
293
+ def simulated?(date)
294
+ return false unless simulated_to
295
+
296
+ simulated_to >= date.to_date
297
+ end
298
+
299
+ def simulate_to(date)
300
+ simulate { finish date.to_date }
301
+ end
302
+
303
+ def simulate_today
304
+ return if simulated?(date)
305
+
306
+ daily
307
+ daily_schedule
308
+ end
309
+
310
+ def tomorrow
311
+ simulate_today
312
+
313
+ @date += 1
314
+
315
+ simulate_today
316
+ end
317
+
318
+ private def load_activities
319
+ check_setup
320
+
321
+ self.activities = Activity.with_source(source).until(start_date)
322
+
323
+ @simulated_to = activities.map(&:effective_date).max
324
+
325
+ Account.sum_by_source(source, effective_date: start_date).each do |ledger, accounts|
326
+ accounts.each do |account, balance|
327
+ @accounts[ledger][account] = balance
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,64 @@
1
+ module Morty
2
+ class Adjustment
3
+ attr_reader :accountant
4
+
5
+ def initialize(accountant, retroactive_date, accounting_date, excluded_activities: [])
6
+ @accountant = accountant
7
+
8
+ @retroactive_date = retroactive_date
9
+ @accounting_date = accounting_date
10
+
11
+ @excluded_activities = excluded_activities
12
+
13
+ @min_date = accountant.activities.map(&:effective_date).min || retroactive_date
14
+ end
15
+
16
+ # @todo simplify this
17
+ def adjust(activity)
18
+ adjuster.simulate_to(@retroactive_date)
19
+
20
+ if activity
21
+ activity.entries = adjuster.activity(activity.type, activity.effective_date, activity.amount).entries
22
+ accountant.apply(activity)
23
+ end
24
+
25
+ adjuster.simulate_to(@accounting_date)
26
+
27
+ adjustment = adjuster.build_activity(:adjustment) do |a|
28
+ a.entries = diff.entries
29
+ end
30
+
31
+ additional = diff.additional.to_a
32
+ additional.each { |a| a.accounting_date = @accounting_date }
33
+
34
+ accountant.apply additional
35
+ accountant.apply adjustment
36
+
37
+ activity
38
+ end
39
+
40
+ private def adjuster
41
+ @adjuster ||= accountant.adjusting_accountant(**options)
42
+ end
43
+
44
+ private def diff
45
+ @diff ||= Diff.new(accountant, adjuster)
46
+ end
47
+
48
+ private def options
49
+ {
50
+ schedule: schedule,
51
+ start_date: @min_date - 1
52
+ }
53
+ end
54
+
55
+ private def schedule
56
+ accountant.activities.reject(&:cancelling?)
57
+ .reject { |a| a.type?(:interest) || a.type?(:adjustment) }
58
+ .reject { |a| @excluded_activities.include?(a) }
59
+ .between(@min_date, @accounting_date)
60
+ .list
61
+ .sort_by { |a| [a.effective_date, a.id || Float::INFINITY] }
62
+ end
63
+ end
64
+ end
data/lib/morty/book.rb ADDED
@@ -0,0 +1,54 @@
1
+ module Morty
2
+ class Book
3
+ attr_reader :accountant, :ledger
4
+
5
+ def initialize(ledger, accountant:)
6
+ raise "missing ledger" unless ledger
7
+
8
+ @ledger = ledger
9
+ @accountant = accountant
10
+ end
11
+
12
+ def accounts
13
+ accountant.accounts(ledger)
14
+ end
15
+
16
+ def apply(entry)
17
+ case entry
18
+ when Entry
19
+ accounts[entry.dr] += entry.amount
20
+ accounts[entry.cr] -= entry.amount
21
+ when Activity
22
+ entry.entries.select { |e| e.ledger == ledger }.each do |entry|
23
+ apply(entry)
24
+ end
25
+ end
26
+ end
27
+
28
+ def balances_list
29
+ accountant.balances_list[ledger]
30
+ end
31
+
32
+ # can define per ledger
33
+ #
34
+ # returns { balance_name => value }
35
+ def balances
36
+ balances_list.to_h { |label, list| [label, list.sum { |name| accounts[name] }] }
37
+ end
38
+
39
+ def entry(dr, cr, amount, activity:)
40
+ amount = amount.try(:to_d)
41
+
42
+ return if amount.nil? || amount.zero?
43
+
44
+ raise "entry amount cannot be negative" if amount < 0
45
+
46
+ type = EntryType.find_by_accounts(dr, cr, ledger)
47
+
48
+ entry = activity.entries.build(entry_type: type, amount: amount)
49
+
50
+ apply(entry)
51
+ entry
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,52 @@
1
+ module Morty
2
+ module Context
3
+ class Activity
4
+ attr_reader :accountant, :activity, :book
5
+
6
+ delegate :rates, :source, to: :accountant
7
+ delegate :accounts, :balances, to: :book
8
+ delegate :amount, to: :activity
9
+
10
+ def initialize(book, activity)
11
+ @accountant = book.accountant
12
+ @activity = activity
13
+ @book = book
14
+
15
+ define_singleton_method(accountant.source_name) { source } if accountant.source_name
16
+ end
17
+
18
+ def entry(dr, cr, amount)
19
+ book.entry(dr, cr, amount, activity: activity)
20
+ end
21
+
22
+ def waterfall(amount, limit: nil, complete: false, entries:)
23
+ remaining = amount
24
+
25
+ limit = limit.try :to_sym
26
+
27
+ list = entries.split.map(&:to_sym).each_slice(2)
28
+
29
+ last = list.size
30
+
31
+ list.with_index(1) do |(dr, cr), i|
32
+ limit = nil if complete && i == last
33
+
34
+ amount = case limit
35
+ when :dr
36
+ accounts.key?(dr) ? [remaining, accounts[dr].abs].min : 0.to_d
37
+ when :cr
38
+ accounts.key?(cr) ? [remaining, accounts[cr].abs].min : 0.to_d
39
+ else
40
+ remaining
41
+ end
42
+
43
+ entry dr, cr, amount
44
+
45
+ remaining -= amount
46
+
47
+ break if remaining.zero?
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,23 @@
1
+ module Morty
2
+ module Context
3
+ class Daily
4
+ attr_reader :accountant
5
+
6
+ delegate_missing_to :@accountant
7
+
8
+ def initialize(accountant)
9
+ @accountant = accountant
10
+
11
+ define_singleton_method(accountant.source_name) { source } if accountant.source_name
12
+ end
13
+
14
+ def today
15
+ accountant.date
16
+ end
17
+
18
+ def rate
19
+ rates.detect { |date, _| today >= date }&.last
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ module Morty
2
+ module Context
3
+ class Simulation
4
+ attr_reader :accountant
5
+
6
+ def initialize(accountant)
7
+ @accountant = accountant
8
+ end
9
+
10
+ # the "_" argument is to match the arity of Accountant#activity
11
+ def finish(date, _ = nil)
12
+ date = date.to_date
13
+
14
+ accountant.simulate_today
15
+ accountant.tomorrow while accountant.date < date
16
+ end
17
+
18
+ ActivityType.pluck(:name).each do |type|
19
+ define_method(type) do |date, amount|
20
+ finish date
21
+ accountant.activity type, date, amount
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ module Morty::Cucumber::Helpers
2
+ def accountant_class(type)
3
+ "#{type.parameterize.underscore}_accountant".classify.constantize
4
+ end
5
+
6
+ def activities_from(table)
7
+ table.raw.map do |row|
8
+ type, *rest, amount = row
9
+
10
+ {
11
+ type: type.to_sym,
12
+ date: rest.first&.to_date || Date.current,
13
+ amount: amount.presence&.to_d
14
+ }
15
+ end
16
+ end
17
+
18
+ def activity_counts_from(table)
19
+ table.raw.to_h { |type, count| [type.to_sym, count.to_i] }
20
+ end
21
+
22
+ def balances_from(table)
23
+ table.raw.to_h { |account, amount| [account.to_sym, amount.presence&.to_d] }
24
+ end
25
+ end
26
+
27
+ World(Morty::Cucumber::Helpers)