mudrat_projector 0.9.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +2 -0
  3. data/.ruby-version +1 -0
  4. data/.travis.yml +3 -0
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +49 -0
  7. data/README.md +46 -0
  8. data/Rakefile +26 -0
  9. data/bin/testrb +3 -0
  10. data/finance_fu.txt +41 -0
  11. data/lib/mudrat_projector/account.rb +75 -0
  12. data/lib/mudrat_projector/amortizer.rb +103 -0
  13. data/lib/mudrat_projector/banker_rounding.rb +11 -0
  14. data/lib/mudrat_projector/chart_of_accounts.rb +119 -0
  15. data/lib/mudrat_projector/date_diff.rb +169 -0
  16. data/lib/mudrat_projector/projection.rb +45 -0
  17. data/lib/mudrat_projector/projector.rb +71 -0
  18. data/lib/mudrat_projector/schedule.rb +45 -0
  19. data/lib/mudrat_projector/scheduled_transaction.rb +45 -0
  20. data/lib/mudrat_projector/tax_calculation.rb +154 -0
  21. data/lib/mudrat_projector/tax_calculator.rb +144 -0
  22. data/lib/mudrat_projector/tax_values_by_year.yml +102 -0
  23. data/lib/mudrat_projector/transaction.rb +71 -0
  24. data/lib/mudrat_projector/transaction_entry.rb +126 -0
  25. data/lib/mudrat_projector/transaction_handler.rb +19 -0
  26. data/lib/mudrat_projector/validator.rb +49 -0
  27. data/lib/mudrat_projector/version.rb +3 -0
  28. data/lib/mudrat_projector.rb +27 -0
  29. data/mudrat_projector.gemspec +28 -0
  30. data/test/integrations/long_term_projection_test.rb +42 -0
  31. data/test/integrations/mortgage_test.rb +85 -0
  32. data/test/integrations/self_employed_tax_calculation_test.rb +44 -0
  33. data/test/models/accounts_test.rb +39 -0
  34. data/test/models/chart_of_accounts_test.rb +170 -0
  35. data/test/models/date_diff_test.rb +117 -0
  36. data/test/models/projection_test.rb +62 -0
  37. data/test/models/projector_test.rb +168 -0
  38. data/test/models/schedule_test.rb +68 -0
  39. data/test/models/scheduled_transaction_test.rb +47 -0
  40. data/test/models/tax_calculator_test.rb +145 -0
  41. data/test/models/transaction_entry_test.rb +37 -0
  42. data/test/models/transaction_handler_test.rb +67 -0
  43. data/test/models/transaction_test.rb +66 -0
  44. data/test/models/validator_test.rb +45 -0
  45. data/test/test_helper.rb +69 -0
  46. metadata +204 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 27f998c4d27ead6cc375a7fb9fbde9d41851189e
4
+ data.tar.gz: 91a01f95d654f29d459cbc94698fb0bb75997ecf
5
+ SHA512:
6
+ metadata.gz: 4c8fcab59b9a618c9b9415010703ab4d1d2c8d8a13b9a221fc3eaa26d04680612b948f2588f39cc2431a33ef1f500b169fe555731a8755a7e479415e241fc1ed
7
+ data.tar.gz: fd954a625bd5d802c9563044a11360d4dba16e1a14e81f4d8250816bb95b577a554dbec765beed4f7e7e20e4422b76bae2218f9293a9788a96b2e75be0874838
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ tags
2
+ tmp
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.0.0-p353
data/.travis.yml ADDED
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.0.0-p353
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in mudrat_projector.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,49 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ mudrat_projector (0.0.1)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ansi (1.4.3)
10
+ builder (3.2.2)
11
+ coderay (1.1.0)
12
+ docile (1.1.1)
13
+ hashie (2.0.5)
14
+ method_source (0.8.2)
15
+ minitest (5.0.8)
16
+ minitest-reporters (1.0.0)
17
+ ansi
18
+ builder
19
+ minitest (>= 5.0)
20
+ powerbar
21
+ multi_json (1.8.2)
22
+ powerbar (1.0.11)
23
+ ansi (~> 1.4.0)
24
+ hashie (>= 1.1.0)
25
+ pry (0.9.12.4)
26
+ coderay (~> 1.0)
27
+ method_source (~> 0.8)
28
+ slop (~> 3.4)
29
+ rake (10.1.0)
30
+ simplecov (0.8.2)
31
+ docile (~> 1.1.0)
32
+ multi_json
33
+ simplecov-html (~> 0.8.0)
34
+ simplecov-html (0.8.0)
35
+ slop (3.4.7)
36
+ timecop (0.7.0)
37
+
38
+ PLATFORMS
39
+ ruby
40
+
41
+ DEPENDENCIES
42
+ bundler (~> 1.3)
43
+ minitest
44
+ minitest-reporters
45
+ mudrat_projector!
46
+ pry
47
+ rake
48
+ simplecov
49
+ timecop
data/README.md ADDED
@@ -0,0 +1,46 @@
1
+ [![Build Status](https://api.travis-ci.org/ntl/mudrat-projector.png)](https://travis-ci.org/ntl/mudrat-projector)
2
+
3
+ # MudratProjector
4
+
5
+ Simple financial projection built in ruby.
6
+
7
+ ```ruby
8
+ include MudratProjector
9
+
10
+ projector = Projector.new from: "1/1/2000"
11
+ projector.add_account :checking, type: :asset
12
+ projector.add_account :uncle_vinnie, type: :revenue
13
+ projector.add_transaction(
14
+ date: "7/4/2000",
15
+ debit: { amount: 6000, account_id: :checking },
16
+ credit: { amount: 6000, account_id: :uncle_vinnie }
17
+ )
18
+ projector.project to: "12/31/2000"
19
+ assert_equal 5000, projector.account_balance(:checking)
20
+ ```
21
+
22
+ ## Installation
23
+
24
+ Add this line to your application's Gemfile:
25
+
26
+ gem 'mudrat_projector'
27
+
28
+ And then execute:
29
+
30
+ $ bundle
31
+
32
+ Or install it yourself as:
33
+
34
+ $ gem install mudrat_projector
35
+
36
+ ## Usage
37
+
38
+ TODO: Write usage instructions here
39
+
40
+ ## Contributing
41
+
42
+ 1. Fork it
43
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
44
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
45
+ 4. Push to the branch (`git push origin my-new-feature`)
46
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ require "rake/testtask"
2
+
3
+ ENV['COVERAGE'] = '1'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.pattern = 'test/**/*_test.rb'
7
+ t.libs << 'test'
8
+ end
9
+
10
+ desc "Update ctags"
11
+ task :ctags do
12
+ `ctags -R --languages=Ruby --totals -f tags`
13
+ end
14
+
15
+ task :environment do
16
+ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
17
+ require 'mudrat_projector'
18
+ end
19
+
20
+ desc "Open a pry console"
21
+ task :console => :environment do
22
+ require 'pry'
23
+ MudratProjector.pry
24
+ end
25
+
26
+ task default: :test
data/bin/testrb ADDED
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+
3
+ ruby -Ilib -Itest $@
data/finance_fu.txt ADDED
@@ -0,0 +1,41 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+ Scenario
11
+
12
+ Income
13
+ Self employed
14
+ Salaried
15
+ Expenses
16
+ Recurring
17
+ One time
18
+ Liabilities
19
+ Mortgage, student loan debt, etc.
20
+ Investments
21
+ Property
22
+ Stock
23
+ Mutual funds, etc.
24
+
25
+
26
+
27
+ Bank/Financial transaction history
28
+
29
+ Can reconcile against active scenario
30
+
31
+
32
+
33
+ Projection
34
+
35
+ Creates ledgers using bank transaction history when available, active scenario on fallback
36
+ Changes to active scenario immediately propogate to projection
37
+ Calculates taxes and adjusts for inflation
38
+ When scenario changes, we store 1 year, 2 year, 5 year, etc.
39
+
40
+
41
+
@@ -0,0 +1,75 @@
1
+ module MudratProjector
2
+ class Account
3
+ TYPES = %i(asset expense liability revenue equity)
4
+
5
+ attr :open_date, :parent_id, :tags, :type
6
+
7
+ def initialize params = {}
8
+ @entries = []
9
+ @open_date = params[:open_date] || ABSOLUTE_START
10
+ @offset = 0
11
+ @opening_balance = params[:opening_balance] || 0
12
+ @parent_id = params[:parent_id] || nil
13
+ @tags = params[:tags] || []
14
+ @type = params.fetch :type
15
+ end
16
+
17
+ def add_entry entry
18
+ @entries.push entry
19
+ @offset += entry.delta
20
+ end
21
+
22
+ def balance
23
+ @opening_balance + @offset
24
+ end
25
+
26
+ def close!
27
+ freeze
28
+ return self if closed?
29
+ self.class.new serialize
30
+ end
31
+
32
+ def closed?
33
+ @entries.empty?
34
+ end
35
+
36
+ def create_child params = {}
37
+ new_params = serialize
38
+ new_params.merge!(
39
+ opening_balance: params[:opening_balance],
40
+ parent_id: params.fetch(:parent_id),
41
+ tags: (tags | Array(params[:tags])),
42
+ )
43
+ self.class.new new_params
44
+ end
45
+
46
+ def parent?
47
+ parent_id.nil? ? false : true
48
+ end
49
+
50
+ def tag? tag_name
51
+ tags.include? tag_name
52
+ end
53
+
54
+ def serialize
55
+ hash = { opening_balance: balance }
56
+ %i(open_date parent_id tags type).each do |attr|
57
+ value = public_send attr
58
+ unless default_value? attr, value
59
+ hash[attr] = value unless Array(value).empty?
60
+ end
61
+ end
62
+ hash
63
+ end
64
+
65
+ private
66
+
67
+ def default_value? attr, value
68
+ if attr == :open_date
69
+ value == ABSOLUTE_START
70
+ else
71
+ false
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,103 @@
1
+ module MudratProjector
2
+ class Amortizer
3
+ attr :balance, :projection_range
4
+ end
5
+
6
+ class CompoundInterestAmortizer < Amortizer
7
+ attr :schedule
8
+
9
+ def initialize schedule, projection_range, **params
10
+ @schedule = schedule
11
+ @projection_range = projection_range
12
+ @balance, @interest, @principal = amortize
13
+ end
14
+
15
+ def amortize
16
+ balance = initial_value * ((1 + rate) ** months_amortized)
17
+ interest = balance - initial_value
18
+ [balance, interest, 0]
19
+ end
20
+
21
+ def each_entry &block
22
+ [[:credit, interest, schedule.interest_account ],
23
+ [:credit, principal, schedule.principal_account],
24
+ [:debit, payment, schedule.payment_account ]].each &block
25
+ end
26
+
27
+ def initial_value
28
+ schedule.initial_value
29
+ end
30
+
31
+ def interest
32
+ @interest.round 2
33
+ end
34
+
35
+ def months_amortized
36
+ start = [projection_range.begin, schedule_range.begin].max
37
+ finish = [projection_range.end, schedule_range.end].min
38
+ DateDiff.date_diff(:month, start, finish).to_i
39
+ end
40
+
41
+ def next_transaction transaction, schedule
42
+ Transaction.new(
43
+ date: projection_range.end + 1,
44
+ credits: transaction.credits,
45
+ debits: transaction.debits,
46
+ schedule: schedule,
47
+ )
48
+ end
49
+
50
+ def rate
51
+ schedule.rate
52
+ end
53
+
54
+ def principal
55
+ @principal.round 2
56
+ end
57
+
58
+ def payment
59
+ interest + principal
60
+ end
61
+
62
+ def schedule_range
63
+ schedule.range
64
+ end
65
+ end
66
+
67
+ class MortgageAmortizer < CompoundInterestAmortizer
68
+ attr :extra_principal, :monthly_payment
69
+
70
+ def initialize *args, extra_principal: 0
71
+ @extra_principal = extra_principal
72
+ super
73
+ end
74
+
75
+ def monthly_payment
76
+ schedule.monthly_payment
77
+ end
78
+
79
+ def each_entry &block
80
+ [[:credit, interest, schedule.payment_account ],
81
+ [:credit, principal, schedule.payment_account ],
82
+ [:debit, interest, schedule.interest_account ],
83
+ [:debit, principal, schedule.principal_account]].each &block
84
+ end
85
+
86
+ def amortize
87
+ interest_paid = 0
88
+ principal_paid = 0
89
+
90
+ mp = monthly_payment
91
+
92
+ new_balance = months_amortized.times.inject initial_value do |balance, _|
93
+ interest = balance * rate
94
+ principal = (mp - interest) + extra_principal
95
+ interest_paid += interest
96
+ principal_paid += principal
97
+ balance - principal
98
+ end
99
+
100
+ [new_balance, interest_paid, principal_paid]
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,11 @@
1
+ module MudratProjector
2
+ module BankerRounding
3
+ def with_banker_rounding
4
+ old_rounding_mode = BigDecimal.mode BigDecimal::ROUND_MODE
5
+ BigDecimal.mode BigDecimal::ROUND_MODE, BigDecimal::ROUND_HALF_EVEN
6
+ yield
7
+ ensure
8
+ BigDecimal.mode BigDecimal::ROUND_MODE, old_rounding_mode
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,119 @@
1
+ module MudratProjector
2
+ class ChartOfAccounts
3
+ include Enumerable
4
+
5
+ def initialize
6
+ @accounts = {}
7
+ end
8
+
9
+ def account_balance id
10
+ fetch(id).balance + subaccounts_balance(id)
11
+ end
12
+
13
+ def accounts
14
+ @accounts.keys
15
+ end
16
+
17
+ def add_account id, **params
18
+ @accounts[id] = Account.new params
19
+ end
20
+
21
+ def apply_transaction transaction
22
+ validate_transaction! transaction
23
+ transaction.entries.each do |entry| entry.calculate self; end
24
+ transaction.entries.each do |entry|
25
+ fetch(entry.account_id).add_entry entry
26
+ end
27
+ transaction
28
+ end
29
+
30
+ def balance
31
+ inject 0 do |sum, account|
32
+ if account.parent?
33
+ sum
34
+ else
35
+ method = %i(asset expense).include?(account.type) ? :+ : :-
36
+ sum.public_send method, account.balance
37
+ end
38
+ end
39
+ end
40
+
41
+ def each &block
42
+ @accounts.values.each &block
43
+ end
44
+
45
+ def exists? account_id
46
+ @accounts.has_key? account_id
47
+ end
48
+
49
+ def fetch account_id
50
+ @accounts.fetch account_id
51
+ end
52
+
53
+ def net_worth
54
+ @accounts.reduce 0 do |sum, (_, account)|
55
+ if account.type == :asset
56
+ sum + account.balance
57
+ elsif account.type == :liability
58
+ sum - account.balance
59
+ else
60
+ sum
61
+ end
62
+ end
63
+ end
64
+
65
+ def size
66
+ @accounts.size
67
+ end
68
+
69
+ def serialize
70
+ @accounts.reduce Hash.new do |hash, (id, account)|
71
+ hash[id] = account.serialize
72
+ hash
73
+ end
74
+ end
75
+
76
+ def split_account id, into: {}
77
+ parent = fetch id
78
+ into.map do |sub_account_id, hash|
79
+ @accounts[sub_account_id] =
80
+ if hash
81
+ parent.create_child(
82
+ opening_balance: hash[:amount],
83
+ parent_id: id,
84
+ tags: hash[:tags],
85
+ )
86
+ else
87
+ parent.create_child parent_id: id
88
+ end
89
+ end
90
+ end
91
+
92
+ def subaccounts_balance id
93
+ subaccount_ids = @accounts.reduce [] do |ary, (subaccount_id, account)|
94
+ ary.push subaccount_id if account.parent_id == id
95
+ ary
96
+ end
97
+ subaccount_ids.reduce 0 do |sum, id| sum + account_balance(id); end
98
+ end
99
+
100
+ def validate_transaction! transaction
101
+ transaction.each do |entry|
102
+ validate_entry! transaction.date, entry
103
+ end
104
+ end
105
+
106
+ def validate_entry! transaction_date, entry
107
+ unless @accounts.has_key? entry.account_id
108
+ raise Projector::AccountDoesNotExist, "Transaction references non "\
109
+ "existent account #{entry.account_id.inspect}"
110
+ end
111
+ open_date = fetch(entry.account_id).open_date
112
+ unless open_date <= transaction_date
113
+ raise Projector::AccountDoesNotExist, "Transaction references account "\
114
+ "#{entry.account_id.inspect} which does not open until #{open_date}, "\
115
+ "but transaction is set for #{transaction_date}"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,169 @@
1
+ module MudratProjector
2
+ module DateDiff
3
+ extend self
4
+
5
+ def advance intervals: nil, unit: nil, from: nil
6
+ fetch_subclass(unit).advance intervals, from: from
7
+ end
8
+
9
+ def date_diff *maybe_unit_from_to, unit: nil, from: nil, to: nil
10
+ if [unit, from, to].all? &:nil?
11
+ unit, from, to = maybe_unit_from_to
12
+ end
13
+ fetch_subclass(unit).new(from, to).calculate
14
+ end
15
+
16
+ def fetch_subclass unit
17
+ klass_bit = unit.to_s.capitalize.gsub(/_[a-z]/) do |dash_letter|
18
+ dash_letter[1].upcase
19
+ end
20
+ klass_name = "#{klass_bit}Calculator"
21
+ DateDiff.const_get klass_name
22
+ end
23
+ private :fetch_subclass
24
+
25
+ Calculator = Struct.new :from, :to do
26
+ def calculate
27
+ fail
28
+ end
29
+
30
+ def days_between
31
+ (to - from).to_f + 1
32
+ end
33
+ private :days_between
34
+ end
35
+
36
+ class DayCalculator < Calculator
37
+ def calculate
38
+ days_between
39
+ end
40
+
41
+ def self.advance intervals, from: from
42
+ from + intervals.round
43
+ end
44
+ end
45
+
46
+ class WeekCalculator < DayCalculator
47
+ def calculate
48
+ super / 7.0
49
+ end
50
+
51
+ def self.advance intervals, from: from
52
+ from + (intervals * 7)
53
+ end
54
+ end
55
+
56
+ class ComplexCalculator < Calculator
57
+ attr :first_unit, :last_unit
58
+
59
+ def initialize *args
60
+ super
61
+ @first_unit = fetch_unit from
62
+ @last_unit = fetch_unit to
63
+ end
64
+
65
+ def calculate
66
+ if first_unit.begin == last_unit.begin
67
+ days_between / days_in_unit(first_unit)
68
+ else
69
+ calculate_unit(from, first_unit.end) +
70
+ units_between +
71
+ calculate_unit(last_unit.begin, to)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def calculate_unit unit_begin, unit_end
78
+ self.class.new(unit_begin, unit_end).calculate
79
+ end
80
+
81
+ def calculate_units_between start, finish
82
+ count = 1
83
+ until start == finish
84
+ count += 1
85
+ start = advance_one_unit start
86
+ end
87
+ count
88
+ end
89
+
90
+ def days_in_unit unit
91
+ ((unit.end + 1) - unit.begin).to_f
92
+ end
93
+
94
+ def units_between
95
+ start = first_unit.end + 1
96
+ finish = rewind_one_unit last_unit.begin
97
+ return 0 if start > finish
98
+ calculate_units_between start, finish
99
+ end
100
+ end
101
+
102
+ class YearCalculator < ComplexCalculator
103
+ def fetch_unit date
104
+ (Date.new(date.year)..Date.new(date.year, 12, 31))
105
+ end
106
+
107
+ def advance_one_unit date
108
+ date.next_year
109
+ end
110
+
111
+ def rewind_one_unit date
112
+ date.prev_year
113
+ end
114
+
115
+ def self.advance intervals, from: from
116
+ Date.new(from.year + intervals, from.month, from.day)
117
+ end
118
+ end
119
+
120
+ class QuarterCalculator < ComplexCalculator
121
+ def fetch_unit date
122
+ [1, 4, 7, 10].each do |quarter|
123
+ if (quarter..quarter + 2).include? date.month
124
+ start_of_quarter = Date.new(date.year, quarter)
125
+ return (start_of_quarter..(start_of_quarter.next_month.next_month.next_month - 1))
126
+ end
127
+ end
128
+ fail "Date month was #{date.month}"
129
+ end
130
+
131
+ def advance_one_unit date
132
+ date.next_month.next_month.next_month
133
+ end
134
+
135
+ def rewind_one_unit date
136
+ date.prev_month.prev_month.prev_month
137
+ end
138
+
139
+ def self.advance intervals, from: from
140
+ (intervals * 3).times.inject from do |date, _| date.next_month; end
141
+ end
142
+ end
143
+
144
+ class MonthCalculator < ComplexCalculator
145
+ def fetch_unit date
146
+ start_of_month = Date.new(date.year, date.month)
147
+ (start_of_month..(start_of_month.next_month - 1))
148
+ end
149
+
150
+ def advance_one_unit date
151
+ date.next_month
152
+ end
153
+
154
+ def rewind_one_unit date
155
+ date.prev_month
156
+ end
157
+
158
+ def self.advance intervals, from: from
159
+ if intervals < 1
160
+ days_in_month = Date.new(from.year, from.month, -1).day
161
+ days = intervals * days_in_month
162
+ DayCalculator.advance days, from: from
163
+ else
164
+ intervals.times.inject from do |date, _| date.next_month; end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end