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
@@ -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,3 @@
1
+ module MudratProjector
2
+ VERSION = "0.9.0"
3
+ 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