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