mudrat_projector 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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