FlowBudget MVP: Chrome extension with SKU-level parsing, sinking funds, seasonal baselines, lifestyle creep heatmap, net worth dashboard
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
getTotalBudget,
|
||||
getTotalSpent,
|
||||
getRemaining,
|
||||
getProgressPercent,
|
||||
addTransaction,
|
||||
removeTransaction,
|
||||
rollOverMonth,
|
||||
computeLifestyleCreep,
|
||||
getNetWorth,
|
||||
} from '../src/lib/budget.js'
|
||||
import type { Category, Transaction } from '../src/types.js'
|
||||
|
||||
const baseCats: Category[] = [
|
||||
{ id: 'food', name: 'Food', budget: 500, spent: 100, color: '#4CAF50', isSinkingFund: false, rolloverEnabled: false, rolloverAmount: 0 },
|
||||
{ id: 'vacation', name: 'Vacation', budget: 200, spent: 0, color: '#00BCD4', isSinkingFund: true, rolloverEnabled: true, rolloverAmount: 50 },
|
||||
]
|
||||
|
||||
describe('getTotalBudget', () => {
|
||||
it('sums category budgets including rollover', () => {
|
||||
assert.strictEqual(getTotalBudget(baseCats), 750)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTotalSpent', () => {
|
||||
it('sums category spending', () => {
|
||||
assert.strictEqual(getTotalSpent(baseCats), 100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRemaining', () => {
|
||||
it('computes remaining for non-sinking fund', () => {
|
||||
assert.strictEqual(getRemaining(baseCats[0]), 400)
|
||||
})
|
||||
it('includes rollover for sinking fund', () => {
|
||||
assert.strictEqual(getRemaining(baseCats[1]), 250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProgressPercent', () => {
|
||||
it('returns correct percent', () => {
|
||||
assert.strictEqual(getProgressPercent(baseCats[0]), 20)
|
||||
})
|
||||
it('caps at 100', () => {
|
||||
const cat: Category = { ...baseCats[0], spent: 600 }
|
||||
assert.strictEqual(getProgressPercent(cat), 100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addTransaction', () => {
|
||||
it('adds a simple transaction', () => {
|
||||
const tx: Transaction = { id: '1', date: '2024-01-01', amount: 50, merchant: 'Store', categoryId: 'food', description: '', isSplit: false }
|
||||
const result = addTransaction(baseCats, [], tx)
|
||||
assert.strictEqual(result.categories[0].spent, 150)
|
||||
assert.strictEqual(result.transactions.length, 1)
|
||||
})
|
||||
it('adds a split transaction', () => {
|
||||
const tx: Transaction = {
|
||||
id: '2', date: '2024-01-01', amount: 100, merchant: 'Amazon', categoryId: 'food', description: '', isSplit: true,
|
||||
items: [{ name: 'Food', amount: 60, categoryId: 'food' }, { name: 'Thing', amount: 40, categoryId: 'vacation' }],
|
||||
originalAmount: 100,
|
||||
}
|
||||
const result = addTransaction(baseCats, [], tx)
|
||||
assert.strictEqual(result.categories[0].spent, 160)
|
||||
assert.strictEqual(result.categories[1].spent, 40)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeTransaction', () => {
|
||||
it('removes a transaction and reverts spending', () => {
|
||||
const tx: Transaction = { id: '1', date: '2024-01-01', amount: 50, merchant: 'Store', categoryId: 'food', description: '', isSplit: false }
|
||||
const added = addTransaction(baseCats, [], tx)
|
||||
const result = removeTransaction(added.categories, added.transactions, '1')
|
||||
assert.strictEqual(result.categories[0].spent, 100)
|
||||
assert.strictEqual(result.transactions.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollOverMonth', () => {
|
||||
it('rolls over remaining to next month for sinking funds', () => {
|
||||
const cats = rollOverMonth(baseCats)
|
||||
assert.strictEqual(cats[1].rolloverAmount, 300)
|
||||
assert.strictEqual(cats[1].spent, 0)
|
||||
})
|
||||
it('does not roll over non-sinking funds', () => {
|
||||
const cats = rollOverMonth(baseCats)
|
||||
assert.strictEqual(cats[0].rolloverAmount, 0)
|
||||
assert.strictEqual(cats[0].spent, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeLifestyleCreep', () => {
|
||||
it('computes year-over-year changes', () => {
|
||||
const snapshots = [
|
||||
{ month: '2023-06', categories: { food: { budget: 400, spent: 300 } }, netWorth: 10000 },
|
||||
{ month: '2024-06', categories: { food: { budget: 500, spent: 350 } }, netWorth: 12000 },
|
||||
]
|
||||
const creep = computeLifestyleCreep(snapshots, '2024-06')
|
||||
assert.strictEqual(creep.length, 1)
|
||||
assert.strictEqual(creep[0].changePercent, 25)
|
||||
assert.strictEqual(creep[0].isInflation, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNetWorth', () => {
|
||||
it('sums all account balances', () => {
|
||||
const accounts = [{ id: '1', name: 'A', type: 'checking' as const, balance: 1000 }, { id: '2', name: 'B', type: 'savings' as const, balance: 2000 }]
|
||||
assert.strictEqual(getNetWorth(accounts), 3000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = dirname(__dirname)
|
||||
|
||||
try {
|
||||
execSync('npx tsc -p tsconfig.tests.json', { cwd: root, stdio: 'inherit' })
|
||||
} catch (e) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
execSync('node --test dist-tests/tests/budget.test.js', { cwd: root, stdio: 'inherit' })
|
||||
} catch (e) {
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user