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.
- checksums.yaml +5 -5
- data/.github/dependabot.yml +12 -0
- data/.github/workflows/ci.yml +107 -0
- data/.gitignore +17 -3
- data/.rubocop.yml +20 -0
- data/Appraisals +24 -0
- data/Gemfile +28 -1
- data/LICENSE +21 -0
- data/README.md +37 -7
- data/Rakefile +37 -0
- data/app/models/morty/account.rb +37 -0
- data/app/models/morty/account_type.rb +7 -0
- data/app/models/morty/activity.rb +147 -0
- data/app/models/morty/activity_type.rb +7 -0
- data/app/models/morty/application_record.rb +6 -0
- data/app/models/morty/entry.rb +24 -0
- data/app/models/morty/entry_type.rb +23 -0
- data/app/models/morty/ledger.rb +8 -0
- data/config/routes.rb +2 -0
- data/config.ru +7 -0
- data/cucumber.yml +2 -0
- data/db/migrate/20260224063053_create_morty_schema.rb +17 -0
- data/db/seeds.rb +18 -0
- data/db/sql/create_morty_schema.sql +479 -0
- data/features/accountant.feature +47 -0
- data/features/adjustment.feature +79 -0
- data/features/cancel.feature +130 -0
- data/features/daily.feature +42 -0
- data/features/default.feature +33 -0
- data/features/ledger.feature +57 -0
- data/features/retroactive.feature +92 -0
- data/features/return.feature +112 -0
- data/features/reversal.feature +57 -0
- data/features/simulation.feature +128 -0
- data/features/support/accountants/adjusting_accountant.rb +34 -0
- data/features/support/accountants/daily_accountant.rb +13 -0
- data/features/support/accountants/default_accountant.rb +2 -0
- data/features/support/accountants/defaulting_accountant.rb +32 -0
- data/features/support/accountants/multiple_ledgers_accountant.rb +51 -0
- data/features/support/accountants/simulating_accountant.rb +36 -0
- data/features/support/accountants/sourceless_accountant.rb +2 -0
- data/features/support/accountants/waterfalling_accountant.rb +15 -0
- data/features/support/env.rb +17 -0
- data/features/waterfall.feature +34 -0
- data/gemfiles/rails_7.0.gemfile +30 -0
- data/gemfiles/rails_7.0.gemfile.lock +494 -0
- data/gemfiles/rails_7.1.gemfile +30 -0
- data/gemfiles/rails_7.1.gemfile.lock +543 -0
- data/gemfiles/rails_7.2.gemfile +30 -0
- data/gemfiles/rails_7.2.gemfile.lock +539 -0
- data/gemfiles/rails_8.0.gemfile +30 -0
- data/gemfiles/rails_8.0.gemfile.lock +536 -0
- data/gemfiles/rails_8.1.gemfile +30 -0
- data/gemfiles/rails_8.1.gemfile.lock +538 -0
- data/lib/morty/accountant.rb +332 -0
- data/lib/morty/adjustment.rb +64 -0
- data/lib/morty/book.rb +54 -0
- data/lib/morty/context/activity.rb +52 -0
- data/lib/morty/context/daily.rb +23 -0
- data/lib/morty/context/simulation.rb +26 -0
- data/lib/morty/cucumber/helpers.rb +27 -0
- data/lib/morty/cucumber/steps.rb +191 -0
- data/lib/morty/diff.rb +71 -0
- data/lib/morty/dsl.rb +86 -0
- data/lib/morty/engine.rb +21 -0
- data/lib/morty/error.rb +3 -0
- data/lib/morty/event.rb +27 -0
- data/lib/morty/list/activity.rb +57 -0
- data/lib/morty/rate.rb +59 -0
- data/lib/morty/schedule.rb +36 -0
- data/lib/morty/seed.rb +60 -0
- data/lib/morty/source.rb +19 -0
- data/lib/morty/tasks/morty_tasks.rake +4 -0
- data/lib/morty/version.rb +1 -1
- data/lib/morty.rb +27 -1
- data/morty.gemspec +22 -19
- data/spec/dummy/Rakefile +6 -0
- data/spec/dummy/app/assets/images/.keep +0 -0
- data/spec/dummy/app/assets/stylesheets/application.css +15 -0
- data/spec/dummy/app/controllers/application_controller.rb +3 -0
- data/spec/dummy/app/controllers/concerns/.keep +0 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/jobs/application_job.rb +7 -0
- data/spec/dummy/app/models/application_record.rb +3 -0
- data/spec/dummy/app/models/concerns/.keep +0 -0
- data/spec/dummy/app/views/layouts/application.html.erb +28 -0
- data/spec/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/spec/dummy/app/views/pwa/service-worker.js +26 -0
- data/spec/dummy/bin/ci +6 -0
- data/spec/dummy/bin/dev +2 -0
- data/spec/dummy/bin/rails +4 -0
- data/spec/dummy/bin/rake +4 -0
- data/spec/dummy/bin/setup +35 -0
- data/spec/dummy/config/application.rb +48 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/cable.yml +10 -0
- data/spec/dummy/config/ci.rb +15 -0
- data/spec/dummy/config/database.yml +15 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +47 -0
- data/spec/dummy/config/environments/test.rb +53 -0
- data/spec/dummy/config/initializers/content_security_policy.rb +29 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/spec/dummy/config/initializers/inflections.rb +16 -0
- data/spec/dummy/config/locales/en.yml +31 -0
- data/spec/dummy/config/puma.rb +39 -0
- data/spec/dummy/config/routes.rb +3 -0
- data/spec/dummy/config/storage.yml +27 -0
- data/spec/dummy/config.ru +6 -0
- data/spec/dummy/db/seeds.rb +52 -0
- data/spec/dummy/log/.keep +0 -0
- data/spec/dummy/public/400.html +135 -0
- data/spec/dummy/public/404.html +135 -0
- data/spec/dummy/public/406-unsupported-browser.html +135 -0
- data/spec/dummy/public/422.html +135 -0
- data/spec/dummy/public/500.html +135 -0
- data/spec/dummy/public/icon.png +0 -0
- data/spec/dummy/public/icon.svg +3 -0
- data/spec/lib/accountant_spec.rb +236 -0
- data/spec/lib/book_spec.rb +91 -0
- data/spec/lib/diff_spec.rb +102 -0
- data/spec/lib/event_spec.rb +53 -0
- data/spec/lib/list/activity_spec.rb +117 -0
- data/spec/lib/schedule_spec.rb +106 -0
- data/spec/lib/source_spec.rb +31 -0
- data/spec/models/account_spec.rb +48 -0
- data/spec/models/activity_spec.rb +139 -0
- data/spec/models/entry_spec.rb +41 -0
- data/spec/models/entry_type_spec.rb +43 -0
- data/spec/rate_spec.rb +83 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/support/test_helpers.rb +25 -0
- metadata +193 -16
- 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)
|