FlowBudget MVP: Chrome extension with SKU-level parsing, sinking funds, seasonal baselines, lifestyle creep heatmap, net worth dashboard

This commit is contained in:
Bun Bun
2026-06-19 12:31:22 +00:00
commit d6b9fe0ac6
24 changed files with 3465 additions and 0 deletions
+112
View File
@@ -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)
})
})
+18
View File
@@ -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)
}