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.
- checksums.yaml +7 -0
- data/.gitignore +2 -0
- data/.ruby-version +1 -0
- data/.travis.yml +3 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +49 -0
- data/README.md +46 -0
- data/Rakefile +26 -0
- data/bin/testrb +3 -0
- data/finance_fu.txt +41 -0
- data/lib/mudrat_projector/account.rb +75 -0
- data/lib/mudrat_projector/amortizer.rb +103 -0
- data/lib/mudrat_projector/banker_rounding.rb +11 -0
- data/lib/mudrat_projector/chart_of_accounts.rb +119 -0
- data/lib/mudrat_projector/date_diff.rb +169 -0
- data/lib/mudrat_projector/projection.rb +45 -0
- data/lib/mudrat_projector/projector.rb +71 -0
- data/lib/mudrat_projector/schedule.rb +45 -0
- data/lib/mudrat_projector/scheduled_transaction.rb +45 -0
- data/lib/mudrat_projector/tax_calculation.rb +154 -0
- data/lib/mudrat_projector/tax_calculator.rb +144 -0
- data/lib/mudrat_projector/tax_values_by_year.yml +102 -0
- data/lib/mudrat_projector/transaction.rb +71 -0
- data/lib/mudrat_projector/transaction_entry.rb +126 -0
- data/lib/mudrat_projector/transaction_handler.rb +19 -0
- data/lib/mudrat_projector/validator.rb +49 -0
- data/lib/mudrat_projector/version.rb +3 -0
- data/lib/mudrat_projector.rb +27 -0
- data/mudrat_projector.gemspec +28 -0
- data/test/integrations/long_term_projection_test.rb +42 -0
- data/test/integrations/mortgage_test.rb +85 -0
- data/test/integrations/self_employed_tax_calculation_test.rb +44 -0
- data/test/models/accounts_test.rb +39 -0
- data/test/models/chart_of_accounts_test.rb +170 -0
- data/test/models/date_diff_test.rb +117 -0
- data/test/models/projection_test.rb +62 -0
- data/test/models/projector_test.rb +168 -0
- data/test/models/schedule_test.rb +68 -0
- data/test/models/scheduled_transaction_test.rb +47 -0
- data/test/models/tax_calculator_test.rb +145 -0
- data/test/models/transaction_entry_test.rb +37 -0
- data/test/models/transaction_handler_test.rb +67 -0
- data/test/models/transaction_test.rb +66 -0
- data/test/models/validator_test.rb +45 -0
- data/test/test_helper.rb +69 -0
- metadata +204 -0
@@ -0,0 +1,126 @@
|
|
1
|
+
module MudratProjector
|
2
|
+
class TransactionEntry
|
3
|
+
attr :account_id, :scalar
|
4
|
+
|
5
|
+
module TransactionEntry::Factory
|
6
|
+
def new params = {}
|
7
|
+
catch :instance do
|
8
|
+
maybe_build_new_fixed_entry params
|
9
|
+
maybe_build_new_percentage_entry params
|
10
|
+
super
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def new_credit params = {}
|
15
|
+
params = { credit_or_debit: :credit }.merge params
|
16
|
+
new params
|
17
|
+
end
|
18
|
+
|
19
|
+
def new_debit params = {}
|
20
|
+
params = { credit_or_debit: :debit }.merge params
|
21
|
+
new params
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def maybe_build_new_fixed_entry params
|
27
|
+
return unless params.has_key? :amount
|
28
|
+
params = params.dup
|
29
|
+
params[:scalar] = params.delete :amount
|
30
|
+
throw :instance, new(params).tap(&:calculate)
|
31
|
+
end
|
32
|
+
|
33
|
+
def maybe_build_new_percentage_entry params
|
34
|
+
return unless params.has_key?(:percent) && self == TransactionEntry
|
35
|
+
params = params.dup
|
36
|
+
params[:scalar] = params.delete :percent
|
37
|
+
params[:other_account_id] = params.delete :of
|
38
|
+
throw :instance, PercentageTransactionEntry.new(params)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
extend TransactionEntry::Factory
|
43
|
+
|
44
|
+
attr :amount, :delta
|
45
|
+
|
46
|
+
def initialize params = {}
|
47
|
+
@account_id = params.fetch :account_id
|
48
|
+
@scalar = params.fetch :scalar
|
49
|
+
@credit_or_debit = params.fetch :credit_or_debit
|
50
|
+
validate!
|
51
|
+
end
|
52
|
+
|
53
|
+
def * multiplier
|
54
|
+
return self if multiplier == 1
|
55
|
+
self.class.new serialize.merge(scalar: scalar * multiplier)
|
56
|
+
end
|
57
|
+
|
58
|
+
def calculate chart_of_accounts = nil
|
59
|
+
@amount = calculate_amount chart_of_accounts
|
60
|
+
return if chart_of_accounts.nil?
|
61
|
+
account_type = chart_of_accounts.fetch(account_id).type
|
62
|
+
check = %i(asset expense).include?(account_type) ? :debit? : :credit?
|
63
|
+
@delta = send(check) ? amount : -amount
|
64
|
+
end
|
65
|
+
|
66
|
+
def calculate_amount chart_of_accounts
|
67
|
+
@amount = scalar
|
68
|
+
end
|
69
|
+
|
70
|
+
def credit?
|
71
|
+
@credit_or_debit == :credit
|
72
|
+
end
|
73
|
+
|
74
|
+
def debit?
|
75
|
+
@credit_or_debit == :debit
|
76
|
+
end
|
77
|
+
|
78
|
+
def inspect
|
79
|
+
"#<#{self.class}: amount=#{fmt(scalar)}, account_id=#{account_id.inspect} type=#{@credit_or_debit.inspect}>"
|
80
|
+
end
|
81
|
+
|
82
|
+
def serialize
|
83
|
+
{
|
84
|
+
account_id: account_id,
|
85
|
+
scalar: scalar,
|
86
|
+
credit_or_debit: @credit_or_debit,
|
87
|
+
}
|
88
|
+
end
|
89
|
+
|
90
|
+
def validate!
|
91
|
+
if scalar == 0
|
92
|
+
raise ArgumentError, "You cannot supply a scalar of 0"
|
93
|
+
end
|
94
|
+
unless %i(credit debit).include? @credit_or_debit
|
95
|
+
raise ArgumentError, "Must supply :credit or :debit, not #{@credit_or_debit.inspect}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def fmt number
|
102
|
+
number.respond_to?(:round) ? number.round(2).to_f : number
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
class PercentageTransactionEntry < TransactionEntry
|
107
|
+
attr :other_account_id
|
108
|
+
|
109
|
+
def initialize params = {}
|
110
|
+
@other_account_id = params.fetch :other_account_id
|
111
|
+
super params
|
112
|
+
end
|
113
|
+
|
114
|
+
def calculate_amount chart_of_accounts
|
115
|
+
@amount = scalar * chart_of_accounts.fetch(other_account_id).balance
|
116
|
+
end
|
117
|
+
|
118
|
+
def inspect
|
119
|
+
"#<#{self.class}: percent=#{fmt(scalar * 100)}%, account_id=#{account_id.inspect} type=#{@credit_or_debit.inspect}, other_account_id=#{other_account_id.inspect}>"
|
120
|
+
end
|
121
|
+
|
122
|
+
def serialize
|
123
|
+
super.tap do |hash| hash[:other_account_id] = other_account_id; end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module MudratProjector
|
2
|
+
class TransactionHandler
|
3
|
+
attr_accessor :next_projector
|
4
|
+
|
5
|
+
def initialize projection: projection
|
6
|
+
@projection = projection
|
7
|
+
end
|
8
|
+
|
9
|
+
def << transaction
|
10
|
+
in_projection, leftover = transaction.slice @projection.range.end
|
11
|
+
@projection.add_transaction_batch in_projection
|
12
|
+
defer leftover if leftover
|
13
|
+
end
|
14
|
+
|
15
|
+
def defer transaction
|
16
|
+
next_projector.add_transaction transaction if next_projector
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module MudratProjector
|
2
|
+
class Validator
|
3
|
+
attr :chart, :projector
|
4
|
+
|
5
|
+
def initialize projector: projector, chart: chart
|
6
|
+
@projector = projector
|
7
|
+
@chart = chart
|
8
|
+
end
|
9
|
+
|
10
|
+
def must_be_balanced!
|
11
|
+
unless projector.balanced?
|
12
|
+
raise Projector::BalanceError, "Cannot project unless the accounts "\
|
13
|
+
"are in balance"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def validate_account! account_id, params
|
18
|
+
if chart.exists? account_id
|
19
|
+
raise Projector::AccountExists, "Account #{account_id.inspect} exists"
|
20
|
+
end
|
21
|
+
unless Account::TYPES.include? params[:type]
|
22
|
+
raise Projector::InvalidAccount, "Account #{account_id.inspect} has "\
|
23
|
+
"invalid type #{params[:type].inspect}"
|
24
|
+
end
|
25
|
+
if params.has_key?(:open_date) && params[:open_date] > projector.from
|
26
|
+
if params.has_key? :opening_balance
|
27
|
+
raise Projector::InvalidAccount, "Account #{account_id.inspect} opens "\
|
28
|
+
"after projector, but has an opening balance"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_transaction! transaction
|
34
|
+
if transaction.date < projector.from
|
35
|
+
raise Projector::InvalidTransaction, "Transactions cannot occur before "\
|
36
|
+
"projection start date. (#{projector.from} vs. #{transaction.date})"
|
37
|
+
end
|
38
|
+
unless transaction.balanced?
|
39
|
+
raise Projector::BalanceError, "Credits and debit entries both "\
|
40
|
+
"must be supplied; they cannot amount to zero"
|
41
|
+
end
|
42
|
+
if transaction.credits.empty? || transaction.debits.empty?
|
43
|
+
raise Projector::InvalidTransaction, "You must supply at least a debit "\
|
44
|
+
"and a credit on each transaction"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require "bigdecimal"
|
2
|
+
require "bigdecimal/util"
|
3
|
+
require "date"
|
4
|
+
require "forwardable"
|
5
|
+
require "yaml"
|
6
|
+
|
7
|
+
require "mudrat_projector/version"
|
8
|
+
|
9
|
+
module MudratProjector
|
10
|
+
ABSOLUTE_START = Date.new 1970
|
11
|
+
ABSOLUTE_END = Date.new 9999
|
12
|
+
|
13
|
+
def self.classify sym
|
14
|
+
"_#{sym}".gsub %r{_[a-z]} do |bit|
|
15
|
+
bit.slice! 0, 1
|
16
|
+
bit.upcase!
|
17
|
+
bit
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
Dir.glob File.expand_path("../mudrat_projector/**/*.rb", __FILE__) do |path|
|
22
|
+
base_without_ext = File.basename path, ".*"
|
23
|
+
klass_name = classify base_without_ext
|
24
|
+
relative_path = File.join "mudrat_projector", base_without_ext
|
25
|
+
autoload klass_name, relative_path
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'mudrat_projector/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "mudrat_projector"
|
8
|
+
spec.version = MudratProjector::VERSION
|
9
|
+
spec.authors = ["ntl"]
|
10
|
+
spec.email = ["nathanladd+github@gmail.com"]
|
11
|
+
spec.description = %q{Mudrat Projector is a simple financial projection engine.}
|
12
|
+
spec.summary = %q{Mudrat Projector is a simple financial projection engine designed for personal finance computations.}
|
13
|
+
spec.homepage = "https://github.com/ntl/mudrat-projector"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^test/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
22
|
+
spec.add_development_dependency "minitest"
|
23
|
+
spec.add_development_dependency "minitest-reporters"
|
24
|
+
spec.add_development_dependency "pry"
|
25
|
+
spec.add_development_dependency "rake"
|
26
|
+
spec.add_development_dependency "simplecov"
|
27
|
+
spec.add_development_dependency "timecop"
|
28
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class LongTermProjectiontest < Minitest::Test
|
4
|
+
def setup
|
5
|
+
@projector = Projector.new from: jan_1_2012
|
6
|
+
@projector.add_account :checking, type: :asset
|
7
|
+
@projector.add_account :job, type: :revenue, tags: %i(salary)
|
8
|
+
@projector.add_account :investment, type: :asset
|
9
|
+
@projector.add_account :dividends, type: :revenue, tags: %i(dividend)
|
10
|
+
|
11
|
+
@projector.add_transaction(
|
12
|
+
date: jan_1_2012,
|
13
|
+
credit: { amount: 6000, account_id: :job },
|
14
|
+
debit: { amount: 6000, account_id: :checking },
|
15
|
+
schedule: every_month,
|
16
|
+
)
|
17
|
+
@projector.add_transaction(
|
18
|
+
date: jan_1_2012,
|
19
|
+
credit: { percent: (6 / 1200.to_d), of: :investment, account_id: :dividends },
|
20
|
+
debit: { percent: (6 / 1200.to_d), of: :investment, account_id: :investment },
|
21
|
+
schedule: every_month,
|
22
|
+
)
|
23
|
+
@projector.add_transaction(
|
24
|
+
date: jan_1_2012,
|
25
|
+
credit: { amount: 500, account_id: :checking },
|
26
|
+
debit: { amount: 500, account_id: :investment },
|
27
|
+
schedule: every_month,
|
28
|
+
)
|
29
|
+
|
30
|
+
@household = { filing_status: :single, exemptions: 1 }
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_two_years
|
34
|
+
projector = TaxCalculator.project @projector, to: dec_31_2013, household: @household
|
35
|
+
|
36
|
+
# used http://investor.gov/tools/calculators/compound-interest-calculator
|
37
|
+
assert_in_epsilon 715.98, projector.account_balance(:dividends)
|
38
|
+
expected_gross = 72000 + 72000 + 715.98
|
39
|
+
expected_taxes = 15702.45 + 17073.80
|
40
|
+
assert_in_epsilon expected_gross - expected_taxes, projector.net_worth
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class MortgageTest < Minitest::Test
|
4
|
+
def setup
|
5
|
+
@loan_amount = 200_000
|
6
|
+
@home_value = 250_000
|
7
|
+
@down_payment = @home_value - @loan_amount
|
8
|
+
|
9
|
+
@interest_rate = 5.0.to_d
|
10
|
+
@property_tax_rate = 1.25.to_d
|
11
|
+
@loan_term = 30
|
12
|
+
@start_date = jan_1_2012
|
13
|
+
|
14
|
+
@projector = Projector.new from: jan_1_2012
|
15
|
+
|
16
|
+
@projector.add_account :checking, type: :asset, opening_balance: 50000
|
17
|
+
@projector.add_account :job, type: :revenue, opening_balance: 50000
|
18
|
+
@projector.add_account :home, type: :asset, open_date: feb_1_2012
|
19
|
+
@projector.add_account :mortgage, type: :liability, open_date: feb_1_2012
|
20
|
+
@projector.add_account :interest, type: :expense, open_date: feb_1_2012
|
21
|
+
|
22
|
+
@projector.add_transaction(
|
23
|
+
date: jan_1_2012,
|
24
|
+
credit: { amount: 6000, account_id: :job },
|
25
|
+
debit: { amount: 6000, account_id: :checking },
|
26
|
+
schedule: { unit: :month, scalar: 1 },
|
27
|
+
)
|
28
|
+
|
29
|
+
@projector.add_transaction(
|
30
|
+
date: feb_1_2012,
|
31
|
+
credits: [{ amount: @down_payment, account_id: :checking },
|
32
|
+
{ amount: @loan_amount, account_id: :mortgage }],
|
33
|
+
debit: { amount: @home_value, account_id: :home },
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
def test_balance_after_first_year
|
38
|
+
pay_off_loan years: 30
|
39
|
+
@projector.project to: dec_31_2012
|
40
|
+
assert_in_epsilon 197_300.83, @projector.account_balance(:mortgage).to_f
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_net_worth_after_first_year
|
44
|
+
expected_checking_balance = 72000 - (9110.90 + 2699.17)
|
45
|
+
expected_net_worth = expected_checking_balance + @home_value - 197_300.83
|
46
|
+
|
47
|
+
pay_off_loan years: 30
|
48
|
+
@projector.project to: dec_31_2012
|
49
|
+
|
50
|
+
assert_in_epsilon expected_net_worth, @projector.net_worth
|
51
|
+
end
|
52
|
+
|
53
|
+
def test_mortgage_balances_to_zero_at_end_of_term
|
54
|
+
pay_off_loan years: 30
|
55
|
+
@projector.project to: jan_31_2042
|
56
|
+
assert_in_epsilon 0, @projector.account_balance(:mortgage)
|
57
|
+
end
|
58
|
+
|
59
|
+
def test_pay_off_mortgage_early
|
60
|
+
pay_off_loan years: 15
|
61
|
+
@projector.project to: jan_31_2027
|
62
|
+
assert_in_epsilon 0, @projector.account_balance(:mortgage)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def calculate_monthly_payment r, years = @loan_term
|
68
|
+
x = (1 + r) ** (years * 12)
|
69
|
+
mp = (@loan_amount * r * x) / (x - 1)
|
70
|
+
end
|
71
|
+
|
72
|
+
def pay_off_loan years: @loan_term
|
73
|
+
r = @interest_rate / 1200
|
74
|
+
mp = calculate_monthly_payment r, years
|
75
|
+
|
76
|
+
@projector.add_transaction(
|
77
|
+
date: feb_1_2012,
|
78
|
+
credits: [{ percent: r, of: :mortgage, account_id: :mortgage},
|
79
|
+
{ amount: mp, account_id: :checking }],
|
80
|
+
debits: [{ amount: mp, account_id: :mortgage },
|
81
|
+
{ percent: r, of: :mortgage, account_id: :interest}],
|
82
|
+
schedule: { unit: :month, scalar: 1, count: (years * 12) },
|
83
|
+
)
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
# http://taxes.about.com/od/paymentoptions/a/estimated_tax_3.htm
|
4
|
+
class SelfEmployedTaxCalculationTest < Minitest::Test
|
5
|
+
def setup
|
6
|
+
@projector = Projector.new from: jan_1_2013
|
7
|
+
|
8
|
+
@projector.accounts = {
|
9
|
+
checking: { type: :asset },
|
10
|
+
se_expenses: { type: :expense, tags: %i(self_employed) },
|
11
|
+
se_job: { type: :revenue, tags: %i(self_employed) },
|
12
|
+
}
|
13
|
+
|
14
|
+
@projector.transactions = [{
|
15
|
+
date: jan_1_2013,
|
16
|
+
credit: { amount: 25000 / 3.0.to_d, account_id: :se_job },
|
17
|
+
debit: { amount: 25000 / 3.0.to_d, account_id: :checking },
|
18
|
+
schedule: every_month,
|
19
|
+
},{
|
20
|
+
date: jan_1_2013,
|
21
|
+
credit: { amount: 7500 / 3.0.to_d, account_id: :checking },
|
22
|
+
debit: { amount: 7500 / 3.0.to_d, account_id: :se_expenses },
|
23
|
+
schedule: every_month,
|
24
|
+
}]
|
25
|
+
end
|
26
|
+
|
27
|
+
def test_shelley
|
28
|
+
@tax_calculator = TaxCalculator.new projector: @projector, household: single
|
29
|
+
calculation = @tax_calculator.calculate!
|
30
|
+
assert_in_epsilon 70000, calculation.total_income
|
31
|
+
assert_in_epsilon 9890.685, calculation.self_employment_tax
|
32
|
+
assert_in_epsilon 4945.34, calculation.adjustments
|
33
|
+
assert_in_epsilon 65054.66, calculation.agi
|
34
|
+
assert_in_epsilon 55054.66, calculation.taxable_income
|
35
|
+
assert_in_epsilon 9692.50, calculation.income_tax
|
36
|
+
assert_in_epsilon 19583.19, calculation.taxes
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def single
|
42
|
+
{ filing_status: :single, exemptions: 1 }
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class AccountTest < Minitest::Test
|
4
|
+
def setup
|
5
|
+
@chart = ChartOfAccounts.new
|
6
|
+
@account = @chart.add_account :foo, type: :asset
|
7
|
+
end
|
8
|
+
|
9
|
+
# Closed means "are the books closed?", e.g. are there any transactions that
|
10
|
+
# haven't been processed
|
11
|
+
def test_closed_returns_true_iff_no_transaction_entries
|
12
|
+
assert @account.closed?
|
13
|
+
add_transaction_entry
|
14
|
+
refute @account.closed?
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_close_freezes_and_returns_account_if_closed
|
18
|
+
new_account = @account.close!
|
19
|
+
|
20
|
+
assert @account.frozen?
|
21
|
+
assert_equal new_account.object_id, @account.object_id
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_close_freezes_and_returns_new_closed_account_if_unclosed
|
25
|
+
add_transaction_entry
|
26
|
+
new_account = @account.close!
|
27
|
+
|
28
|
+
assert @account.frozen?
|
29
|
+
refute_equal new_account.object_id, @account.object_id
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def add_transaction_entry
|
35
|
+
entry = TransactionEntry.new_debit account_id: :foo, amount: 1
|
36
|
+
entry.calculate @chart
|
37
|
+
@account.add_entry entry
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class ChartOfAccountsTest < Minitest::Test
|
4
|
+
def setup
|
5
|
+
@chart = ChartOfAccounts.new
|
6
|
+
end
|
7
|
+
|
8
|
+
def test_can_add_an_account
|
9
|
+
@chart.add_account :checking, type: :asset, open_date: jan_1_2000
|
10
|
+
assert_equal 1, @chart.size
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_adding_account_can_default_open_date
|
14
|
+
@chart.add_account :checking, type: :asset
|
15
|
+
assert_equal 1, @chart.size
|
16
|
+
assert_equal ABSOLUTE_START, @chart.fetch(:checking).open_date
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_can_set_opening_balance
|
20
|
+
@chart.add_account :checking, type: :asset, opening_balance: 500
|
21
|
+
assert_equal [500], @chart.map(&:balance)
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_balanced_iff_opening_balances_zero_out
|
25
|
+
assert_equal 0, @chart.balance
|
26
|
+
@chart.add_account :checking, type: :asset, opening_balance: 500
|
27
|
+
assert_equal 500, @chart.balance
|
28
|
+
@chart.add_account :slush, type: :equity, opening_balance: 500
|
29
|
+
assert_equal 0, @chart.balance
|
30
|
+
end
|
31
|
+
|
32
|
+
def test_can_tag
|
33
|
+
@chart.add_account :checking, type: :asset, tags: %i(foo)
|
34
|
+
assert @chart.fetch(:checking).tag? :foo
|
35
|
+
end
|
36
|
+
|
37
|
+
def test_can_add_a_child_account
|
38
|
+
@chart.add_account :my_bank, type: :asset
|
39
|
+
@chart.split_account :my_bank, into: %i(checking)
|
40
|
+
end
|
41
|
+
|
42
|
+
def test_can_add_a_child_account_with_opening_balances
|
43
|
+
@chart.add_account :my_bank, type: :asset, opening_balance: 200
|
44
|
+
@chart.split_account :my_bank, into: { checking: { amount: 50 }, savings: { amount: 150 }}
|
45
|
+
assert_equal 200, @chart.fetch(:my_bank).balance
|
46
|
+
assert_equal 50, @chart.fetch(:checking).balance
|
47
|
+
assert_equal 150, @chart.fetch(:savings).balance
|
48
|
+
assert_equal 200, @chart.balance
|
49
|
+
end
|
50
|
+
|
51
|
+
def test_can_add_a_child_account_with_tags
|
52
|
+
@chart.add_account :my_bank, type: :asset, tags: %i(foo)
|
53
|
+
@chart.split_account :my_bank, into: [:checking, [:savings, { tags: %i(bar) }]]
|
54
|
+
assert @chart.fetch(:checking).tag? :foo
|
55
|
+
refute @chart.fetch(:checking).tag? :bar
|
56
|
+
assert @chart.fetch(:savings).tag? :foo
|
57
|
+
assert @chart.fetch(:savings).tag? :bar
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_net_worth
|
61
|
+
add_smattering_of_accounts
|
62
|
+
# Net worth is assets minus liabilities
|
63
|
+
assert_equal (25 + 400 + 15000) - (13000), @chart.net_worth
|
64
|
+
end
|
65
|
+
|
66
|
+
def test_applying_fixed_transaction
|
67
|
+
@chart.add_account :checking, type: :asset
|
68
|
+
@chart.add_account :job, type: :revenue
|
69
|
+
|
70
|
+
fixed_transaction = Transaction.new(
|
71
|
+
date: jan_1_2000,
|
72
|
+
debits: [{ amount: 1234, account_id: :checking, credit_or_debit: :debit },
|
73
|
+
{ amount: 12, account_id: :job, credit_or_debit: :debit }],
|
74
|
+
credits: [{ amount: 1234, account_id: :job, credit_or_debit: :credit},
|
75
|
+
{ amount: 12, account_id: :checking, credit_or_debit: :credit}],
|
76
|
+
)
|
77
|
+
|
78
|
+
@chart.apply_transaction fixed_transaction
|
79
|
+
assert_equal 1222, @chart.net_worth
|
80
|
+
end
|
81
|
+
|
82
|
+
def test_applying_transaction_with_percentages
|
83
|
+
@chart.add_account :checking, type: :asset
|
84
|
+
@chart.add_account :investment, type: :asset, opening_balance: 200000
|
85
|
+
@chart.add_account :investment_revenue, type: :revenue
|
86
|
+
|
87
|
+
percentage_transaction = Transaction.new(
|
88
|
+
date: dec_31_2000,
|
89
|
+
debit: { percent: 0.06, of: :investment, account_id: :checking, credit_or_debit: :debit },
|
90
|
+
credit: { percent: 0.06, of: :investment, account_id: :investment_revenue, credit_or_debit: :credit },
|
91
|
+
)
|
92
|
+
|
93
|
+
@chart.apply_transaction percentage_transaction
|
94
|
+
assert_equal 212000, @chart.net_worth
|
95
|
+
assert_equal 12000, @chart.fetch(:checking).balance
|
96
|
+
end
|
97
|
+
|
98
|
+
def test_applying_transaction_for_non_existent_or_future_accounts_raises_error
|
99
|
+
@chart.add_account :checking, type: :asset, open_date: jan_1_2000
|
100
|
+
@chart.add_account :job, type: :revenue
|
101
|
+
|
102
|
+
transaction = Transaction.new(
|
103
|
+
date: jan_2_2000,
|
104
|
+
debit: { amount: 1234, account_id: :no_existe, credit_or_debit: :debit },
|
105
|
+
credit: { amount: 1234, account_id: :job, credit_or_debit: :credit },
|
106
|
+
)
|
107
|
+
|
108
|
+
assert_raises Projector::AccountDoesNotExist do
|
109
|
+
@chart.apply_transaction transaction
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def test_serialize
|
114
|
+
add_smattering_of_accounts
|
115
|
+
expected_hash = {
|
116
|
+
checking: { type: :asset, opening_balance: 25 },
|
117
|
+
savings: { type: :asset, opening_balance: 400 },
|
118
|
+
auto: { type: :asset, opening_balance: 15000 },
|
119
|
+
bills: { type: :expense, opening_balance: 26 },
|
120
|
+
car_loan: { type: :liability, opening_balance: 13000 },
|
121
|
+
new_job: { type: :revenue, opening_balance: 500 },
|
122
|
+
}
|
123
|
+
assert_equal expected_hash.keys, @chart.serialize.keys
|
124
|
+
expected_hash.each do |key, expected_value|
|
125
|
+
assert_equal expected_value, @chart.serialize.fetch(key)
|
126
|
+
end
|
127
|
+
assert_equal expected_hash, @chart.serialize
|
128
|
+
end
|
129
|
+
|
130
|
+
def test_account_balance_with_splits
|
131
|
+
@chart.add_account :my_bank, type: :asset
|
132
|
+
@chart.add_account :job, type: :revenue
|
133
|
+
@chart.split_account :my_bank, into: %i(checking savings)
|
134
|
+
@chart.split_account :checking, into: %i(checking_1 checking_2)
|
135
|
+
|
136
|
+
@chart.apply_transaction Transaction.new(
|
137
|
+
date: jan_1_2000,
|
138
|
+
debit: { amount: 1, account_id: :my_bank, credit_or_debit: :debit },
|
139
|
+
credit: { amount: 1, account_id: :job, credit_or_debit: :credit },
|
140
|
+
)
|
141
|
+
@chart.apply_transaction Transaction.new(
|
142
|
+
date: jan_1_2000,
|
143
|
+
debit: { amount: 5, account_id: :checking, credit_or_debit: :debit },
|
144
|
+
credit: { amount: 5, account_id: :job, credit_or_debit: :credit },
|
145
|
+
)
|
146
|
+
@chart.apply_transaction Transaction.new(
|
147
|
+
date: jan_1_2000,
|
148
|
+
debit: { amount: 12, account_id: :checking_1, credit_or_debit: :debit },
|
149
|
+
credit: { amount: 12, account_id: :job, credit_or_debit: :credit },
|
150
|
+
)
|
151
|
+
|
152
|
+
assert_equal 12, @chart.account_balance(:checking_1)
|
153
|
+
assert_equal 0, @chart.account_balance(:checking_2)
|
154
|
+
assert_equal 18, @chart.account_balance(:job)
|
155
|
+
assert_equal 17, @chart.account_balance(:checking)
|
156
|
+
assert_equal 0, @chart.account_balance(:savings)
|
157
|
+
assert_equal 18, @chart.account_balance(:my_bank)
|
158
|
+
end
|
159
|
+
|
160
|
+
private
|
161
|
+
|
162
|
+
def add_smattering_of_accounts
|
163
|
+
@chart.add_account :checking, type: :asset, opening_balance: 25
|
164
|
+
@chart.add_account :savings, type: :asset, opening_balance: 400
|
165
|
+
@chart.add_account :auto, type: :asset, opening_balance: 15000
|
166
|
+
@chart.add_account :bills, type: :expense, opening_balance: 26
|
167
|
+
@chart.add_account :car_loan, type: :liability, opening_balance: 13000
|
168
|
+
@chart.add_account :new_job, type: :revenue, opening_balance: 500
|
169
|
+
end
|
170
|
+
end
|