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,191 @@
|
|
|
1
|
+
require "morty/cucumber/helpers"
|
|
2
|
+
require "chronic"
|
|
3
|
+
|
|
4
|
+
ParameterType(
|
|
5
|
+
name: "date",
|
|
6
|
+
regexp: /\d{4}-\d{2}-\d{2}/,
|
|
7
|
+
transformer: ->(date) { date.to_date }
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
ParameterType(
|
|
11
|
+
name: "decimal",
|
|
12
|
+
regexp: /[\d.]+/,
|
|
13
|
+
transformer: ->(str) { str.to_d }
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
Given /^an? (.+) accountant$/ do |type|
|
|
17
|
+
@accountant = accountant_class(type).new
|
|
18
|
+
|
|
19
|
+
unless type.to_sym == :sourceless
|
|
20
|
+
@accountant.source = Data.define(:id).new(id: 1)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Given /^the (?:accountant|configuration):$/ do |str|
|
|
25
|
+
@definition = str
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Given /^a start date of (.*)$/ do |text|
|
|
29
|
+
@accountant.start_date = Chronic.parse(text).to_date
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Given "an interest rate of {decimal}%" do |rate|
|
|
33
|
+
@accountant.rates = { @accountant.start_date => rate / 100.to_d }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Given "a daily interest rate of {decimal}%" do |rate|
|
|
37
|
+
@accountant.rates = { @accountant.start_date => rate / 100 * 365 }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Given "the interest rates:" do |table|
|
|
41
|
+
@accountant.rates = table.rows.map { |date, rate| [date, rate.to_d / 100] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Given "the schedule:" do |table|
|
|
45
|
+
@accountant.schedule = activities_from(table)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
When "I run the daily for {}" do |text|
|
|
49
|
+
case text
|
|
50
|
+
when "today" then @accountant.daily Date.today
|
|
51
|
+
when "tomorrow" then @accountant.daily Date.today + 1
|
|
52
|
+
when /^(\d+) days? from now$/ then @accountant.daily Date.today + $1.to_i
|
|
53
|
+
when /^(\d+) days?$/
|
|
54
|
+
$1.to_i.times do |i|
|
|
55
|
+
@accountant.daily Date.today + i
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
When "I simulate to/until {}" do |text|
|
|
61
|
+
@accountant.simulate_to Chronic.parse(text).to_date
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
When /^I simulate (?:this activity|these activities):$/ do |table|
|
|
65
|
+
schedule = activities_from(table)
|
|
66
|
+
|
|
67
|
+
@accountant.simulate do
|
|
68
|
+
schedule.each do |event|
|
|
69
|
+
send *event.values_at(:type, :date, :amount)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
When /^I apply (?:this activity|these activities):$/ do |table|
|
|
75
|
+
activities_from(table).each do |event|
|
|
76
|
+
@accountant.activity event[:type], event[:date], event[:amount]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
When "I apply a(n) {word} (activity )effective {date} for ${decimal}" do |type, date, amount|
|
|
81
|
+
@accountant.activity type, @accountant.date, amount, effective_date: date
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
When "I apply a(n) {word} (activity )for ${decimal}" do |type, amount|
|
|
85
|
+
@accountant.activity type, @accountant.date, amount
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
When "I save the accountant" do
|
|
89
|
+
@accountant.save
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
When "I reload the accountant" do
|
|
93
|
+
old = @accountant
|
|
94
|
+
|
|
95
|
+
@schedule = old.schedule
|
|
96
|
+
|
|
97
|
+
@accountant = old.class.new
|
|
98
|
+
@accountant.rates = old.rates
|
|
99
|
+
@accountant.source = old.source
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
When "I save and reload the accountant" do
|
|
103
|
+
date = @accountant.date
|
|
104
|
+
|
|
105
|
+
steps %Q{
|
|
106
|
+
When I save the accountant
|
|
107
|
+
When I reload the accountant
|
|
108
|
+
Given a start date of #{date}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@accountant.schedule = @schedule
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
When "I reload the accountant with a start date of {date}" do |date|
|
|
115
|
+
steps %Q{
|
|
116
|
+
When I reload the accountant
|
|
117
|
+
Given a start date of #{date}
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
When "I reload the accountant with a start_date of {date}" do |date|
|
|
122
|
+
steps %Q{
|
|
123
|
+
When I reload the accountant
|
|
124
|
+
Given a start date of #{date}
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# I cancel the 1st payment on 2026-01-01
|
|
129
|
+
When /^I (return|cancel) the (\d)(?:st|nd|rd|th) ([^ ]+) on (\d\d\d\d-\d\d-\d\d)$/ do |return_or_cancel, ordinal, type, date|
|
|
130
|
+
activities = @accountant.activities.select { |a| a.type?(type) && a.effective_date == date.to_date }
|
|
131
|
+
@accountant.send(return_or_cancel.to_sym, activities[ordinal.to_i - 1])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
When /^I (return|cancel) the (\d\d\d\d-\d\d-\d\d) ([^ ]+)$/ do |return_or_cancel, date, type|
|
|
135
|
+
@accountant.send(return_or_cancel.to_sym, @accountant.activities.detect { |a| a.type?(type) && a.effective_date == date.to_date })
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
When /^I (return|cancel) the (\d\d\d\d-\d\d-\d\d) ([^ ]+) on (\d\d\d\d-\d\d-\d\d)$/ do |return_or_cancel, date, type, cancel_date|
|
|
139
|
+
# @accountant.finish(cancel_date)?
|
|
140
|
+
@accountant.send(return_or_cancel.to_sym, @accountant.activities.detect { |a| a.type?(type) && a.effective_date == date.to_date })
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
When "I reverse the {date} {word}" do |date, type|
|
|
144
|
+
@accountant.reverse(@accountant.activities.detect { |a| a.type?(type) && a.effective_date == date })
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
Then "the {word} ledger has these balances:" do |ledger, table|
|
|
148
|
+
expect(@accountant.accounts(ledger)).to include(balances_from(table))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Then "the {word} ledger has these aggregated balances:" do |ledger, table|
|
|
152
|
+
expect(@accountant.balances(ledger)).to include(balances_from(table))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
Then "(I have )these balances:" do |table|
|
|
156
|
+
expect(@accountant.accounts).to eq(balances_from(table))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Then "(I have )all zero balances" do
|
|
160
|
+
expect(@accountant.accounts.values.uniq).to eq([0.to_d])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
Then "(I have )these activity counts:" do |table|
|
|
164
|
+
expect(@accountant.activities.count_by_type).to eq activity_counts_from(table)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Then /^(?:I (?:still )?have )?(?:an?|(\d+)) (?:(\w+) )?activit(?:y|ies)$/ do |count, type|
|
|
168
|
+
list = type ? @accountant.activities.with_type(type.to_sym) : @accountant.activities
|
|
169
|
+
|
|
170
|
+
expect(list.size).to eq((count || 1).to_i)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
Then /^the (?:accountant|configuration) is valid$/ do
|
|
174
|
+
expect { eval(@definition) }.not_to raise_error
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
Then /^the (?:accountant|configuration) is invalid$/ do
|
|
178
|
+
expect { eval(@definition) }.to raise_error(Morty::Error)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
Then "I cannot save" do
|
|
182
|
+
expect { @accountant.save }.to raise_error(Morty::Error, /missing source/)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
Then "I cannot simulate" do
|
|
186
|
+
expect { @accountant.simulate }.to raise_error(Morty::Error)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
Then "I debug" do
|
|
190
|
+
binding.irb
|
|
191
|
+
end
|
data/lib/morty/diff.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Morty
|
|
2
|
+
class Diff
|
|
3
|
+
def initialize(original, adjusted)
|
|
4
|
+
@original = original.activities
|
|
5
|
+
@adjusted = adjusted.activities
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def entries
|
|
9
|
+
original_sums = Sum.new(@original)
|
|
10
|
+
adjusted_sums = Sum.new(@adjusted.list - additional.list)
|
|
11
|
+
|
|
12
|
+
diff = adjusted_sums - original_sums
|
|
13
|
+
|
|
14
|
+
diff.map { |type, amount| Entry.new(entry_type: type, amount: amount) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def additional
|
|
18
|
+
@additional ||= @adjusted.reject { |a| a.type?(:interest) || original?(a) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def original?(activity)
|
|
22
|
+
@original.include?(activity)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Sum
|
|
26
|
+
def initialize(activities)
|
|
27
|
+
@entries = activities.flat_map(&:entries)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def calculate
|
|
31
|
+
return @hash if @hash
|
|
32
|
+
|
|
33
|
+
hash = @entries.each_with_object(Hash.new(0.to_d)) do |entry, sums|
|
|
34
|
+
sums[entry.type] += entry.amount
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@hash = reduce(hash)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param other Sum
|
|
41
|
+
def -(other)
|
|
42
|
+
left = calculate
|
|
43
|
+
right = other.calculate
|
|
44
|
+
|
|
45
|
+
types = left.keys + right.keys
|
|
46
|
+
|
|
47
|
+
result = types.uniq.each_with_object(Hash.new(0.to_d)) do |type, sums|
|
|
48
|
+
sums[type] = left[type] - right[type]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
reduce(result)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reduce(input)
|
|
55
|
+
input.each_with_object(Hash.new(0.to_d)) do |(type, amount), sums|
|
|
56
|
+
inverse = type.inverse
|
|
57
|
+
|
|
58
|
+
next if sums.key?(type) || sums.key?(inverse)
|
|
59
|
+
|
|
60
|
+
amount -= input[inverse] if input.key?(inverse)
|
|
61
|
+
|
|
62
|
+
case
|
|
63
|
+
when amount > 0 then sums[type] += amount
|
|
64
|
+
when amount < 0 then sums[inverse] += amount.abs
|
|
65
|
+
when amount == 0 then next
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/morty/dsl.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Morty
|
|
2
|
+
# DSL for defining Accountants.
|
|
3
|
+
#
|
|
4
|
+
# @example
|
|
5
|
+
# class Accountant < Morty::Accountant
|
|
6
|
+
# source :customer
|
|
7
|
+
#
|
|
8
|
+
# activity :sale do |amount|
|
|
9
|
+
# entry :cash, :revenue, amount
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Accountant.new(source: customer, start_date: Date.current)
|
|
14
|
+
module DSL
|
|
15
|
+
def self.extended(klass)
|
|
16
|
+
klass.class_attribute :activity_procs
|
|
17
|
+
klass.class_attribute :balances_list
|
|
18
|
+
klass.class_attribute :daily_proc
|
|
19
|
+
klass.class_attribute :daily_guard_proc
|
|
20
|
+
klass.class_attribute :ledgers
|
|
21
|
+
klass.class_attribute :source_name
|
|
22
|
+
|
|
23
|
+
klass.activity_procs = Hash.new { |hash, key| hash[key] = {} }
|
|
24
|
+
klass.balances_list = Hash.new { |hash, key| hash[key] = {} }
|
|
25
|
+
klass.ledgers = [:default]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def activity(name, &block)
|
|
29
|
+
raise Error, "missing block" unless block_given?
|
|
30
|
+
|
|
31
|
+
activity_procs[current_ledger][name] = block
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def balance(name, accounts)
|
|
35
|
+
balances_list[current_ledger][name.to_sym] = accounts.map(&:to_sym)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def current_ledger
|
|
39
|
+
@ledger || :default
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def daily(&block)
|
|
43
|
+
raise Error, "missing block" unless block_given?
|
|
44
|
+
|
|
45
|
+
self.daily_proc = block
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def daily_guard(&block)
|
|
49
|
+
self.daily_guard_proc = block
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# define activities scoped to a given ledger
|
|
53
|
+
def ledger(name, &block)
|
|
54
|
+
raise Error, "missing block" unless block_given?
|
|
55
|
+
|
|
56
|
+
@ledger = name.to_sym
|
|
57
|
+
|
|
58
|
+
self.ledgers |= [name]
|
|
59
|
+
|
|
60
|
+
instance_exec(&block)
|
|
61
|
+
ensure
|
|
62
|
+
@ledger = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def source(name)
|
|
66
|
+
name = name.to_sym
|
|
67
|
+
|
|
68
|
+
raise Error, "invalid source: #{name} method already defined in #{self}" if instance_methods.include?(name)
|
|
69
|
+
|
|
70
|
+
klass = name.to_s.classify.safe_constantize
|
|
71
|
+
raise Error, "invalid source: #{name}" unless klass
|
|
72
|
+
raise Error, "invalid source: #{name} missing #id" unless klass.instance_methods.include?(:id)
|
|
73
|
+
|
|
74
|
+
define_method(name) { source }
|
|
75
|
+
|
|
76
|
+
self.source_name = name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# define an activity that uses a waterfall
|
|
80
|
+
def waterfall(name, **kwargs)
|
|
81
|
+
activity name do
|
|
82
|
+
waterfall amount, **kwargs
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/morty/engine.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "active_record/railtie"
|
|
2
|
+
|
|
3
|
+
require "lookup_by"
|
|
4
|
+
|
|
5
|
+
module Morty
|
|
6
|
+
def self.table_name_prefix
|
|
7
|
+
"morty."
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Engine < ::Rails::Engine
|
|
11
|
+
isolate_namespace Morty
|
|
12
|
+
|
|
13
|
+
initializer :append_migrations do |app|
|
|
14
|
+
unless app.root.to_s.match?(root.to_s)
|
|
15
|
+
config.paths["db/migrate"].expanded.each do |path|
|
|
16
|
+
app.config.paths["db/migrate"] << path unless app.config.paths["db/migrate"].include?(path)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/morty/error.rb
ADDED
data/lib/morty/event.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Morty
|
|
2
|
+
class Event
|
|
3
|
+
attr_reader :info
|
|
4
|
+
|
|
5
|
+
def initialize(event)
|
|
6
|
+
case event
|
|
7
|
+
when Activity then @info = event.to_event
|
|
8
|
+
when Event then @info = event.info
|
|
9
|
+
when Hash then @info = { amount: event[:amount], date: event[:date], type: event[:type] }
|
|
10
|
+
else
|
|
11
|
+
raise Error, "Event.new takes an Activity, Event, or Hash(:amount, :date, :type)"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def amount
|
|
16
|
+
@info[:amount]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def date
|
|
20
|
+
@info[:date]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def type
|
|
24
|
+
@info[:type]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Morty
|
|
2
|
+
module List
|
|
3
|
+
class Activity
|
|
4
|
+
include Enumerable
|
|
5
|
+
|
|
6
|
+
attr_reader :list
|
|
7
|
+
|
|
8
|
+
delegate_missing_to :@list
|
|
9
|
+
|
|
10
|
+
def initialize(obj)
|
|
11
|
+
case obj
|
|
12
|
+
when self.class then @list = obj.list
|
|
13
|
+
when Array then @list = obj
|
|
14
|
+
when ActiveRecord::Relation then @list = obj.to_a
|
|
15
|
+
else raise Error
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def between(start, finish, by_accounting_date: false)
|
|
20
|
+
range = start.to_date .. finish.to_date
|
|
21
|
+
|
|
22
|
+
if by_accounting_date
|
|
23
|
+
select { |a| range.cover?(a.accounting_date) }
|
|
24
|
+
else
|
|
25
|
+
select { |a| range.cover?(a.effective_date) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def by_type
|
|
30
|
+
group_by(&:type)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def count_by_type
|
|
34
|
+
by_type.transform_values(&:size)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def each(&block) = list.each(&block)
|
|
38
|
+
|
|
39
|
+
def push(activity)
|
|
40
|
+
return if activity.entries.none?
|
|
41
|
+
|
|
42
|
+
list << activity
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reject(&block) = self.class.new list.reject(&block)
|
|
46
|
+
def select(&block) = self.class.new list.select(&block)
|
|
47
|
+
|
|
48
|
+
def sum_by_account
|
|
49
|
+
Morty::Account.sum_over_activities list.map(&:id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def with_type(type)
|
|
53
|
+
self.class.new(select { |a| a.activity_type?(type.to_sym) })
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/morty/rate.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Morty
|
|
2
|
+
class Rate
|
|
3
|
+
include Comparable
|
|
4
|
+
|
|
5
|
+
attr_reader :daily, :daily_leap, :monthly, :yearly
|
|
6
|
+
|
|
7
|
+
def initialize(annual_rate)
|
|
8
|
+
case annual_rate
|
|
9
|
+
when self.class
|
|
10
|
+
@rate = annual_rate.yearly
|
|
11
|
+
else
|
|
12
|
+
@rate = annual_rate.to_d
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@daily = @rate./(365).round(8)
|
|
16
|
+
@daily_leap = @rate./(366).round(8)
|
|
17
|
+
|
|
18
|
+
@monthly = @rate./(12).round(8)
|
|
19
|
+
@yearly = @rate
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def <=>(other)
|
|
23
|
+
case other
|
|
24
|
+
when Rate
|
|
25
|
+
yearly <=> other.yearly
|
|
26
|
+
else
|
|
27
|
+
yearly <=> self.class.new(other).yearly
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def annual_percentage
|
|
32
|
+
"%.3f" % (@yearly * 100)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def daily_for(date)
|
|
36
|
+
date.leap? ? daily_leap : daily
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def daily_percentage
|
|
40
|
+
"%.8f" % (daily * 100)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def daily_percentage_leap
|
|
44
|
+
"%.8f" % (daily_leap * 100)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<Morty::Rate: #{annual_percentage}% annually, #{daily_percentage}%/#{daily_percentage_leap}% daily>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_d
|
|
52
|
+
yearly
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_s
|
|
56
|
+
"%.2f" % @yearly
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Morty
|
|
2
|
+
class Schedule
|
|
3
|
+
include Enumerable
|
|
4
|
+
|
|
5
|
+
attr_reader :accountant, :events
|
|
6
|
+
|
|
7
|
+
def initialize(accountant, list)
|
|
8
|
+
@accountant = accountant
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@events = case list
|
|
12
|
+
when nil then []
|
|
13
|
+
when Array then list.map { |event| Event.new(event) }
|
|
14
|
+
when Schedule then list.events
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def <<(events)
|
|
19
|
+
@events += events.map { |event| Event.new(event) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def between(start, finish)
|
|
23
|
+
range = start.to_date .. finish.to_date
|
|
24
|
+
|
|
25
|
+
events.select { |e| range.cover?(e.date) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def each(&block)
|
|
29
|
+
@events.each(&block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def for(date)
|
|
33
|
+
select { |e| e.date == date }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/morty/seed.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Morty
|
|
2
|
+
module Seed
|
|
3
|
+
# Populate the accounts table
|
|
4
|
+
#
|
|
5
|
+
# Morty::Seed.accounts %w[
|
|
6
|
+
# cash
|
|
7
|
+
# interest
|
|
8
|
+
# principal
|
|
9
|
+
# principal_late
|
|
10
|
+
# principal_charged_off
|
|
11
|
+
# revenue
|
|
12
|
+
# ]
|
|
13
|
+
def self.accounts(list)
|
|
14
|
+
list.each_slice(2) do |type, account|
|
|
15
|
+
Account.where(account: account, account_type_id: type).first_or_create!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Account.lookup.reload
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Populate the account_types table
|
|
22
|
+
#
|
|
23
|
+
# Morty::Seed.account_types %w[
|
|
24
|
+
# A Asset DR
|
|
25
|
+
# X Expense DR
|
|
26
|
+
# L Liability CR
|
|
27
|
+
# E Equity CR
|
|
28
|
+
# R Revenue CR
|
|
29
|
+
# ]
|
|
30
|
+
def self.account_types(list)
|
|
31
|
+
raise ArgumentError, "expected triples" unless list.size % 3 == 0
|
|
32
|
+
|
|
33
|
+
list.each_slice(3) do |abbr, type, normal_balance|
|
|
34
|
+
AccountType.find_or_create_by!(account_type_id: abbr) do |at|
|
|
35
|
+
at.account_type = type
|
|
36
|
+
at.normal_balance = normal_balance.upcase
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
AccountType.lookup.reload
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Populate valid entry_types
|
|
44
|
+
#
|
|
45
|
+
# Morty::Seed.entry_types(:default, %w[
|
|
46
|
+
# principal cash
|
|
47
|
+
# cash principal
|
|
48
|
+
# cash interest
|
|
49
|
+
# interest revenue
|
|
50
|
+
# principal_late principal
|
|
51
|
+
# ]
|
|
52
|
+
def self.entry_types(ledger, list)
|
|
53
|
+
list.each_slice(2) do |dr, cr|
|
|
54
|
+
# Create the entry type and its reverse (debit and credit reversed)
|
|
55
|
+
EntryType.where(ledger: ledger, dr: dr, cr: cr).first_or_create!
|
|
56
|
+
EntryType.where(ledger: ledger, dr: cr, cr: dr).first_or_create!
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/morty/source.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Morty
|
|
2
|
+
# A wrapper class for the object for which we are accounting
|
|
3
|
+
class Source
|
|
4
|
+
attr_reader :object
|
|
5
|
+
|
|
6
|
+
delegate_missing_to :@object
|
|
7
|
+
|
|
8
|
+
def initialize(object)
|
|
9
|
+
raise Error, "source must define an id method" unless object.respond_to?(:id)
|
|
10
|
+
|
|
11
|
+
@object = object
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# has_many light
|
|
15
|
+
def activities
|
|
16
|
+
Activity.where(source_id: object.id)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/morty/version.rb
CHANGED
data/lib/morty.rb
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
|
+
require "morty/engine"
|
|
1
2
|
require "morty/version"
|
|
2
3
|
|
|
3
4
|
module Morty
|
|
4
|
-
|
|
5
|
+
autoload :Accountant, "morty/accountant"
|
|
6
|
+
autoload :Adjustment, "morty/adjustment"
|
|
7
|
+
autoload :Book, "morty/book"
|
|
8
|
+
autoload :Diff, "morty/diff"
|
|
9
|
+
autoload :DSL, "morty/dsl"
|
|
10
|
+
autoload :Error, "morty/error"
|
|
11
|
+
autoload :Event, "morty/event"
|
|
12
|
+
autoload :Rate, "morty/rate"
|
|
13
|
+
autoload :Source, "morty/source"
|
|
14
|
+
autoload :Schedule, "morty/schedule"
|
|
15
|
+
autoload :Seed, "morty/seed"
|
|
16
|
+
|
|
17
|
+
module Context
|
|
18
|
+
autoload :Activity, "morty/context/activity"
|
|
19
|
+
autoload :Daily, "morty/context/daily"
|
|
20
|
+
autoload :Simulation, "morty/context/simulation"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Cucumber
|
|
24
|
+
autoload :Helpers, "morty/cucumber/helpers"
|
|
25
|
+
autoload :Steps, "morty/cucumber/steps"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module List
|
|
29
|
+
autoload :Activity, "morty/list/activity"
|
|
30
|
+
end
|
|
5
31
|
end
|