diff --git a/HISTORY.md b/HISTORY.md index 4c42f0e..b58f790 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -8,6 +8,11 @@ - **[Export] Richer export โ€” JSON + date ranges** โ€” `GET /api/export` now accepts a `from`/`to` date range (in addition to `year`) for CSV or JSON, and there's a new `GET /api/export/user-json` full portable JSON export (same assembly as the SQLite/Excel exports). The Download pane gains a JSON card and a filtered "Payments export" (date range + CSV/JSON). Test `tests/exportRicher.test.js`. - **[Data] "Erase my data" (danger zone)** โ€” a self-service reset: `POST /api/user/erase-data` (rate-limited, type-to-confirm) transactionally wipes your financial + imported data (bills, payments, transactions, bank connections, categories, imports, rules) while **preserving your account, login, 2FA and preferences**, then re-seeds default categories and audits the action. Red-accented card in Export & backups with a type-to-confirm dialog. Test `tests/eraseUserData.test.js` (wipes only the requesting user, preserves others + the account). +### ๐Ÿ” Snowball review + +- **[Debt] Payoff projection no longer fakes a "freedom" date when a debt can't be paid off** โ€” if a debt's minimum payment never overcomes its interest and the rolling snowball can't cover it either, it never reaches $0. The simulation previously excluded that debt from `months_to_freedom` (a `Math.max` over payoff months treated "never" as 0), so it reported the *other* debts' last month as your payoff date โ€” misleadingly optimistic. Now `months_to_freedom`/`payoff_date` are `null` when any active debt never clears, the result is flagged `capped`, and each such debt is marked `never_paid` (`services/snowballService.js`, `services/aprService.js`). (Snowball review #1) +- **[Debt] Added the missing test coverage for all payoff math** โ€” the debt-payoff engine (`calculateSnowball`/`calculateAvalanche`/`calculateMinimumOnly`/`amortizationSchedule`/`monthsToPayoff`/`debtAprSnapshot`) had **zero** automated tests despite being the most math-heavy code in the app. Added `tests/snowballMath.test.js` (12 tests) with hand-calculated examples (0% and 12% APR amortization rows, snowball rolling, avalanche-beats-snowball interest, skip reasons, APR breakdown) and the unpayable-debt edge above. (Snowball review #5) + ### ๐Ÿ› QA Fixes - **[SimpleFIN] Purging a soft-deleted bill orphaned its matched transactions** โ€” found on the live SimpleFIN DB: 3 transactions were `match_status='matched'` with `matched_bill_id=NULL`. Root cause: bills are soft-deleted (retained for recovery), then the retention GC (`pruneSoftDeletedFinancialRecords`, `services/cleanupService.js`) hard-deletes them past the 30-day window. `transactions.matched_bill_id` is `ON DELETE SET NULL`, so the purge nulled the pointer but left `match_status='matched'` โ€” a limbo row **excluded from spending/analytics (`match_status != 'matched'`) yet attributed to no bill**, silently dropping that spend. The purge now releases those matches back to `'unmatched'` in the same transaction and self-heals any pre-existing orphans; retention behaviour is unchanged. Verified on a copy of the live DB (3โ†’0 orphans, 0 transactions lost). Regression: 3 tests in `tests/backupAndCleanup.test.js`. (QA-B5-04) diff --git a/services/aprService.js b/services/aprService.js index 59d1bf3..a12ee61 100644 --- a/services/aprService.js +++ b/services/aprService.js @@ -167,20 +167,23 @@ function calculateMinimumOnly(debts, startDate = new Date()) { payoff_display: d.payoffMonth ? monthDisplay(d.payoffMonth) : null, total_interest: round2(d.totalInterest), months: d.payoffMonth, + never_paid: d.payoffMonth === null, })); + // Only report a payoff date if every active debt actually clears (see _simulate). + const allPaid = active.every(d => d.payoffMonth !== null); const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0)); const totalInterest = sumMoney(active, d => d.totalInterest); return { - months_to_freedom: maxMonth || null, + months_to_freedom: allPaid && maxMonth ? maxMonth : null, total_interest_paid: round2(totalInterest), - payoff_date: maxMonth ? monthLabel(maxMonth) : null, - payoff_display: maxMonth ? monthDisplay(maxMonth) : null, + payoff_date: allPaid && maxMonth ? monthLabel(maxMonth) : null, + payoff_display: allPaid && maxMonth ? monthDisplay(maxMonth) : null, debts: debtResults, skipped, extra_payment: 0, - capped: month >= MAX_MONTHS, + capped: month >= MAX_MONTHS || !allPaid, }; } diff --git a/services/snowballService.js b/services/snowballService.js index fee0eab..8239211 100644 --- a/services/snowballService.js +++ b/services/snowballService.js @@ -111,20 +111,26 @@ function _simulate(orderedDebts, extraPayment, startDate) { payoff_display: d.payoffMonth ? monthDisplay(d.payoffMonth) : null, total_interest: round2(d.totalInterest), months: d.payoffMonth, + never_paid: d.payoffMonth === null, // minimum never overcomes interest })); + // A payoff date only exists if EVERY active debt actually reaches $0. If a + // debt's minimum never overcomes its interest (and the snowball can't cover + // it), it never pays off โ€” reporting the other debts' last month would be a + // misleadingly optimistic "freedom" date, so return null and mark it capped. + const allPaid = active.every(d => d.payoffMonth !== null); const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0)); const totalInterest = sumMoney(active, d => d.totalInterest); return { - months_to_freedom: maxMonth || null, + months_to_freedom: allPaid && maxMonth ? maxMonth : null, total_interest_paid: round2(totalInterest), - payoff_date: maxMonth ? monthLabel(maxMonth) : null, - payoff_display: maxMonth ? monthDisplay(maxMonth) : null, + payoff_date: allPaid && maxMonth ? monthLabel(maxMonth) : null, + payoff_display: allPaid && maxMonth ? monthDisplay(maxMonth) : null, debts: debtResults, skipped, extra_payment: extra, - capped: month >= MAX_MONTHS, + capped: month >= MAX_MONTHS || !allPaid, }; } diff --git a/tests/snowballMath.test.js b/tests/snowballMath.test.js new file mode 100644 index 0000000..52ee64c --- /dev/null +++ b/tests/snowballMath.test.js @@ -0,0 +1,137 @@ +'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); +});