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,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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Morty
2
+ class Error < StandardError; end
3
+ end
@@ -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
@@ -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
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :morty do
3
+ # # Task goes here
4
+ # end
data/lib/morty/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Morty
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
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
- # Your code goes here...
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