BillTracker/tests/snowballMath.test.js

138 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// Debt-payoff math (Snowball review #5 + #1). Pure functions — hand-calculated
// examples, plus the unpayable-debt edge where a minimum never overcomes interest.
const test = require('node:test');
const assert = require('node:assert/strict');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
const {
monthlyInterest, monthsToPayoff, amortizationSchedule,
calculateMinimumOnly, debtAprSnapshot,
} = require('../services/aprService');
// ── primitives ───────────────────────────────────────────────────────────────
test('monthlyInterest = balance × APR/12', () => {
assert.equal(monthlyInterest(1000, 12), 10); // 1% / month
assert.equal(monthlyInterest(1000, 0), 0);
assert.equal(monthlyInterest(-5, 12), 0); // clamped
});
test('monthsToPayoff — hand examples + edge cases', () => {
assert.equal(monthsToPayoff(1000, 0, 100), 10); // no interest: 1000/100
assert.equal(monthsToPayoff(0, 12, 100), 0); // already paid
assert.equal(monthsToPayoff(1000, 12, 0), null); // no payment
assert.equal(monthsToPayoff(1000, 12, 5), null); // 5 < 1000×1% interest → never
assert.ok(monthsToPayoff(1000, 12, 100) > 10); // interest adds a few months
});
test('amortizationSchedule — APR=0 is principal-only', () => {
const s = amortizationSchedule(1000, 0, 100);
assert.equal(s.length, 10);
assert.deepEqual(s[0], { month: 1, payment: 100, principal: 100, interest: 0, balance: 900 });
assert.equal(s[9].balance, 0);
});
test('amortizationSchedule — first row of a 12% APR loan', () => {
const s = amortizationSchedule(1000, 12, 100);
// interest = 1000×0.01 = 10; principal = 90; balance = 910
assert.deepEqual(s[0], { month: 1, payment: 100, principal: 90, interest: 10, balance: 910 });
});
test('amortizationSchedule — unpayable / invalid returns []', () => {
assert.deepEqual(amortizationSchedule(1000, 12, 5), []); // payment < interest
assert.deepEqual(amortizationSchedule(0, 12, 100), []); // no balance
assert.deepEqual(amortizationSchedule(1000, 12, 0), []); // no payment
});
// ── snowball simulation ────────────────────────────────────────────────────────
test('calculateSnowball rolls a freed minimum into the next debt (0% hand example)', () => {
// A(200/min100) then B(300/min100), no interest, no extra.
// m1: A→100, B→200 · m2: A→0 (payoff m2, roll +100), B→100 · m3: B→0 (payoff m3)
const r = calculateSnowball([
{ id: 1, name: 'A', current_balance: 200, minimum_payment: 100, interest_rate: 0 },
{ id: 2, name: 'B', current_balance: 300, minimum_payment: 100, interest_rate: 0 },
], 0);
assert.equal(r.months_to_freedom, 3);
assert.equal(r.total_interest_paid, 0);
assert.equal(r.debts.find(d => d.id === 1).payoff_month, 2);
assert.equal(r.debts.find(d => d.id === 2).payoff_month, 3);
assert.equal(r.capped, false);
});
test('calculateAvalanche pays less interest than snowball when order matters', () => {
const debts = [
{ id: 1, name: 'Low APR', current_balance: 1000, minimum_payment: 100, interest_rate: 5 },
{ id: 2, name: 'High APR', current_balance: 1000, minimum_payment: 100, interest_rate: 30 },
];
const sb = calculateSnowball(debts, 200); // attacks id 1 first (given order)
const av = calculateAvalanche(debts, 200); // reorders → attacks id 2 (30%) first
assert.ok(av.total_interest_paid <= sb.total_interest_paid, 'avalanche ≤ snowball interest');
assert.equal(av.months_to_freedom != null, true);
});
test('UNPAYABLE debt: minimum below interest → months_to_freedom null, capped, never_paid (#1)', () => {
// 1000 @ 24% APR = 20/mo interest, min 10 → balance grows forever.
const r = calculateSnowball([
{ id: 1, name: 'Stuck', current_balance: 1000, minimum_payment: 10, interest_rate: 24 },
], 0);
assert.equal(r.months_to_freedom, null, 'no honest payoff date');
assert.equal(r.payoff_date, null);
assert.equal(r.capped, true);
assert.equal(r.debts[0].never_paid, true);
});
test('UNPAYABLE debt among payable ones does not fake a freedom date (#1)', () => {
// The rolled snowball ($50) is far too small to ever cover Stuck's ~$300/mo
// interest, so Stuck never clears even after Payable is gone.
const r = calculateSnowball([
{ id: 1, name: 'Payable', current_balance: 50, minimum_payment: 50, interest_rate: 0 },
{ id: 2, name: 'Stuck', current_balance: 10000, minimum_payment: 10, interest_rate: 36 },
], 0);
assert.equal(r.months_to_freedom, null, 'one unpayable debt ⇒ not free');
assert.equal(r.capped, true);
assert.equal(r.debts.find(d => d.id === 1).payoff_month, 1); // payable one still records its month
assert.equal(r.debts.find(d => d.id === 2).never_paid, true);
});
test('calculateSnowball skips zero / missing balances', () => {
const r = calculateSnowball([
{ id: 1, name: 'Zero', current_balance: 0, minimum_payment: 50, interest_rate: 0 },
{ id: 2, name: 'Null', current_balance: null, minimum_payment: 50, interest_rate: 0 },
{ id: 3, name: 'Real', current_balance: 100, minimum_payment: 100, interest_rate: 0 },
], 0);
assert.equal(r.debts.length, 1);
assert.equal(r.skipped.length, 2);
assert.deepEqual(r.skipped.map(s => s.reason).sort(), ['no_balance', 'zero_balance']);
});
// ── minimum-only baseline ──────────────────────────────────────────────────────
test('calculateMinimumOnly flags its skip reasons', () => {
const r = calculateMinimumOnly([
{ id: 1, name: 'NoBal', current_balance: null, minimum_payment: 50, interest_rate: 0 },
{ id: 2, name: 'Stuck', current_balance: 1000, minimum_payment: 5, interest_rate: 12 }, // 5 < 10 interest
{ id: 3, name: 'NoMin', current_balance: 500, minimum_payment: 0, interest_rate: 0 },
{ id: 4, name: 'Good', current_balance: 300, minimum_payment: 100, interest_rate: 0 },
]);
const reasons = Object.fromEntries(r.skipped.map(s => [s.name, s.reason]));
assert.equal(reasons.NoBal, 'no_balance');
assert.equal(reasons.Stuck, 'payment_below_interest');
assert.equal(reasons.NoMin, 'no_minimum_payment');
assert.equal(r.debts.length, 1); // only "Good" is active
assert.equal(r.months_to_freedom, 3); // 300 / 100
});
// ── APR snapshot ───────────────────────────────────────────────────────────────
test('debtAprSnapshot breaks down a minimum payment', () => {
const snap = debtAprSnapshot({ current_balance: 1000, interest_rate: 12, minimum_payment: 50 });
assert.equal(snap.monthly_interest, 10); // 1000 × 1%
assert.equal(snap.principal_per_min_pmt, 40); // 50 10
assert.equal(snap.interest_pct_of_min, 20); // 10/50
assert.equal(snap.annual_interest_estimate, 120);
assert.equal(debtAprSnapshot({ current_balance: 0 }), null);
});