From a44e78034c42e7af76f1c73f6853349b20dcf7d6 Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Fri, 2 Oct 2026 01:49:52 +0530 Subject: [PATCH 1/4] feat(categories): add category management with main/subcategory support and Others fallback --- src/app/api/categories/route.ts | 96 ++-- src/app/budgets/page.tsx | 38 +- src/app/layout.tsx | 2 + src/components/CategoryModal.tsx | 644 ++++++++++++++++++++++++ src/components/CommandPalette.tsx | 11 + src/components/PayeeTagManagerModal.tsx | 473 ++++++++++------- src/components/TransactionModal.tsx | 94 +++- src/context/AppContext.tsx | 52 ++ src/lib/finance-service.ts | 169 +++++++ tests/finance-service.test.ts | 60 +++ 10 files changed, 1420 insertions(+), 219 deletions(-) create mode 100644 src/components/CategoryModal.tsx diff --git a/src/app/api/categories/route.ts b/src/app/api/categories/route.ts index 237decf..f7f5bef 100644 --- a/src/app/api/categories/route.ts +++ b/src/app/api/categories/route.ts @@ -1,28 +1,15 @@ import { NextResponse } from 'next/server'; import { getCurrentUser, handleApiError } from '@/lib/auth'; -import { getDb } from '@/lib/db'; +import { FinanceService } from '@/lib/finance-service'; export const runtime = 'nodejs'; export async function GET(req: Request) { try { const user = getCurrentUser(req); - const db = getDb(); - - const categories = db.prepare(` - SELECT * FROM categories - WHERE (user_id = ? OR id LIKE 'cat-%') AND archived = 0 - ORDER BY sort_order ASC, name ASC - `).all(user.id) as any[]; - - // Structure into parent and subcategories - const parents = categories.filter(c => !c.parent_id); - const tree = parents.map(p => ({ - ...p, - subcategories: categories.filter(c => c.parent_id === p.id), - })); - - return NextResponse.json({ categories, tree }); + const service = new FinanceService(); + const result = service.getCategories(user.id); + return NextResponse.json(result); } catch (error: any) { return handleApiError(error, 'Failed to fetch categories'); } @@ -32,30 +19,67 @@ export async function POST(req: Request) { try { const user = getCurrentUser(req); const body = await req.json(); - const db = getDb(); - if (!body.name || !body.type) { - return NextResponse.json({ error: 'Name and type are required' }, { status: 400 }); + if (!body.name) { + return NextResponse.json({ error: 'Category name is required' }, { status: 400 }); } - const id = `cat_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`; - db.prepare(` - INSERT INTO categories (id, user_id, parent_id, name, type, icon, color, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - id, - user.id, - body.parentId || null, - body.name, - body.type, - body.icon || 'tag', - body.color || '#6B7280', - body.sortOrder || 99 - ); - - const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(id); + const service = new FinanceService(); + const category = service.createCategory(user.id, { + name: body.name, + type: body.type, + parentId: body.parentId, + isSubcategory: body.isSubcategory, + icon: body.icon, + color: body.color, + sortOrder: body.sortOrder, + }); + return NextResponse.json({ category }, { status: 201 }); } catch (error: any) { return handleApiError(error, 'Failed to create category'); } } + +export async function PUT(req: Request) { + try { + const user = getCurrentUser(req); + const body = await req.json(); + + if (!body.id) { + return NextResponse.json({ error: 'Category id is required' }, { status: 400 }); + } + + const service = new FinanceService(); + const category = service.updateCategory(user.id, body.id, { + name: body.name, + parentId: body.parentId, + icon: body.icon, + color: body.color, + sortOrder: body.sortOrder, + }); + + return NextResponse.json({ category }); + } catch (error: any) { + return handleApiError(error, 'Failed to update category'); + } +} + +export async function DELETE(req: Request) { + try { + const user = getCurrentUser(req); + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ error: 'Category id is required' }, { status: 400 }); + } + + const service = new FinanceService(); + service.deleteCategory(user.id, id); + + return NextResponse.json({ success: true }); + } catch (error: any) { + return handleApiError(error, 'Failed to delete category'); + } +} diff --git a/src/app/budgets/page.tsx b/src/app/budgets/page.tsx index 99ff083..e3f7b0f 100644 --- a/src/app/budgets/page.tsx +++ b/src/app/budgets/page.tsx @@ -7,7 +7,7 @@ import { Plus, PieChart, AlertTriangle, CheckCircle2, Trash2, X } from 'lucide-r import { formatIndianNumberString, parseIndianNumber } from '@/lib/amount-evaluator'; export default function BudgetsPage() { - const { showToast, refreshKey, triggerRefresh, startAsyncOp, stopAsyncOp } = useApp(); + const { showToast, refreshKey, triggerRefresh, startAsyncOp, stopAsyncOp, openCategoryModal } = useApp(); const [budgets, setBudgets] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); @@ -94,9 +94,14 @@ export default function BudgetsPage() {

- +
+ + +
{/* Summary Cards */} @@ -200,9 +205,28 @@ export default function BudgetsPage() {
- +
+ + +
{ + const val = e.target.value; + setParentId(val); + if (val) { + const sel = parentCandidates.find(p => p.id === val); + if (sel && sel.color) setColor(sel.color); + } + }} + style={{ + width: '100%', + padding: '0.625rem 0.75rem', + backgroundColor: 'var(--bg-surface)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + color: 'var(--text-primary)', + fontSize: '0.875rem', + }} + > + + {parentCandidates.map(p => ( + + ))} + + {!parentId && ( +
+ If unselected, this subcategory will automatically appear under “Others” +
+ )} +
+ )} + + {/* Category Name Input with Live Preview */} +
+ +
+
+ +
+ setName(e.target.value)} + placeholder={isSubcategory ? 'e.g., Groceries, Coffee, Cab' : 'e.g., Food & Dining, Pet Care'} + autoFocus + style={{ + flex: 1, + padding: '0.625rem 0.75rem', + fontSize: '0.875rem', + backgroundColor: 'var(--bg-surface)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + color: 'var(--text-primary)', + }} + /> +
+
+ + {/* Color Presets */} +
+ +
+ {COLOR_PRESETS.map(c => ( +
+
+ + {/* Icon Selector Grid */} +
+ +
+ {ICON_OPTIONS.map(opt => { + const isSelected = icon === opt.name; + const IconComponent = opt.Icon; + return ( + + ); + })} +
+
+ + {/* Actions */} +
+ + +
+
+ + + ); +}; + +export const GlobalCategoryModal: React.FC = () => { + const { isCategoryModalOpen, closeCategoryModal, categoryModalOptions } = useApp(); + + if (!isCategoryModalOpen) return null; + + return ( + + ); +}; diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 9942bee..6c45141 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -31,6 +31,7 @@ export const CommandPalette = () => { openTransactionModal, openImportModal, openAccountModal, + openCategoryModal, theme, setTheme, showToast, @@ -100,6 +101,16 @@ export const CommandPalette = () => { openTransactionModal('income'); }, }, + { + id: 'add-category', + label: 'Add Category / Subcategory', + shortcut: 'C', + icon: PlusCircle, + action: () => { + closeCommandPalette(); + openCategoryModal(); + }, + }, { id: 'transfer', label: 'Transfer Money', diff --git a/src/components/PayeeTagManagerModal.tsx b/src/components/PayeeTagManagerModal.tsx index aa55bc6..ea16d72 100644 --- a/src/components/PayeeTagManagerModal.tsx +++ b/src/components/PayeeTagManagerModal.tsx @@ -2,23 +2,25 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useApp } from '@/context/AppContext'; -import { X, Tag, Store, Trash2, Plus, Search, Edit2, AlertTriangle, Check } from 'lucide-react'; +import { X, Tag, Store, Trash2, Plus, Search, Edit2, AlertTriangle, Check, Folder, Layers } from 'lucide-react'; interface PayeeTagManagerModalProps { isOpen: boolean; onClose: () => void; - initialTab?: 'payees' | 'tags'; + initialTab?: 'categories' | 'payees' | 'tags'; onUpdate?: () => void; } export const PayeeTagManagerModal: React.FC = ({ isOpen, onClose, - initialTab = 'payees', + initialTab = 'categories', onUpdate, }) => { - const { showToast } = useApp(); - const [activeTab, setActiveTab] = useState<'payees' | 'tags'>(initialTab); + const { showToast, openCategoryModal } = useApp(); + const [activeTab, setActiveTab] = useState<'categories' | 'payees' | 'tags'>(initialTab); + const [categories, setCategories] = useState([]); + const [categoryTree, setCategoryTree] = useState([]); const [payees, setPayees] = useState([]); const [tags, setTags] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -33,14 +35,17 @@ export const PayeeTagManagerModal: React.FC = ({ const loadData = useCallback(async () => { try { - const [merchRes, tagsRes] = await Promise.all([ + const [merchRes, tagsRes, catRes] = await Promise.all([ fetch('/api/merchants').then(r => r.json()), fetch('/api/tags').then(r => r.json()), + fetch('/api/categories').then(r => r.json()), ]); setPayees(merchRes.merchants || []); setTags(tagsRes.tags || []); + setCategories(catRes.categories || []); + setCategoryTree(catRes.tree || []); } catch { - showToast('Failed to load payees or tags', 'error'); + showToast('Failed to load data', 'error'); } }, [showToast]); @@ -82,6 +87,19 @@ export const PayeeTagManagerModal: React.FC = ({ } }; + const handleDeleteCategory = async (id: string, name: string) => { + try { + const res = await fetch(`/api/categories?id=${id}`, { method: 'DELETE' }); + if (res.ok) { + showToast(`Category "${name}" removed`); + loadData(); + onUpdate?.(); + } + } catch { + showToast('Failed to delete category', 'error'); + } + }; + const handleStartEditTag = (tag: any) => { setEditingTagId(tag.id); setEditTagName(tag.name); @@ -124,26 +142,26 @@ export const PayeeTagManagerModal: React.FC = ({ const handleCreateTag = async (e: React.FormEvent) => { e.preventDefault(); - if (!newTagName.trim()) return; + const cleanName = newTagName.trim().replace(/^#/, ''); + if (!cleanName) return; - setLoading(true); try { const res = await fetch('/api/tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newTagName.trim(), color: newTagColor }), + body: JSON.stringify({ name: cleanName, color: newTagColor }), }); const data = await res.json(); if (res.ok) { - showToast(`Tag "#${data.tag.name}" created`); + showToast(`Tag "#${cleanName}" created`); setNewTagName(''); loadData(); onUpdate?.(); + } else { + showToast(data.error || 'Failed to create tag', 'error'); } } catch { showToast('Failed to create tag', 'error'); - } finally { - setLoading(false); } }; @@ -152,41 +170,80 @@ export const PayeeTagManagerModal: React.FC = ({ ); const filteredTags = tags.filter(t => - t.name.toLowerCase().includes(searchQuery.toLowerCase()) + t.name.toLowerCase().includes(searchQuery.toLowerCase().replace(/^#/, '')) ); + const filteredCategoryTree = categoryTree.filter(cat => { + const matchesParent = cat.name.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesSub = cat.subcategories?.some((s: any) => + s.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + return matchesParent || matchesSub; + }); + return ( -
+
e.stopPropagation()} - style={{ maxWidth: '500px', width: '100%', padding: '1.5rem' }} > {/* Header */} -
-
+
+
- {activeTab === 'payees' ? : } + {activeTab === 'categories' ? : activeTab === 'payees' ? : }
-

Manage Payees & Tags

-
- Clean up or delete suggestions from your autocomplete lists -
+

+ Manage Categories, Payees & Tags +

+

+ Organize your financial classification data +

-
@@ -202,6 +259,25 @@ export const PayeeTagManagerModal: React.FC = ({ gap: '0.25rem', }} > +
- {/* Search */} -
- - setSearchQuery(e.target.value)} - /> + {/* Search & Actions Bar */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+ {activeTab === 'categories' && ( + + )}
{/* Tab Content */} - {activeTab === 'payees' ? ( + {activeTab === 'categories' ? ( +
+ {filteredCategoryTree.length === 0 ? ( +
+ {searchQuery ? 'No categories matching search' : 'No categories found'} +
+ ) : ( + filteredCategoryTree.map(parent => ( +
+
+
+ + + {parent.name} + + + {parent.type} + +
+ +
+ + {!parent.id.startsWith('cat-') && ( + + )} +
+
+ + {/* Subcategories list */} + {parent.subcategories && parent.subcategories.length > 0 && ( +
+ {parent.subcategories.map((sub: any) => ( + + + {sub.name} + {!sub.id.startsWith('cat-') && ( + + )} + + ))} +
+ )} +
+ )) + )} +
+ ) : activeTab === 'payees' ? (
{filteredPayees.length === 0 ? ( @@ -300,35 +497,38 @@ export const PayeeTagManagerModal: React.FC = ({ type="text" className="form-input" style={{ flex: 1, fontSize: '0.825rem' }} - placeholder="New tag name (e.g. vacation)" + placeholder="New tag name (e.g. vacation, taxes)..." value={newTagName} onChange={e => setNewTagName(e.target.value)} /> -
- {tagColors.map(c => ( +
+ {tagColors.slice(0, 5).map(c => (
- -
+
{filteredTags.length === 0 ? (
{searchQuery ? 'No tags matching search' : 'No tags created yet'} @@ -336,122 +536,60 @@ export const PayeeTagManagerModal: React.FC = ({ ) : ( filteredTags.map(t => { const isEditing = editingTagId === t.id; - if (isEditing) { - const cleanOld = t.name; - const cleanNew = editTagName.trim().replace(/^#/, ''); - const isRenaming = cleanNew && cleanNew.toLowerCase() !== cleanOld.toLowerCase(); - return (
-
- # - setEditTagName(e.target.value)} - placeholder="Tag name" - style={{ flex: 1, fontSize: '0.825rem', padding: '0.35rem 0.6rem' }} - autoFocus - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault(); - handleUpdateTag(t.id, t.name); - } else if (e.key === 'Escape') { - setEditingTagId(null); - } - }} - /> -
- - {/* Color Selector */} -
- Tag Color: -
- {tagColors.map(c => ( -
-
- - {/* Warning Box on Rename */} - {isRenaming && ( -
- -
- Warning: Renaming this tag will update all{' '} - {t.transaction_count || 0} previous transaction(s) tagged with{' '} - #{t.name} to #{cleanNew}. -
-
- )} - - {/* Save / Cancel buttons */} -
- - + setEditTagName(e.target.value)} + autoFocus + /> +
+ {tagColors.map(c => ( +
+ +
); } @@ -463,28 +601,27 @@ export const PayeeTagManagerModal: React.FC = ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '0.5rem 0.75rem', + padding: '0.55rem 0.75rem', backgroundColor: 'var(--bg-subtle)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-default)', - transition: 'border-color 0.15s ease', }} >
#{t.name} - - ({t.transaction_count || 0}) + + ({t.transaction_count || 0} txs)
-
+
+
+ + • + +
@@ -1456,6 +1482,58 @@ export const TransactionModal = () => { ); }); })()} + + {/* Add Category Action button at bottom of dropdown */} +
+ +
)}
diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index a99f4d0..2925cc7 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -38,6 +38,22 @@ interface AppContextType { isCommandPaletteOpen: boolean; openCommandPalette: () => void; closeCommandPalette: () => void; + isCategoryModalOpen: boolean; + categoryModalOptions: { + initialType?: 'expense' | 'income'; + initialParentId?: string; + initialName?: string; + initialIsSubcategory?: boolean; + onCategoryCreated?: (category: any) => void; + }; + openCategoryModal: ( + initialType?: 'expense' | 'income', + initialParentId?: string, + initialName?: string, + initialIsSubcategory?: boolean, + onCategoryCreated?: (category: any) => void + ) => void; + closeCategoryModal: () => void; isImportModalOpen: boolean; openImportModal: () => void; closeImportModal: () => void; @@ -228,6 +244,37 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { setEditingTransaction(null); }, []); + const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); + const [categoryModalOptions, setCategoryModalOptions] = useState<{ + initialType?: 'expense' | 'income'; + initialParentId?: string; + initialName?: string; + initialIsSubcategory?: boolean; + onCategoryCreated?: (category: any) => void; + }>({}); + + const openCategoryModal = useCallback(( + initialType: 'expense' | 'income' = 'expense', + initialParentId?: string, + initialName?: string, + initialIsSubcategory?: boolean, + onCategoryCreated?: (category: any) => void + ) => { + setCategoryModalOptions({ + initialType, + initialParentId, + initialName, + initialIsSubcategory: initialIsSubcategory ?? Boolean(initialParentId), + onCategoryCreated, + }); + setIsCategoryModalOpen(true); + }, []); + + const closeCategoryModal = useCallback(() => { + setIsCategoryModalOpen(false); + setCategoryModalOptions({}); + }, []); + const openCommandPalette = useCallback(() => setIsCommandPaletteOpen(true), []); const closeCommandPalette = useCallback(() => setIsCommandPaletteOpen(false), []); @@ -250,6 +297,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { if (e.key === 'Escape') { if (isCommandPaletteOpen) setIsCommandPaletteOpen(false); + if (isCategoryModalOpen) setIsCategoryModalOpen(false); if (isTransactionModalOpen) { setIsTransactionModalOpen(false); setEditingTransaction(null); @@ -411,6 +459,10 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { isCommandPaletteOpen, openCommandPalette, closeCommandPalette, + isCategoryModalOpen, + categoryModalOptions, + openCategoryModal, + closeCategoryModal, isImportModalOpen, openImportModal, closeImportModal, diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index 04ec9da..fdf9b68 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -452,6 +452,175 @@ export class FinanceService { return { success: true }; } + getCategories(userId: string) { + const categories = this.db.prepare(` + SELECT * FROM categories + WHERE (user_id = ? OR id LIKE 'cat-%') AND archived = 0 + ORDER BY sort_order ASC, name ASC + `).all(userId) as any[]; + + const parents = categories.filter(c => !c.parent_id); + const tree = parents.map(p => ({ + ...p, + subcategories: categories.filter(c => c.parent_id === p.id), + })); + + return { categories, tree }; + } + + createCategory(userId: string, data: { + name: string; + type?: 'expense' | 'income'; + parentId?: string | null; + isSubcategory?: boolean; + icon?: string; + color?: string; + sortOrder?: number; + }) { + const cleanName = data.name ? data.name.trim() : ''; + if (!cleanName) { + throw new Error('Category name is required'); + } + + let parentId: string | null = data.parentId && data.parentId !== 'none' ? data.parentId.trim() : null; + let categoryType: 'expense' | 'income' = data.type || 'expense'; + let categoryColor = data.color || '#6B7280'; + let categoryIcon = data.icon || 'tag'; + + // If a parent ID was explicitly provided, verify parent exists & inherit its type/color if not given + if (parentId) { + const parent = this.db.prepare(` + SELECT * FROM categories + WHERE id = ? AND (user_id = ? OR id LIKE 'cat-%') AND archived = 0 + `).get(parentId, userId) as any; + + if (!parent) { + throw new Error('Parent category not found'); + } + categoryType = parent.type || categoryType; + if (!data.color) categoryColor = parent.color || categoryColor; + } else if (data.isSubcategory) { + // Subcategory requested but no parent specified -> Default to 'Others' parent category + let othersParent = this.db.prepare(` + SELECT * FROM categories + WHERE (user_id = ? OR id LIKE 'cat-%') + AND parent_id IS NULL + AND LOWER(name) IN ('others', 'other', 'other expenses', 'other income') + AND type = ? + AND archived = 0 + ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END, sort_order ASC + LIMIT 1 + `).get(userId, categoryType, userId) as any; + + if (!othersParent) { + // Create an 'Others' parent category for the user + const othersId = `cat_${userId}_others_${categoryType}`; + const othersName = categoryType === 'income' ? 'Other Income' : 'Others'; + this.db.prepare(` + INSERT INTO categories (id, user_id, parent_id, name, type, icon, color, sort_order) + VALUES (?, ?, NULL, ?, ?, 'more-horizontal', '#6B7280', 99) + `).run(othersId, userId, othersName, categoryType); + + othersParent = { id: othersId, name: othersName, type: categoryType }; + } + + parentId = othersParent.id; + if (!data.color) categoryColor = othersParent.color || categoryColor; + } + + // Check if category already exists under same parent + let existing: any = null; + if (parentId) { + existing = this.db.prepare(` + SELECT * FROM categories + WHERE (user_id = ? OR id LIKE 'cat-%') + AND parent_id = ? + AND LOWER(name) = ? + AND archived = 0 + `).get(userId, parentId, cleanName.toLowerCase()); + } else { + existing = this.db.prepare(` + SELECT * FROM categories + WHERE (user_id = ? OR id LIKE 'cat-%') + AND parent_id IS NULL + AND LOWER(name) = ? + AND type = ? + AND archived = 0 + `).get(userId, cleanName.toLowerCase(), categoryType); + } + + if (existing) { + return existing; + } + + const id = `cat_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`; + const sortOrder = data.sortOrder ?? 50; + + this.db.prepare(` + INSERT INTO categories (id, user_id, parent_id, name, type, icon, color, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + userId, + parentId, + cleanName, + categoryType, + categoryIcon, + categoryColor, + sortOrder + ); + + const created = this.db.prepare('SELECT * FROM categories WHERE id = ?').get(id) as any; + return created; + } + + updateCategory(userId: string, id: string, data: { + name?: string; + parentId?: string | null; + icon?: string; + color?: string; + sortOrder?: number; + }) { + const existing = this.db.prepare(` + SELECT * FROM categories WHERE id = ? AND (user_id = ? OR id LIKE 'cat-%') + `).get(id, userId) as any; + + if (!existing) throw new Error('Category not found'); + + const newName = data.name !== undefined ? data.name.trim() : existing.name; + if (!newName) throw new Error('Category name cannot be empty'); + + const newParentId = data.parentId !== undefined ? (data.parentId === 'none' ? null : data.parentId) : existing.parent_id; + const newIcon = data.icon || existing.icon || 'tag'; + const newColor = data.color || existing.color || '#6B7280'; + const newSortOrder = data.sortOrder !== undefined ? data.sortOrder : existing.sort_order; + + this.db.prepare(` + UPDATE categories + SET name = ?, parent_id = ?, icon = ?, color = ?, sort_order = ? + WHERE id = ? AND user_id = ? + `).run(newName, newParentId, newIcon, newColor, newSortOrder, id, userId); + + return this.db.prepare('SELECT * FROM categories WHERE id = ?').get(id); + } + + deleteCategory(userId: string, id: string) { + const existing = this.db.prepare(` + SELECT * FROM categories WHERE id = ? AND (user_id = ? OR id LIKE 'cat-%') + `).get(id, userId) as any; + + if (!existing) throw new Error('Category not found'); + + // Reassign subcategories if this is a parent + this.db.prepare(` + UPDATE categories SET parent_id = NULL WHERE parent_id = ? AND user_id = ? + `).run(id, userId); + + // Delete category + this.db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?').run(id, userId); + return { success: true }; + } + suggestCategory(userId: string, merchantName?: string, notes?: string, amount?: number): string | null { // 1. Check rules const rules = this.db.prepare(` diff --git a/tests/finance-service.test.ts b/tests/finance-service.test.ts index 029bee8..d68af9d 100644 --- a/tests/finance-service.test.ts +++ b/tests/finance-service.test.ts @@ -836,6 +836,66 @@ describe('Finance Domain Business Logic Tests', () => { const deleted = service.getAccountById(acc.id, userId); expect(deleted).toBeNull(); }); + + it('supports creating main categories, subcategories with parent, and subcategories defaulting to Others', () => { + // 1. Create a top-level main category + const mainCat = service.createCategory(userId, { + name: 'Pets & Animals', + type: 'expense', + icon: 'dog', + color: '#F59E0B', + }); + expect(mainCat).toBeDefined(); + expect(mainCat.name).toBe('Pets & Animals'); + expect(mainCat.parent_id).toBeNull(); + expect(mainCat.type).toBe('expense'); + + // 2. Create a subcategory under the main category + const subCat1 = service.createCategory(userId, { + name: 'Veterinary Care', + parentId: mainCat.id, + isSubcategory: true, + icon: 'heart-pulse', + }); + expect(subCat1).toBeDefined(); + expect(subCat1.name).toBe('Veterinary Care'); + expect(subCat1.parent_id).toBe(mainCat.id); + expect(subCat1.type).toBe('expense'); + + // 3. Create a subcategory with NO parent category specified -> must automatically be placed under 'Others' + const orphanSubCat = service.createCategory(userId, { + name: 'Unspecified Miscellaneous Item', + type: 'expense', + isSubcategory: true, + parentId: '', // omitted / empty + }); + expect(orphanSubCat).toBeDefined(); + expect(orphanSubCat.name).toBe('Unspecified Miscellaneous Item'); + expect(orphanSubCat.parent_id).not.toBeNull(); + + // Verify the parent category is 'Others' + const categoriesResult = service.getCategories(userId); + const othersParent = categoriesResult.categories.find(c => c.id === orphanSubCat.parent_id); + expect(othersParent).toBeDefined(); + expect(othersParent.name.toLowerCase()).toContain('other'); + + // Verify category tree structure + const treeOthers = categoriesResult.tree.find(t => t.id === othersParent.id); + expect(treeOthers).toBeDefined(); + expect(treeOthers?.subcategories.some((s: any) => s.name === 'Unspecified Miscellaneous Item')).toBe(true); + + // 4. Update category + const updated = service.updateCategory(userId, subCat1.id, { + name: 'Vet & Hospital', + color: '#10B981', + }) as any; + expect(updated?.name).toBe('Vet & Hospital'); + expect(updated?.color).toBe('#10B981'); + + // 5. Delete category + const delResult = service.deleteCategory(userId, subCat1.id); + expect(delResult.success).toBe(true); + }); }); From 50959ad06c7000d8c1a3a0878f77017ea8b10f5b Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Fri, 2 Oct 2026 02:00:27 +0530 Subject: [PATCH 2/4] fix(loans): record emi payment as repayment transfer and restore loan balance on deletion --- src/app/loans/page.tsx | 83 ++++++++--- src/lib/db.ts | 28 +++- src/lib/finance-service.ts | 258 +++++++++++++++++++++++++++++----- tests/finance-service.test.ts | 76 +++++++--- 4 files changed, 369 insertions(+), 76 deletions(-) diff --git a/src/app/loans/page.tsx b/src/app/loans/page.tsx index 2dcb542..5d526b6 100644 --- a/src/app/loans/page.tsx +++ b/src/app/loans/page.tsx @@ -151,7 +151,7 @@ export default function LoansPage() { if (!res.ok) throw new Error(data.error || 'Failed to record payment'); const emiFormatted = Math.round(payModalLoan.emi_amount / 100).toLocaleString('en-IN'); - showToast(`Installment of ₹${emiFormatted} recorded! Bank cash & liabilities updated.`); + showToast(`Repayment transfer of ₹${emiFormatted} recorded! Cash & debt updated.`); setPayModalLoan(null); triggerRefresh(); } catch (err: any) { @@ -402,7 +402,8 @@ export default function LoansPage() { className="btn-primary" onClick={() => { setPayModalLoan(loan); - if (loan.account_id) setPayAccountId(loan.account_id); + const defaultDebit = accounts.find(a => a.id !== loan.account_id && (a.type === 'bank' || a.type === 'savings' || a.type === 'current' || a.type === 'cash')) || accounts.find(a => a.id !== loan.account_id) || accounts[0]; + if (defaultDebit) setPayAccountId(defaultDebit.id); setPayDate(new Date().toISOString().substring(0, 10)); }} style={{ @@ -501,27 +502,42 @@ export default function LoansPage() { Principal Interest Remaining Balance + Status - {amort.schedule.map((row: any) => ( - - #{row.monthNumber} - {row.paymentDate} - - ₹{Math.round(row.emi / 100).toLocaleString('en-IN')} - - - ₹{Math.round(row.principalComponent / 100).toLocaleString('en-IN')} - - - ₹{Math.round(row.interestComponent / 100).toLocaleString('en-IN')} - - - ₹{Math.round((row.remainingBalance ?? row.remainingPrincipal) / 100).toLocaleString('en-IN')} - - - ))} + {amort.schedule.map((row: any) => { + const isPaid = Boolean(row.isPaid); + return ( + + #{row.monthNumber} + {row.paymentDate} + + ₹{Math.round(row.emi / 100).toLocaleString('en-IN')} + + + ₹{Math.round(row.principalComponent / 100).toLocaleString('en-IN')} + + + ₹{Math.round(row.interestComponent / 100).toLocaleString('en-IN')} + + + ₹{Math.round((row.remainingBalance ?? row.remainingPrincipal) / 100).toLocaleString('en-IN')} + + + {isPaid ? ( + + ✓ Paid + + ) : ( + + Upcoming + + )} + + + ); + })}
@@ -828,9 +844,30 @@ export default function LoansPage() {
+ {payModalLoan.account_name && ( +
+
+ + Target Account: {payModalLoan.account_name} +
+ Repayment Transfer +
+ )} +

- This will record an expense transaction and automatically deduct ₹{Math.round(payModalLoan.emi_amount / 100).toLocaleString('en-IN')} from your liquid cash. + This will record a repayment transfer from your selected account to {payModalLoan.account_name || 'the linked debt account'}, reducing both your liquid cash and debt.

diff --git a/src/lib/db.ts b/src/lib/db.ts index 7cf938f..f84f04b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -147,7 +147,9 @@ export function initSchema(db: DatabaseSync) { const hasGoalNotes = (db as any).prepare("SELECT 1 FROM pragma_table_info('goals') WHERE name = 'notes'").get(); const hasLoanType = (db as any).prepare("SELECT 1 FROM pragma_table_info('loans') WHERE name = 'type'").get(); const hasLoanNotes = (db as any).prepare("SELECT 1 FROM pragma_table_info('loans') WHERE name = 'notes'").get(); - if (hasGoalNotes && hasLoanType && hasLoanNotes) { + const hasTxLoanId = (db as any).prepare("SELECT 1 FROM pragma_table_info('transactions') WHERE name = 'loan_id'").get(); + const hasLoanPayments = (db as any).prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='loan_payments'").get(); + if (hasGoalNotes && hasLoanType && hasLoanNotes && hasTxLoanId && hasLoanPayments) { isFullyMigrated = true; } } catch { @@ -240,6 +242,7 @@ export function initSchema(db: DatabaseSync) { transfer_group_id TEXT, -- pairs the 2 sides of a transfer transfer_peer_account_id TEXT, -- for display convenience destination_account_id TEXT, -- for outgoing transfer target + loan_id TEXT, is_deleted INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), @@ -377,6 +380,7 @@ export function initSchema(db: DatabaseSync) { name TEXT NOT NULL, principal INTEGER NOT NULL, outstanding_principal INTEGER NOT NULL, + initial_outstanding_principal INTEGER DEFAULT 0, interest_rate REAL NOT NULL, -- percentage e.g. 8.5 emi_amount INTEGER NOT NULL, tenure_months INTEGER NOT NULL, @@ -389,6 +393,21 @@ export function initSchema(db: DatabaseSync) { FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS loan_payments ( + id TEXT PRIMARY KEY, + loan_id TEXT NOT NULL, + user_id TEXT NOT NULL, + transaction_id TEXT, + transfer_group_id TEXT, + amount INTEGER NOT NULL DEFAULT 0, + principal_paid INTEGER NOT NULL DEFAULT 0, + interest_paid INTEGER NOT NULL DEFAULT 0, + payment_date TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (loan_id) REFERENCES loans(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS reconciliations ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -456,6 +475,7 @@ export function initSchema(db: DatabaseSync) { `ALTER TABLE transactions ADD COLUMN recurring_id TEXT`, `ALTER TABLE transactions ADD COLUMN transfer_peer_account_id TEXT`, `ALTER TABLE transactions ADD COLUMN destination_account_id TEXT`, + `ALTER TABLE transactions ADD COLUMN loan_id TEXT`, `ALTER TABLE budgets ADD COLUMN period_type TEXT DEFAULT 'monthly'`, `ALTER TABLE budgets ADD COLUMN period_start TEXT`, `ALTER TABLE budgets ADD COLUMN period_end TEXT`, @@ -464,10 +484,16 @@ export function initSchema(db: DatabaseSync) { `ALTER TABLE investments ADD COLUMN cost_basis INTEGER DEFAULT 0`, `ALTER TABLE loans ADD COLUMN principal INTEGER DEFAULT 0`, `ALTER TABLE loans ADD COLUMN outstanding_principal INTEGER DEFAULT 0`, + `ALTER TABLE loans ADD COLUMN initial_outstanding_principal INTEGER DEFAULT 0`, `ALTER TABLE loans ADD COLUMN tenure_months INTEGER DEFAULT 12`, `ALTER TABLE loans ADD COLUMN emi_day INTEGER DEFAULT 5`, `ALTER TABLE loans ADD COLUMN type TEXT DEFAULT 'loan'`, `ALTER TABLE loans ADD COLUMN notes TEXT`, + `CREATE INDEX IF NOT EXISTS idx_loan_payments_loan ON loan_payments(loan_id)`, + `CREATE INDEX IF NOT EXISTS idx_loan_payments_user ON loan_payments(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_loan_payments_tx ON loan_payments(transaction_id)`, + `CREATE INDEX IF NOT EXISTS idx_loan_payments_tg ON loan_payments(transfer_group_id)`, + `CREATE INDEX IF NOT EXISTS idx_tx_loan_id ON transactions(loan_id)`, ]; for (const sql of safeAlterations) { diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index 8824e37..05d9e90 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -34,6 +34,7 @@ export interface CreateTransactionDTO { status?: 'pending' | 'cleared' | 'reconciled'; splits?: Array<{ categoryId: string; amount: number; notes?: string }>; tags?: string[]; + loanId?: string; } export class FinanceService { @@ -532,6 +533,7 @@ export class FinanceService { status = 'cleared', splits = [], tags = [], + loanId, } = dto; if (amount <= 0) { @@ -570,8 +572,8 @@ export class FinanceService { this.db.prepare(` INSERT INTO transactions ( id, user_id, account_id, type, amount, currency, date, - notes, status, transfer_group_id, destination_account_id - ) VALUES (?, ?, ?, 'transfer', ?, ?, ?, ?, ?, ?, ?) + notes, status, transfer_group_id, destination_account_id, loan_id + ) VALUES (?, ?, ?, 'transfer', ?, ?, ?, ?, ?, ?, ?, ?) `).run( outTxId, userId, @@ -582,15 +584,16 @@ export class FinanceService { notes || `Transfer to ${destAccount.name}`, status, transferGroupId, - destinationAccountId + destinationAccountId, + loanId || null ); // 2. Incoming transfer leg this.db.prepare(` INSERT INTO transactions ( id, user_id, account_id, type, amount, currency, date, - notes, status, transfer_group_id, transfer_peer_account_id - ) VALUES (?, ?, ?, 'transfer', ?, ?, ?, ?, ?, ?, ?) + notes, status, transfer_group_id, transfer_peer_account_id, loan_id + ) VALUES (?, ?, ?, 'transfer', ?, ?, ?, ?, ?, ?, ?, ?) `).run( inTxId, userId, @@ -601,7 +604,8 @@ export class FinanceService { notes || `Transfer from ${account.name}`, status, transferGroupId, - accountId + accountId, + loanId || null ); // Collect tags: user-provided tags + tags extracted from notes @@ -663,8 +667,8 @@ export class FinanceService { this.db.prepare(` INSERT INTO transactions ( id, user_id, account_id, type, amount, currency, date, - merchant_id, merchant_name, category_id, notes, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + merchant_id, merchant_name, category_id, notes, status, loan_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( txId, userId, @@ -677,7 +681,8 @@ export class FinanceService { merchantName || null, categoryId || null, notes || null, - status + status, + loanId || null ); // Save splits if provided @@ -1112,30 +1117,76 @@ export class FinanceService { const tx = this.getTransactionById(id, userId); if (!tx) throw new Error('Transaction not found'); + const loanIdsToRecalc = new Set(); + if (tx.loan_id) loanIdsToRecalc.add(tx.loan_id); + + try { + const linkedLoanPayments = this.db.prepare(` + SELECT DISTINCT loan_id + FROM loan_payments + WHERE user_id = ? AND (transaction_id = ? OR (transfer_group_id IS NOT NULL AND transfer_group_id = ?)) + `).all(userId, id, tx.transfer_group_id || '') as any[]; + for (const lp of linkedLoanPayments) { + if (lp.loan_id) loanIdsToRecalc.add(lp.loan_id); + } + } catch {} + if (tx.transfer_group_id) { // It's a transfer pair! Delete both legs if (permanent) { this.db.prepare('DELETE FROM transactions WHERE transfer_group_id = ?').run(tx.transfer_group_id); + try { + this.db.prepare('DELETE FROM loan_payments WHERE transfer_group_id = ?').run(tx.transfer_group_id); + } catch {} } else { this.db.prepare('UPDATE transactions SET is_deleted = 1 WHERE transfer_group_id = ?').run(tx.transfer_group_id); } if (tx.account_id) this.recalculateAccountBalance(tx.account_id); if (tx.destination_account_id) this.recalculateAccountBalance(tx.destination_account_id); if (tx.transfer_peer_account_id) this.recalculateAccountBalance(tx.transfer_peer_account_id); + + for (const loanId of loanIdsToRecalc) { + this.recalculateLoanBalance(loanId, userId); + } + return { success: true, undoId: tx.transfer_group_id, isTransfer: true }; } if (permanent) { this.db.prepare('DELETE FROM transactions WHERE id = ? AND user_id = ?').run(id, userId); + try { + this.db.prepare('DELETE FROM loan_payments WHERE transaction_id = ?').run(id); + } catch {} } else { this.db.prepare('UPDATE transactions SET is_deleted = 1 WHERE id = ? AND user_id = ?').run(id, userId); } this.recalculateAccountBalance(tx.account_id); + + for (const loanId of loanIdsToRecalc) { + this.recalculateLoanBalance(loanId, userId); + } + return { success: true, undoId: id, isTransfer: false }; } restoreTransaction(idOrGroupId: string, userId: string) { + const loanIdsToRecalc = new Set(); + try { + const linkedTxs = this.db.prepare(` + SELECT loan_id FROM transactions WHERE user_id = ? AND (id = ? OR transfer_group_id = ?) + `).all(userId, idOrGroupId, idOrGroupId) as any[]; + for (const t of linkedTxs) { + if (t.loan_id) loanIdsToRecalc.add(t.loan_id); + } + const linkedLps = this.db.prepare(` + SELECT loan_id FROM loan_payments WHERE user_id = ? AND (transaction_id = ? OR transfer_group_id = ?) + `).all(userId, idOrGroupId, idOrGroupId) as any[]; + for (const lp of linkedLps) { + if (lp.loan_id) loanIdsToRecalc.add(lp.loan_id); + } + } catch {} + this.db.prepare(` UPDATE transactions SET is_deleted = 0 WHERE user_id = ? AND (id = ? OR transfer_group_id = ?) @@ -1150,6 +1201,10 @@ export class FinanceService { this.recalculateAccountBalance(a.account_id); } + for (const loanId of loanIdsToRecalc) { + this.recalculateLoanBalance(loanId, userId); + } + return { restored: true }; } @@ -1854,6 +1909,29 @@ export class FinanceService { ORDER BY l.created_at DESC `).all(userId) as any[]; + // Get active loan payments counts + const paymentCountsMap = new Map(); + try { + const pCounts = this.db.prepare(` + SELECT lp.loan_id, COUNT(DISTINCT lp.id) as count + FROM loan_payments lp + LEFT JOIN transactions t_single ON (lp.transaction_id IS NOT NULL AND t_single.id = lp.transaction_id) + LEFT JOIN transactions t_group ON (lp.transfer_group_id IS NOT NULL AND t_group.transfer_group_id = lp.transfer_group_id) + WHERE lp.user_id = ? + AND ( + (lp.transaction_id IS NOT NULL AND (t_single.is_deleted = 0 OR t_single.is_deleted IS NULL)) + OR + (lp.transfer_group_id IS NOT NULL AND (t_group.is_deleted = 0 OR t_group.is_deleted IS NULL)) + OR + (lp.transaction_id IS NULL AND lp.transfer_group_id IS NULL) + ) + GROUP BY lp.loan_id + `).all(userId) as any[]; + for (const pc of pCounts) { + paymentCountsMap.set(pc.loan_id, pc.count); + } + } catch {} + return loans.map(loan => { const amortization = this.calculateLoanAmortization( loan.principal, @@ -1872,6 +1950,17 @@ export class FinanceService { ? Math.min(100, Math.round((totalPaid / totalPayable) * 100)) : 0; + const paidCount = paymentCountsMap.get(loan.id) ?? ( + loan.principal > 0 + ? Math.min(loan.tenure_months, Math.round((paidPrincipal / loan.principal) * loan.tenure_months)) + : 0 + ); + + const scheduleWithStatus = amortization.schedule.map((row: any) => ({ + ...row, + isPaid: row.monthNumber <= paidCount, + })); + return { ...loan, type: loan.type || 'loan', @@ -1882,7 +1971,11 @@ export class FinanceService { remainingBalance, paidPrincipal, progressPercent, - amortization, + paidCount, + amortization: { + ...amortization, + schedule: scheduleWithStatus, + }, }; }); } @@ -1906,9 +1999,9 @@ export class FinanceService { const runInsert = () => { this.db.prepare(` INSERT INTO loans ( - id, user_id, account_id, name, principal, outstanding_principal, + id, user_id, account_id, name, principal, outstanding_principal, initial_outstanding_principal, interest_rate, emi_amount, tenure_months, start_date, emi_day, type, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, data.userId, @@ -1916,6 +2009,7 @@ export class FinanceService { data.name, data.principal, data.outstandingPrincipal, + data.outstandingPrincipal || data.principal, data.interestRate, amort.monthlyEmi, data.tenureMonths, @@ -1932,6 +2026,7 @@ export class FinanceService { if (err?.message?.includes('no column named') || err?.message?.includes('table loans')) { try { this.db.exec(`ALTER TABLE loans ADD COLUMN principal INTEGER DEFAULT 0`); } catch {} try { this.db.exec(`ALTER TABLE loans ADD COLUMN outstanding_principal INTEGER DEFAULT 0`); } catch {} + try { this.db.exec(`ALTER TABLE loans ADD COLUMN initial_outstanding_principal INTEGER DEFAULT 0`); } catch {} try { this.db.exec(`ALTER TABLE loans ADD COLUMN tenure_months INTEGER DEFAULT 12`); } catch {} try { this.db.exec(`ALTER TABLE loans ADD COLUMN emi_day INTEGER DEFAULT 5`); } catch {} try { this.db.exec(`ALTER TABLE loans ADD COLUMN type TEXT DEFAULT 'loan'`); } catch {} @@ -1946,6 +2041,51 @@ export class FinanceService { return id; } + recalculateLoanBalance(loanId: string, userId: string) { + const loan = this.db.prepare('SELECT * FROM loans WHERE id = ? AND user_id = ?').get(loanId, userId) as any; + if (!loan) return; + + let totalPrincipalPaid = 0; + try { + const payments = this.db.prepare(` + SELECT lp.* + FROM loan_payments lp + LEFT JOIN transactions t_single ON (lp.transaction_id IS NOT NULL AND t_single.id = lp.transaction_id) + LEFT JOIN transactions t_group ON (lp.transfer_group_id IS NOT NULL AND t_group.transfer_group_id = lp.transfer_group_id) + WHERE lp.loan_id = ? + AND lp.user_id = ? + AND ( + (lp.transaction_id IS NOT NULL AND (t_single.is_deleted = 0 OR t_single.is_deleted IS NULL)) + OR + (lp.transfer_group_id IS NOT NULL AND (t_group.is_deleted = 0 OR t_group.is_deleted IS NULL)) + OR + (lp.transaction_id IS NULL AND lp.transfer_group_id IS NULL) + ) + GROUP BY lp.id + `).all(loanId, userId) as any[]; + + for (const p of payments) { + totalPrincipalPaid += (p.principal_paid || 0); + } + } catch { + // Fallback if loan_payments table doesn't exist yet + } + + const basePrincipal = (loan.initial_outstanding_principal && loan.initial_outstanding_principal > 0) + ? loan.initial_outstanding_principal + : loan.principal; + + const newOutstanding = Math.max(0, basePrincipal - totalPrincipalPaid); + + this.db.prepare(` + UPDATE loans + SET outstanding_principal = ? + WHERE id = ? AND user_id = ? + `).run(newOutstanding, loanId, userId); + + return newOutstanding; + } + recordLoanPayment(params: { userId: string; loanId: string; @@ -1965,39 +2105,85 @@ export class FinanceService { principalComponent = loan.outstanding_principal; } - // Find a relevant category for EMI payments - let emiCat = this.db.prepare("SELECT id FROM categories WHERE user_id = ? AND (name LIKE '%loan%' OR name LIKE '%emi%' OR name LIKE '%debt%') LIMIT 1").get(params.userId) as any; - if (!emiCat) { - emiCat = this.db.prepare("SELECT id FROM categories WHERE user_id = ? AND type = 'expense' LIMIT 1").get(params.userId) as any; - } - const isEmi = loan.type === 'emi'; const txName = isEmi ? `EMI: ${loan.name}` : `Loan EMI: ${loan.name}`; const paymentDate = params.date || new Date().toISOString().substring(0, 10); + const notes = `Installment payment for ${isEmi ? 'Purchase EMI' : 'Loan'} "${loan.name}" (Principal: ₹${Math.round(principalComponent / 100)}, Interest: ₹${Math.round(interestComponent / 100)})`; - // 1. Record expense in account transactions (reduces account balance and liquid cash) - const txId = this.createTransaction({ - userId: params.userId, - accountId: params.accountId, - type: 'expense', - amount: paymentAmount, - date: paymentDate, - merchantName: txName, - categoryId: emiCat?.id, - notes: `Installment payment for ${isEmi ? 'Purchase EMI' : 'Loan'} "${loan.name}" (Principal: ₹${Math.round(principalComponent / 100)}, Interest: ₹${Math.round(interestComponent / 100)})`, - }); + let txId = ''; + let transferGroupId: string | null = null; - // 2. Reduce outstanding principal on loan (reduces liabilities) - const newOutstanding = Math.max(0, loan.outstanding_principal - principalComponent); - this.db.prepare(` - UPDATE loans - SET outstanding_principal = ? - WHERE id = ? AND user_id = ? - `).run(newOutstanding, params.loanId, params.userId); + const linkedAccount = loan.account_id ? this.getAccountById(loan.account_id, params.userId) : null; + const isTransfer = Boolean(linkedAccount && linkedAccount.id !== params.accountId); + + if (isTransfer) { + const isCc = linkedAccount?.type === 'credit_card'; + const tags = ['loan-repayment', 'emi']; + if (isCc) tags.push('cc-repayment'); + + const transferResult = this.createTransaction({ + userId: params.userId, + accountId: params.accountId, // Source account (e.g. Bank) + destinationAccountId: loan.account_id, // Target account (e.g. Credit Card / Loan) + type: 'transfer', + amount: paymentAmount, + date: paymentDate, + notes, + tags, + loanId: params.loanId, + }); + + transferGroupId = transferResult.transferGroupId; + txId = transferResult.id; + } else { + // Find a relevant category for EMI payments + let emiCat = this.db.prepare("SELECT id FROM categories WHERE user_id = ? AND (name LIKE '%loan%' OR name LIKE '%emi%' OR name LIKE '%debt%') LIMIT 1").get(params.userId) as any; + if (!emiCat) { + emiCat = this.db.prepare("SELECT id FROM categories WHERE user_id = ? AND type = 'expense' LIMIT 1").get(params.userId) as any; + } + + txId = this.createTransaction({ + userId: params.userId, + accountId: params.accountId, + type: 'expense', + amount: paymentAmount, + date: paymentDate, + merchantName: txName, + categoryId: emiCat?.id, + notes, + tags: ['loan-repayment', 'emi'], + loanId: params.loanId, + }); + } + + // Record into loan_payments table + const paymentRecordId = `lp_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`; + try { + this.db.prepare(` + INSERT INTO loan_payments ( + id, loan_id, user_id, transaction_id, transfer_group_id, + amount, principal_paid, interest_paid, payment_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + paymentRecordId, + params.loanId, + params.userId, + txId, + transferGroupId, + paymentAmount, + principalComponent, + interestComponent, + paymentDate + ); + } catch {} + + // Recalculate loan balance + const newOutstanding = this.recalculateLoanBalance(params.loanId, params.userId) ?? Math.max(0, loan.outstanding_principal - principalComponent); return { success: true, transactionId: txId, + transferGroupId, principalPaid: principalComponent, interestPaid: interestComponent, newOutstandingPrincipal: newOutstanding, diff --git a/tests/finance-service.test.ts b/tests/finance-service.test.ts index d0995b2..2f70a12 100644 --- a/tests/finance-service.test.ts +++ b/tests/finance-service.test.ts @@ -398,8 +398,8 @@ describe('Finance Domain Business Logic Tests', () => { expect(updated?.current_balance).toBe(4950000); }); - it('records loan & EMI payment, decreasing liquid cash and loan liabilities synchronously', () => { - // 1. Create a bank account with ₹1,00,000 + it('records loan & EMI payment as a transfer from bank to credit card and updates balances', () => { + // 1. Create a bank account with ₹1,00,000 and a credit card account with ₹60,000 debt const bank = service.createAccount({ userId, name: 'Axis Bank Checking', @@ -407,10 +407,18 @@ describe('Finance Domain Business Logic Tests', () => { openingBalance: 10000000, // ₹1,00,000 })!; - // 2. Create a purchase EMI for MacBook (0% No-Cost EMI: ₹60,000 over 6 months -> ₹10,000/mo) + const card = service.createAccount({ + userId, + name: 'HDFC Regalia CC', + type: 'credit_card', + openingBalance: 6000000, // ₹60,000 debt + creditLimit: 20000000, // ₹2,00,000 + })!; + + // 2. Create a purchase EMI for MacBook on the credit card (0% No-Cost EMI: ₹60,000 over 6 months -> ₹10,000/mo) const emiId = service.createLoan({ userId, - accountId: bank.id, + accountId: card.id, name: 'MacBook Air M3', principal: 6000000, // ₹60,000 outstandingPrincipal: 6000000, @@ -420,12 +428,7 @@ describe('Finance Domain Business Logic Tests', () => { type: 'emi', }); - const metricsBefore = service.getDashboardMetrics(userId); - expect(metricsBefore.cashBalance).toBe(10000000); // ₹1,00,000 - expect(metricsBefore.totalLiabilities).toBe(6000000); // ₹60,000 - expect(metricsBefore.netWorth).toBe(10000000 - 6000000); // ₹40,000 - - // 3. Record EMI installment payment of ₹10,000 + // 3. Record EMI installment payment of ₹10,000 from bank to CC const paymentResult = service.recordLoanPayment({ userId, loanId: emiId, @@ -435,17 +438,58 @@ describe('Finance Domain Business Logic Tests', () => { }); expect(paymentResult.success).toBe(true); + expect(paymentResult.transferGroupId).toBeDefined(); expect(paymentResult.newOutstandingPrincipal).toBe(5000000); // ₹50,000 remaining - // 4. Verify that bank balance (liquid cash) decreased by ₹10,000 + // 4. Verify bank balance decreased by ₹10,000 const bankAfter = service.getAccountById(bank.id, userId); expect(bankAfter?.current_balance).toBe(9000000); // ₹90,000 - // 5. Verify that dashboard metrics reflect updated liquid cash and reduced liabilities - const metricsAfter = service.getDashboardMetrics(userId); - expect(metricsAfter.cashBalance).toBe(9000000); // ₹90,000 liquid cash - expect(metricsAfter.totalLiabilities).toBe(5000000); // ₹50,000 remaining liabilities - expect(metricsAfter.netWorth).toBe(9000000 - 5000000); // ₹40,000 (net worth preserved!) + // 5. Verify credit card debt decreased by ₹10,000 (from ₹60,000 to ₹50,000) + const cardAfter = service.getAccountById(card.id, userId); + expect(cardAfter?.current_balance).toBe(5000000); // ₹50,000 + + // 6. Verify transfer transaction tags include cc-repayment and loan-repayment + const tx = service.getTransactionById(paymentResult.transactionId, userId); + expect(tx?.type).toBe('transfer'); + expect(tx?.tags).toContain('cc-repayment'); + expect(tx?.tags).toContain('loan-repayment'); + expect(tx?.tags).toContain('emi'); + + // 7. Verify loans list returns updated outstanding and paid status + const loans = service.getLoans(userId); + const emiLoan = loans.find(l => l.id === emiId); + expect(emiLoan?.outstanding_principal).toBe(5000000); + expect(emiLoan?.paidPrincipal).toBe(1000000); + expect(emiLoan?.amortization.schedule[0].isPaid).toBe(true); + expect(emiLoan?.amortization.schedule[1].isPaid).toBe(false); + + // 8. Delete the repayment transaction and verify loan balance & accounts are restored + service.deleteTransaction(paymentResult.transactionId, userId); + + const bankRestored = service.getAccountById(bank.id, userId); + expect(bankRestored?.current_balance).toBe(10000000); // restored to ₹1,00,000 + + const cardRestored = service.getAccountById(card.id, userId); + expect(cardRestored?.current_balance).toBe(6000000); // restored to ₹60,000 debt + + const loansAfterDelete = service.getLoans(userId); + const emiLoanAfterDelete = loansAfterDelete.find(l => l.id === emiId); + expect(emiLoanAfterDelete?.outstanding_principal).toBe(6000000); // restored to ₹60,000! + expect(emiLoanAfterDelete?.paidPrincipal).toBe(0); + expect(emiLoanAfterDelete?.amortization.schedule[0].isPaid).toBe(false); + + // 9. Restore the deleted transaction and verify loan balance & accounts re-apply + service.restoreTransaction(paymentResult.transferGroupId!, userId); + + const bankReapplied = service.getAccountById(bank.id, userId); + expect(bankReapplied?.current_balance).toBe(9000000); + + const loansAfterRestore = service.getLoans(userId); + const emiLoanAfterRestore = loansAfterRestore.find(l => l.id === emiId); + expect(emiLoanAfterRestore?.outstanding_principal).toBe(5000000); + expect(emiLoanAfterRestore?.paidPrincipal).toBe(1000000); + expect(emiLoanAfterRestore?.amortization.schedule[0].isPaid).toBe(true); }); it('updates an existing expense transaction and recalculates account balances accurately', () => { From 71f58d55593fb74c9204e3b02514185d26f35d0e Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Fri, 2 Oct 2026 02:38:40 +0530 Subject: [PATCH 3/4] fix(reports): resolve unhandled client runtime crashes, sql group by, and null guards --- src/app/reports/page.tsx | 197 ++++++++++++++++++++------------- src/lib/finance-service.ts | 8 +- tests/reports-render.test.ts | 207 +++++++++++++++++++++++++++++++++++ vitest.config.mts | 13 +++ 4 files changed, 346 insertions(+), 79 deletions(-) create mode 100644 tests/reports-render.test.ts create mode 100644 vitest.config.mts diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index a2b0708..5783461 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -31,8 +31,10 @@ import { } from 'lucide-react'; /* ──── SVG Speedometer Gauge ──── */ -function SpeedometerGauge({ value, max, label, sublabel }: { value: number; max: number; label: string; sublabel?: string }) { - const clampedPercent = Math.min(100, Math.max(0, max > 0 ? (value / max) * 100 : 0)); +export function SpeedometerGauge({ value, max, label, sublabel }: { value: number; max: number; label: string; sublabel?: string }) { + const safeVal = Number.isFinite(value) ? value : 0; + const safeMax = Number.isFinite(max) && max > 0 ? max : 100; + const clampedPercent = Math.min(100, Math.max(0, (safeVal / safeMax) * 100)); // Arc from 135° to 405° (270° sweep) const startAngle = 135; const sweepAngle = 270; @@ -121,14 +123,16 @@ function SpeedometerGauge({ value, max, label, sublabel }: { value: number; max: } /* ──── Tooltip Bar for Daily Spending ──── */ -function DailyBar({ day, date, spent, cumulative, maxSpend, avgSpend }: { +export function DailyBar({ day, date, spent, cumulative, maxSpend, avgSpend }: { day: number; date: string; spent: number; cumulative: number; maxSpend: number; avgSpend: number; }) { const [showTooltip, setShowTooltip] = useState(false); const barRef = useRef(null); - const barHeight = maxSpend > 0 ? (spent / maxSpend) * 110 : 0; - const hasSpend = spent > 0; - const isSpike = spent > avgSpend * 1.5; + const safeMax = Number.isFinite(maxSpend) && maxSpend > 0 ? maxSpend : 100; + const safeSpent = Number.isFinite(spent) ? spent : 0; + const barHeight = safeMax > 0 ? (safeSpent / safeMax) * 110 : 0; + const hasSpend = safeSpent > 0; + const isSpike = safeSpent > avgSpend * 1.5; return (
Day {day} · {formatDateDMY(date)}
-
Spent: ₹{(spent / 100).toLocaleString('en-IN')}
-
Cumulative: ₹{(cumulative / 100).toLocaleString('en-IN')}
+
Spent: ₹{(safeSpent / 100).toLocaleString('en-IN')}
+
Cumulative: ₹{((cumulative || 0) / 100).toLocaleString('en-IN')}
)}
{ - return items.map((it, idx) => ({ - ...it, - color: it.color || DONUT_PALETTE[idx % DONUT_PALETTE.length], - })); + return (items || []) + .filter(it => (it?.total || 0) > 0) + .map((it, idx) => ({ + ...it, + id: it.id || `slice_${idx}`, + name: it.name || 'Uncategorized', + total: it.total || 0, + percentage: Number.isFinite(it.percentage) ? it.percentage : 0, + color: it.color || DONUT_PALETTE[idx % DONUT_PALETTE.length], + })); }, [items]); const totalSum = useMemo(() => { @@ -273,9 +283,9 @@ function ExpenseDonutChart({ `; } - const gap = slices.length > 1 ? Math.min(1.5, sweep * 0.1) : 0; + const gap = slices.length > 1 ? Math.min(1.5, Math.max(0, sweep * 0.1)) : 0; const actualStart = startAngle + gap / 2; - const actualEnd = endAngle - gap / 2; + const actualEnd = Math.max(actualStart + 0.05, endAngle - gap / 2); const x1 = cx + rOut * Math.cos(toRad(actualStart)); const y1 = cy + rOut * Math.sin(toRad(actualStart)); @@ -309,6 +319,8 @@ function ExpenseDonutChart({ ); } + const activeSliceName = activeSlice?.name || 'Uncategorized'; + return (
- {activeSlice ? (activeSlice.name.length > 14 ? activeSlice.name.substring(0, 12) + '…' : activeSlice.name) : (analyticsLabel === 'Tag' ? 'TOTAL TAGGED' : 'TOTAL SPENT')} + {activeSlice + ? (activeSliceName.length > 14 ? activeSliceName.substring(0, 12) + '…' : activeSliceName) + : (analyticsLabel === 'Tag' ? 'TOTAL TAGGED' : 'TOTAL SPENT')} ('split'); const [hoveredAnalyticsId, setHoveredAnalyticsId] = useState(null); - const [data, setData] = useState(null); - const [forecast, setForecast] = useState(null); - const [loading, setLoading] = useState(true); + const [data, setData] = useState(initialData || null); + const [forecast, setForecast] = useState(initialForecast || null); + const [loading, setLoading] = useState(!initialData); // Load analytics when selectedMonth or compareMonth changes useEffect(() => { + if (initialData && !compareEnabled && selectedMonth === currentMonthStr) { + return; + } setLoading(true); const params = new URLSearchParams(); params.set('month', selectedMonth); @@ -469,14 +492,34 @@ export default function ReportsPage() { ); } - const spendingByCategory = data?.spendingByCategory || []; - const spendingByTag = data?.spendingByTag || []; - const spendingByMerchant = data?.spendingByMerchant || []; - const periods = forecast?.periods || []; - const dailySpending = data?.dailySpending?.days || (Array.isArray(data?.dailySpending) ? data?.dailySpending : []); - const budgetHealth = data?.budgetHealth; - const comparison = data?.comparison; - const metrics = data?.metrics || {}; + if (data?.error) { + return ( +
+

Failed to load reports data

+

{data.error}

+ +
+ ); + } + + const spendingByCategory = Array.isArray(data?.spendingByCategory) ? data.spendingByCategory : []; + const spendingByTag = Array.isArray(data?.spendingByTag) ? data.spendingByTag : []; + const spendingByMerchant = Array.isArray(data?.spendingByMerchant) ? data.spendingByMerchant : []; + const periods = Array.isArray(forecast?.periods) ? forecast.periods : []; + const dailySpending = Array.isArray(data?.dailySpending?.days) + ? data.dailySpending.days + : Array.isArray(data?.dailySpending) + ? data.dailySpending + : []; + const budgetHealth = data?.budgetHealth && typeof data.budgetHealth === 'object' ? data.budgetHealth : null; + const comparison = data?.comparison && typeof data.comparison === 'object' ? data.comparison : null; + const metrics = (data?.metrics && typeof data.metrics === 'object') ? data.metrics : {}; // Fix: use correct field names from getDashboardMetrics const totalMonthSpend = metrics.monthlyExpenses || 0; @@ -487,7 +530,7 @@ export default function ReportsPage() { const isCurrentMonth = selYear === today.getFullYear() && selMon === (today.getMonth() + 1); const elapsedDays = isCurrentMonth ? today.getDate() : daysInMonth; const avgDailySpend = elapsedDays > 0 ? Math.round(totalMonthSpend / elapsedDays) : 0; - const maxDailySpend = Math.max(...dailySpending.map((d: any) => d.dailySpent ?? d.amount ?? 0), 100); + const maxDailySpend = dailySpending.length > 0 ? Math.max(...dailySpending.map((d: any) => d.dailySpent ?? d.amount ?? 0), 100) : 100; // Savings rate for speedometer const savingsRate = totalMonthIncome > 0 ? Math.round(((totalMonthIncome - totalMonthSpend) / totalMonthIncome) * 100) : 0; @@ -636,7 +679,7 @@ export default function ReportsPage() {

- Month Comparison: {formatMonthDisplay(comparison.monthA)} vs {formatMonthDisplay(comparison.monthB)} + Month Comparison: {formatMonthDisplay(comparison.monthA || selectedMonth)} vs {formatMonthDisplay(comparison.monthB || compareMonth)}

Detailed delta in income, expenses, and category variance. @@ -651,15 +694,15 @@ export default function ReportsPage() {
INCOME DELTA
- = 0 ? 'var(--color-income)' : 'var(--color-expense)' }}> - {comparison.diff.income >= 0 ? '+' : ''}₹{Math.round(comparison.diff.income / 100).toLocaleString('en-IN')} + = 0 ? 'var(--color-income)' : 'var(--color-expense)' }}> + {(comparison.diff?.income ?? 0) >= 0 ? '+' : ''}₹{Math.round((comparison.diff?.income ?? 0) / 100).toLocaleString('en-IN')} - = 0 ? 'var(--color-income)' : 'var(--color-expense)' }}> - ({comparison.diff.incomePercent}%) + = 0 ? 'var(--color-income)' : 'var(--color-expense)' }}> + ({comparison.diff?.incomePercent ?? 0}%)
- ₹{(comparison.metricsA.totalIncome / 100).toLocaleString('en-IN')} vs ₹{(comparison.metricsB.totalIncome / 100).toLocaleString('en-IN')} + ₹{(((comparison.metricsA?.totalIncome ?? comparison.metricsA?.monthlyIncome) || 0) / 100).toLocaleString('en-IN')} vs ₹{(((comparison.metricsB?.totalIncome ?? comparison.metricsB?.monthlyIncome) || 0) / 100).toLocaleString('en-IN')}
@@ -667,15 +710,15 @@ export default function ReportsPage() {
EXPENSE DELTA
- - {comparison.diff.expense >= 0 ? '+' : ''}₹{Math.round(comparison.diff.expense / 100).toLocaleString('en-IN')} + + {(comparison.diff?.expense ?? 0) >= 0 ? '+' : ''}₹{Math.round((comparison.diff?.expense ?? 0) / 100).toLocaleString('en-IN')} - - ({comparison.diff.expensePercent}%) + + ({comparison.diff?.expensePercent ?? 0}%)
- ₹{(comparison.metricsA.totalExpense / 100).toLocaleString('en-IN')} vs ₹{(comparison.metricsB.totalExpense / 100).toLocaleString('en-IN')} + ₹{(((comparison.metricsA?.totalExpense ?? comparison.metricsA?.monthlyExpenses) || 0) / 100).toLocaleString('en-IN')} vs ₹{(((comparison.metricsB?.totalExpense ?? comparison.metricsB?.monthlyExpenses) || 0) / 100).toLocaleString('en-IN')}
@@ -683,28 +726,28 @@ export default function ReportsPage() {
NET SAVINGS DELTA
- = 0 ? 'var(--color-income)' : 'var(--color-expense)' }}> - {comparison.diff.netSavings >= 0 ? '+' : ''}₹{Math.round(comparison.diff.netSavings / 100).toLocaleString('en-IN')} + = 0 ? 'var(--color-income)' : 'var(--color-expense)' }}> + {(comparison.diff?.netSavings ?? 0) >= 0 ? '+' : ''}₹{Math.round((comparison.diff?.netSavings ?? 0) / 100).toLocaleString('en-IN')}
- Savings Rate: {comparison.metricsA.savingsRate}% vs {comparison.metricsB.savingsRate}% + Savings Rate: {comparison.metricsA?.savingsRate ?? 0}% vs {comparison.metricsB?.savingsRate ?? 0}%
{/* Category Variance Table */} - {comparison.categoryComparison?.length > 0 && ( + {Array.isArray(comparison.categoryComparison) && comparison.categoryComparison.length > 0 && (
Category-by-Category Shift
{comparison.categoryComparison.slice(0, 6).map((c: any) => { - const increased = c.diff > 0; + const increased = (c.diff || 0) > 0; return (
- {c.name} + {c.name || 'Category'}
- ₹{Math.round(c.amountA / 100).toLocaleString('en-IN')} vs ₹{Math.round(c.amountB / 100).toLocaleString('en-IN')} + ₹{Math.round((c.amountA || 0) / 100).toLocaleString('en-IN')} vs ₹{Math.round((c.amountB || 0) / 100).toLocaleString('en-IN')} - {increased ? '↑ +' : '↓ '}₹{Math.round(Math.abs(c.diff) / 100).toLocaleString('en-IN')} + {increased ? '↑ +' : '↓ '}₹{Math.round(Math.abs(c.diff || 0) / 100).toLocaleString('en-IN')}
@@ -762,10 +805,10 @@ export default function ReportsPage() {
{budgetHealth ? ( ) : (
@@ -835,7 +878,7 @@ export default function ReportsPage() { spent={d.dailySpent ?? d.amount ?? 0} cumulative={d.cumulativeSpent ?? d.cumulative ?? 0} maxSpend={maxDailySpend} - avgSpend={avgDailySpend * 100} + avgSpend={avgDailySpend} /> ))}
@@ -861,21 +904,21 @@ export default function ReportsPage() { Budget Health ({formatMonthDisplay(selectedMonth)})
- {budgetHealth.totalCategories} budget categories tracked · {budgetHealth.overallPercent}% overall utilization + {budgetHealth.totalCategories ?? 0} budget categories tracked · {budgetHealth.overallPercent ?? 0}% overall utilization
- {budgetHealth.overBudgetCount > 0 && ( + {(budgetHealth.overBudgetCount || 0) > 0 && ( {budgetHealth.overBudgetCount} Over Budget )} - {budgetHealth.nearLimitCount > 0 && ( + {(budgetHealth.nearLimitCount || 0) > 0 && ( {budgetHealth.nearLimitCount} Near Limit )} - {budgetHealth.healthyCount > 0 && ( + {(budgetHealth.healthyCount || 0) > 0 && ( {budgetHealth.healthyCount} Healthy @@ -884,10 +927,10 @@ export default function ReportsPage() {
{/* Over budget alerts */} - {budgetHealth.overBudgetItems.length > 0 && ( + {(budgetHealth.overBudgetItems || []).length > 0 && (
OVER BUDGET
- {budgetHealth.overBudgetItems.map((b: any) => ( + {(budgetHealth.overBudgetItems || []).map((b: any) => (
- {b.category_name} + {b.category_name || 'Budget Category'}
- ₹{Math.round(b.spent / 100).toLocaleString('en-IN')} / ₹{Math.round(b.amount / 100).toLocaleString('en-IN')} + ₹{Math.round((b.spent || 0) / 100).toLocaleString('en-IN')} / ₹{Math.round((b.amount || 0) / 100).toLocaleString('en-IN')} - {b.percentUsed}% + {b.percentUsed ?? 0}%
))} @@ -914,10 +957,10 @@ export default function ReportsPage() { )} {/* Near limit warnings */} - {budgetHealth.nearLimitItems.length > 0 && ( + {(budgetHealth.nearLimitItems || []).length > 0 && (
APPROACHING LIMIT (≥80%)
- {budgetHealth.nearLimitItems.map((b: any) => ( + {(budgetHealth.nearLimitItems || []).map((b: any) => (
- {b.category_name} + {b.category_name || 'Budget Category'}
- ₹{Math.round(b.spent / 100).toLocaleString('en-IN')} / ₹{Math.round(b.amount / 100).toLocaleString('en-IN')} + ₹{Math.round((b.spent || 0) / 100).toLocaleString('en-IN')} / ₹{Math.round((b.amount || 0) / 100).toLocaleString('en-IN')} - {b.percentUsed}% + {b.percentUsed ?? 0}%
))} @@ -1242,7 +1285,7 @@ export default function ReportsPage() {
{spendingByMerchant.map((m: any, idx: number) => (
#{idx + 1} - {m.merchant_name} - ({m.count} txns) + {m.merchant_name || 'Unknown Payee'} + {m.count != null && ( + ({m.count} txns) + )}
- ₹{Math.round(m.total / 100).toLocaleString('en-IN')} + ₹{Math.round((m.total || 0) / 100).toLocaleString('en-IN')}
))} @@ -1281,9 +1326,9 @@ export default function ReportsPage() {
- {periods.map((p: any) => ( + {periods.map((p: any, idx: number) => (
{p.days}-DAY PROJECTION
- +
- In: +₹{Math.round(p.expectedIncome / 100).toLocaleString('en-IN')} - Out: -₹{Math.round(p.expectedExpenses / 100).toLocaleString('en-IN')} + In: +₹{Math.round((p.expectedIncome || 0) / 100).toLocaleString('en-IN')} + Out: -₹{Math.round((p.expectedExpenses || 0) / 100).toLocaleString('en-IN')}
))} diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index 07abe95..1777bf0 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -2491,7 +2491,7 @@ export class FinanceService { AND t.type = 'expense' AND t.is_deleted = 0 AND t.date LIKE ? - GROUP BY COALESCE(c.id, 'uncategorized') + GROUP BY COALESCE(c.id, 'uncategorized'), c.name, c.icon, c.color ORDER BY total DESC `).all(userId, `${month}%`) as any[]; @@ -2514,7 +2514,7 @@ export class FinanceService { AND t.type = 'expense' AND t.is_deleted = 0 AND t.date LIKE ? - GROUP BY tg.id + GROUP BY tg.id, tg.name, tg.color ORDER BY total DESC `).all(userId, `${month}%`) as any[]; @@ -2621,7 +2621,9 @@ export class FinanceService { const dayMap: Record = {}; for (const r of rows) { - dayMap[r.day] = r.total; + if (r.day && r.day >= 1 && r.day <= daysInMonth) { + dayMap[r.day] = r.total; + } } let runningCumulative = 0; diff --git a/tests/reports-render.test.ts b/tests/reports-render.test.ts new file mode 100644 index 0000000..b6a1ac0 --- /dev/null +++ b/tests/reports-render.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; + +// Mock useApp +vi.mock('../src/context/AppContext', () => ({ + useApp: () => ({ refreshKey: 0 }) +})); + +import ReportsPageRaw, { SpeedometerGauge, DailyBar, ExpenseDonutChart } from '../src/app/reports/page'; + +const ReportsPage = ReportsPageRaw as React.ComponentType<{ initialData?: any; initialForecast?: any }>; + +describe('Reports Page Comprehensive Render Tests', () => { + it('renders initial loading state when no initialData is provided', () => { + const html = renderToString(React.createElement(ReportsPage)); + expect(html).toContain('Loading comprehensive analytics and reports'); + }); + + it('renders loaded dashboard with real user data from staging', () => { + const mockData = { + selectedMonth: '2026-10', + spendingByCategory: [], + spendingByTag: [], + spendingByMerchant: [ + { merchant_name: 'Shree Krishna Properties', count: 6, total: 16800000 } + ], + cashFlowTrend: [ + { month: '2026-09', monthLabel: 'Oct 26', income: 385000, expense: 6703800, net: -6318800 } + ], + metrics: { + month: '2026-10', + netWorth: 133246800, + totalAssets: 208352000, + totalLiabilities: 75105200, + cashBalance: 140352000, + investmentBalance: 68000000, + creditCardOutstanding: 12105200, + monthlyIncome: 0, + monthlyExpenses: 0, + monthlySavings: 0, + savingsRate: 0 + }, + dailySpending: { + month: '2026-10', + daysInMonth: 31, + totalSpent: 0, + days: [ + { day: 1, date: '2026-10-01', dailySpent: 0, cumulativeSpent: 0 } + ] + }, + budgetHealth: { + totalBudgeted: 8200000, + totalSpent: 0, + totalRemaining: 8200000, + overallPercent: 0, + totalCategories: 6, + overBudgetCount: 0, + nearLimitCount: 0, + healthyCount: 6, + overBudgetItems: [], + nearLimitItems: [] + }, + comparison: null + }; + + const mockForecast = { + currentLiquidBalance: 140352000, + periods: [ + { days: 30, currentLiquidBalance: 140352000, expectedIncome: 13500000, expectedExpenses: 3249892, projectedBalance: 150602108 } + ] + }; + + const html = renderToString(React.createElement(ReportsPage, { initialData: mockData, initialForecast: mockForecast })); + expect(html).toContain('Analytics & Reports'); + expect(html).toContain('Shree Krishna Properties'); + expect(html).toContain('BUDGET UTILIZATION'); + }); + + it('renders error state safely when api returns error', () => { + const errorData = { error: 'Unauthorized: Authentication required' }; + const html = renderToString(React.createElement(ReportsPage, { initialData: errorData })); + expect(html).toContain('Failed to load reports data'); + expect(html).toContain('Unauthorized: Authentication required'); + }); + + it('renders safely when budgetHealth items are missing or null (Bug #1 reproduction)', () => { + const mockData = { + selectedMonth: '2026-10', + spendingByCategory: [{ id: 'c1', name: 'Food', total: 5000, percentage: 100 }], + spendingByTag: [{ id: 't1', name: 'dining', total: 5000, percentage: 100 }], + spendingByMerchant: [{ merchant_name: 'Bistro', count: 1, total: 5000 }], + cashFlowTrend: [], + metrics: { monthlyExpenses: 5000, monthlyIncome: 10000 }, + dailySpending: { days: [{ day: 1, date: '2026-10-01', dailySpent: 5000, cumulativeSpent: 5000 }] }, + budgetHealth: { + totalBudgeted: 10000, + totalSpent: 5000, + overallPercent: 50, + totalCategories: 1, + overBudgetCount: 0, + nearLimitCount: 0, + healthyCount: 1, + // overBudgetItems and nearLimitItems omitted intentionally + }, + comparison: null + }; + + const html = renderToString(React.createElement(ReportsPage, { initialData: mockData })); + expect(html).toContain('Budget Health'); + expect(html).toContain('Food'); + }); + + it('renders safely when category has null name and items have 0 total (Bug #2 reproduction)', () => { + const mockData = { + selectedMonth: '2026-10', + spendingByCategory: [ + { id: 'c1', name: null, total: 0, percentage: 0 }, + { id: 'c2', name: 'Groceries', total: 5000, percentage: 100 } + ], + spendingByTag: [ + { id: 't1', name: null, total: 0, percentage: 0 } + ], + spendingByMerchant: [ + { merchant_name: null, count: 1, total: 5000 } + ], + metrics: { monthlyExpenses: 5000, monthlyIncome: 10000 }, + dailySpending: { days: [] }, + budgetHealth: null, + comparison: null + }; + + const html = renderToString(React.createElement(ReportsPage, { initialData: mockData })); + expect(html).toContain('Groceries'); + expect(html).toContain('Unknown Payee'); + }); + + it('renders safely when comparison has diff but missing metricsA/B (Bug #3 reproduction)', () => { + const mockData = { + selectedMonth: '2026-10', + spendingByCategory: [], + spendingByTag: [], + spendingByMerchant: [], + metrics: { monthlyExpenses: 0, monthlyIncome: 0 }, + dailySpending: { days: [] }, + budgetHealth: null, + comparison: { + monthA: '2026-10', + monthB: '2026-09', + diff: { income: 5000, incomePercent: 10, expense: -2000, expensePercent: -5, netSavings: 7000 }, + metricsA: null, // intentionally null + metricsB: null, // intentionally null + categoryComparison: [{ id: 'c1', name: null, amountA: 1000, amountB: 2000, diff: -1000 }] + } + }; + + // Render with compareEnabled logic simulated + const html = renderToString(React.createElement(ReportsPage, { initialData: mockData })); + expect(html).toBeDefined(); + }); + + it('renders SpeedometerGauge safely with edge cases', () => { + // NaN / Infinity / undefined values + const html1 = renderToString(React.createElement(SpeedometerGauge, { value: NaN, max: 0, label: 'Test NaN' })); + expect(html1).toContain('Test NaN'); + expect(html1).toContain('0%'); + + const html2 = renderToString(React.createElement(SpeedometerGauge, { value: 75, max: 100, label: 'Healthy', sublabel: 'Doing great' })); + expect(html2).toContain('75%'); + expect(html2).toContain('Doing great'); + }); + + it('renders ExpenseDonutChart safely with empty, null, or hovered slice', () => { + const emptyHtml = renderToString(React.createElement(ExpenseDonutChart, { + items: [], + totalSpend: 0, + analyticsLabel: 'Category', + hoveredId: null, + onHover: vi.fn(), + })); + expect(emptyHtml).toContain('No expenses'); + + const chartHtml = renderToString(React.createElement(ExpenseDonutChart, { + items: [ + { id: '1', name: 'Rent', total: 5000000, percentage: 80, color: '#4F46E5' }, + { id: '2', name: 'Food', total: 1000000, percentage: 20, color: '#10B981' }, + ], + totalSpend: 6000000, + analyticsLabel: 'Category', + hoveredId: '1', + onHover: vi.fn(), + })); + expect(chartHtml).toContain('Rent'); + }); + + it('renders DailyBar safely with 0 and spike spends', () => { + const html = renderToString(React.createElement(DailyBar, { + day: 1, + date: '2026-10-01', + spent: 1000000, + cumulative: 2000000, + maxSpend: 1000000, + avgSpend: 200000, + })); + expect(html).toContain('linear-gradient'); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..c1dd3e1 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + fileParallelism: false, + }, + resolve: { + alias: { + '@': path.resolve(import.meta.dirname, './src'), + }, + }, +}); From 63032f911a6543c41ba5939942d1cf8e90c7a6d1 Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Fri, 2 Oct 2026 22:14:22 +0530 Subject: [PATCH 4/4] fix: resolve libsql wal conflict auto-healing and reports hook order --- src/app/api/auth/otp/verify/route.ts | 8 +- src/app/reports/page.tsx | 2 +- src/lib/db.ts | 431 ++++++++++++++++----------- 3 files changed, 267 insertions(+), 174 deletions(-) diff --git a/src/app/api/auth/otp/verify/route.ts b/src/app/api/auth/otp/verify/route.ts index 2c3b337..665bf72 100644 --- a/src/app/api/auth/otp/verify/route.ts +++ b/src/app/api/auth/otp/verify/route.ts @@ -34,12 +34,14 @@ export async function POST(req: Request) { user: result.user, }); - const isProd = process.env.NODE_ENV === 'production'; + const host = req.headers.get('host') || ''; + const isLocal = host.includes('localhost') || host.includes('127.0.0.1'); + const isSecure = process.env.NODE_ENV === 'production' && !isLocal; // Set secure HTTP-only session cookie for 30 days response.cookies.set('apex_session_token', result.token, { httpOnly: true, - secure: isProd, + secure: isSecure, sameSite: 'lax', path: '/', maxAge: 30 * 24 * 60 * 60, // 30 days @@ -48,7 +50,7 @@ export async function POST(req: Request) { // Also set finance_user_id for profile routing response.cookies.set('finance_user_id', result.user.id, { httpOnly: true, - secure: isProd, + secure: isSecure, sameSite: 'lax', path: '/', maxAge: 30 * 24 * 60 * 60, diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index 5783461..38595e1 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -536,7 +536,7 @@ export default function ReportsPage({ const savingsRate = totalMonthIncome > 0 ? Math.round(((totalMonthIncome - totalMonthSpend) / totalMonthIncome) * 100) : 0; // Active analytics data based on view mode - const totalTaggedSpend = useMemo(() => spendingByTag.reduce((acc: number, t: any) => acc + (t.total || 0), 0), [spendingByTag]); + const totalTaggedSpend = spendingByTag.reduce((acc: number, t: any) => acc + (t.total || 0), 0); const activeAnalytics = analyticsView === 'category' ? spendingByCategory : spendingByTag; const activeAnalyticsTotal = analyticsView === 'category' ? totalMonthSpend : totalTaggedSpend; const analyticsLabel = analyticsView === 'category' ? 'Category' : 'Tag'; diff --git a/src/lib/db.ts b/src/lib/db.ts index f84f04b..d12466b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -3,9 +3,48 @@ import path from 'node:path'; import fs from 'node:fs'; let globalDb: any = null; +let globalDbUrl: string | null = null; let syncTimer: any = null; let lastSyncTime = 0; +let isHealing = false; +function handleWalConflict() { + if (isHealing) return; + isHealing = true; + try { + console.warn('[TURSO] Healing local replica state from WalConflict...'); + if (globalDb) { + try { + if (typeof globalDb.close === 'function') { + globalDb.close(); + } + } catch { } + globalDb = null; + globalDbUrl = null; + } + if (syncTimer) { + clearInterval(syncTimer); + syncTimer = null; + } + + const isVercel = Boolean(process.env.VERCEL); + const dataDir = isVercel ? '/tmp' : path.join(process.cwd(), 'data'); + const resolvedPath = path.join(dataDir, 'finance.db'); + + for (const ext of ['', '-info', '-wal', '-shm']) { + try { fs.unlinkSync(resolvedPath + ext); } catch { } + } + + // Re-initialize clean replica + getDb(); + console.log('[TURSO] Self-healing completed successfully.'); + } catch (err) { + console.error('[TURSO] Self-healing failed:', err); + } finally { + isHealing = false; + } +} + export function syncTurso(force = false) { if (globalDb && typeof globalDb.sync === 'function') { const now = Date.now(); @@ -14,8 +53,12 @@ export function syncTurso(force = false) { try { globalDb.sync(); lastSyncTime = Date.now(); - } catch (e) { + } catch (e: any) { console.warn('[TURSO SYNC ERROR]', e); + const msg = e?.message || ''; + if (msg.includes('WalConflict') || msg.includes('InvalidLocalState')) { + handleWalConflict(); + } } } } @@ -31,9 +74,21 @@ function attachAutoSync(db: any) { if (isWrite && typeof stmt.run === 'function') { const originalRun = stmt.run.bind(stmt); stmt.run = function (...args: any[]) { - const result = originalRun(...args); - syncTurso(true); // Push changes to Turso cloud immediately on write! - return result; + try { + const result = originalRun(...args); + syncTurso(true); // Push changes to Turso cloud immediately on write! + return result; + } catch (runErr: any) { + const msg = runErr?.message || ''; + if (msg.includes('WalConflict') || msg.includes('InvalidLocalState')) { + console.warn('[TURSO] WalConflict detected on write, self-healing replica and retrying...'); + handleWalConflict(); + if (globalDb) { + return globalDb.prepare(sql).run(...args); + } + } + throw runErr; + } }; } return stmt; @@ -53,12 +108,38 @@ function attachAutoSync(db: any) { } } +const tursoUrl = process.env.TURSO_DATABASE_URL || null; +const tursoToken = process.env.TURSO_AUTH_TOKEN || null; + export function getDb(dbPath?: string): any { - if (globalDb && !dbPath) { - syncTurso(false); // Quick pull (80ms) if last sync was > 1.5s ago + // Reuse only if the cached connection belongs to the + // currently configured database. + if ( + globalDb && + !dbPath && + globalDbUrl === tursoUrl + ) { + syncTurso(false); return globalDb; } + // Environment/database changed, so discard old connection. + if (globalDb && !dbPath && globalDbUrl !== tursoUrl) { + try { + if (typeof globalDb.close === 'function') { + globalDb.close(); + } + } catch { } + + globalDb = null; + globalDbUrl = null; + + if (syncTimer) { + clearInterval(syncTimer); + syncTimer = null; + } + } + let resolvedPath = dbPath; if (!resolvedPath) { const isVercel = Boolean(process.env.VERCEL); @@ -73,36 +154,45 @@ export function getDb(dbPath?: string): any { resolvedPath = path.join(dataDir, 'finance.db'); } - const tursoUrl = process.env.TURSO_DATABASE_URL; - const tursoToken = process.env.TURSO_AUTH_TOKEN; - let db: any; if (tursoUrl && tursoToken && !dbPath) { try { // Use native Libsql with cloud sync to Turso // eslint-disable-next-line @typescript-eslint/no-require-imports const LibsqlDatabase = require('libsql'); + + let instance: any = null; + const createAndSyncDb = () => { + instance = new LibsqlDatabase(resolvedPath, { syncUrl: tursoUrl, authToken: tursoToken }); + instance.sync(); + return instance; + }; + try { - db = new LibsqlDatabase(resolvedPath, { syncUrl: tursoUrl, authToken: tursoToken }); + db = createAndSyncDb(); } catch (openErr: any) { - if (openErr?.message?.includes('InvalidLocalState') || openErr?.message?.includes('metadata')) { - console.warn('[TURSO] Self-healing local replica state...'); + if (instance) { + try { instance.close(); } catch { } + instance = null; + } + const errMsg = openErr?.message || ''; + if ( + errMsg.includes('InvalidLocalState') || + errMsg.includes('metadata') || + errMsg.includes('WalConflict') + ) { + console.warn('[TURSO] Self-healing local replica state due to error:', errMsg); for (const ext of ['', '-info', '-wal', '-shm']) { - try { fs.unlinkSync(resolvedPath + ext); } catch {} + try { fs.unlinkSync(resolvedPath + ext); } catch { } } - db = new LibsqlDatabase(resolvedPath, { syncUrl: tursoUrl, authToken: tursoToken }); + db = createAndSyncDb(); } else { throw openErr; } } - console.log('[TURSO] Connected to Turso cloud SQLite at:', tursoUrl); - try { - db.sync(); - lastSyncTime = Date.now(); - console.log('[TURSO] Successfully synchronized with Turso cloud.'); - } catch (syncErr) { - console.warn('[TURSO] Initial sync warning:', syncErr); - } + + lastSyncTime = Date.now(); + console.log('[TURSO] Connected and synchronized with Turso cloud at:', tursoUrl); // Automatically sync writes to Turso cloud attachAutoSync(db); @@ -121,11 +211,11 @@ export function getDb(dbPath?: string): any { } else { db = new DatabaseSync(resolvedPath); } - + // Pragmas for performance and integrity try { db.exec('PRAGMA foreign_keys = ON;'); - } catch {} + } catch { } try { db.exec('PRAGMA journal_mode = WAL;'); } catch { @@ -136,6 +226,7 @@ export function getDb(dbPath?: string): any { if (!dbPath) { globalDb = db; + globalDbUrl = tursoUrl; } return db; } @@ -550,152 +641,152 @@ export function seedDefaultCategories(db: DatabaseSync, userId: string) { sort_order: number; subcategories?: Array<{ id: string; name: string; icon: string }>; }> = [ - { - id: 'cat-food', - name: 'Food & Dining', - type: 'expense', - icon: 'utensils', - color: '#EF4444', - sort_order: 1, - subcategories: [ - { id: 'cat-food-groceries', name: 'Groceries', icon: 'shopping-cart' }, - { id: 'cat-food-restaurants', name: 'Restaurants', icon: 'coffee' }, - { id: 'cat-food-delivery', name: 'Food Delivery', icon: 'bike' }, - { id: 'cat-food-coffee', name: 'Coffee & Snacks', icon: 'cup-soda' }, - ], - }, - { - id: 'cat-transport', - name: 'Transportation', - type: 'expense', - icon: 'car', - color: '#F59E0B', - sort_order: 2, - subcategories: [ - { id: 'cat-trans-fuel', name: 'Fuel', icon: 'fuel' }, - { id: 'cat-trans-cab', name: 'Cab & Auto', icon: 'car-taxi' }, - { id: 'cat-trans-public', name: 'Public Transit', icon: 'train' }, - { id: 'cat-trans-maint', name: 'Vehicle Maintenance', icon: 'wrench' }, - ], - }, - { - id: 'cat-housing', - name: 'Housing & Utilities', - type: 'expense', - icon: 'home', - color: '#3B82F6', - sort_order: 3, - subcategories: [ - { id: 'cat-house-rent', name: 'Rent', icon: 'key' }, - { id: 'cat-house-elec', name: 'Electricity & Gas', icon: 'zap' }, - { id: 'cat-house-net', name: 'Internet & Mobile', icon: 'wifi' }, - { id: 'cat-house-maint', name: 'Maintenance', icon: 'tool' }, - ], - }, - { - id: 'cat-shopping', - name: 'Shopping & Electronics', - type: 'expense', - icon: 'shopping-bag', - color: '#EC4899', - sort_order: 4, - subcategories: [ - { id: 'cat-shop-clothes', name: 'Clothing', icon: 'shirt' }, - { id: 'cat-shop-gadgets', name: 'Electronics', icon: 'laptop' }, - { id: 'cat-shop-home', name: 'Home Essentials', icon: 'package' }, - ], - }, - { - id: 'cat-entertainment', - name: 'Entertainment & Leisure', - type: 'expense', - icon: 'film', - color: '#8B5CF6', - sort_order: 5, - subcategories: [ - { id: 'cat-ent-streaming', name: 'Streaming Services', icon: 'tv' }, - { id: 'cat-ent-movies', name: 'Movies & Events', icon: 'ticket' }, - { id: 'cat-ent-games', name: 'Games & Apps', icon: 'gamepad' }, - ], - }, - { - id: 'cat-health', - name: 'Health & Wellness', - type: 'expense', - icon: 'heart-pulse', - color: '#10B981', - sort_order: 6, - subcategories: [ - { id: 'cat-hlth-meds', name: 'Medicines & Pharmacy', icon: 'pill' }, - { id: 'cat-hlth-doctor', name: 'Doctor & Hospital', icon: 'stethoscope' }, - { id: 'cat-hlth-fitness', name: 'Gym & Fitness', icon: 'activity' }, - ], - }, - { - id: 'cat-finance', - name: 'Financial Expenses', - type: 'expense', - icon: 'credit-card', - color: '#64748B', - sort_order: 7, - subcategories: [ - { id: 'cat-fin-emi', name: 'Loan EMI / Interest', icon: 'percent' }, - { id: 'cat-fin-charges', name: 'Bank Charges & Fees', icon: 'receipt' }, - { id: 'cat-fin-insurance', name: 'Insurance Premium', icon: 'shield' }, - { id: 'cat-fin-tax', name: 'Taxes', icon: 'file-text' }, - ], - }, - { - id: 'cat-savings', - name: 'Savings & Goals', - type: 'expense', - icon: 'piggy-bank', - color: '#06B6D4', - sort_order: 8, - subcategories: [ - { id: 'cat-sav-goal', name: 'Savings Goal Contribution', icon: 'target' }, - { id: 'cat-sav-invest', name: 'Investments & Mutual Funds', icon: 'trending-up' }, - { id: 'cat-sav-emergency', name: 'Emergency Fund', icon: 'shield-check' }, - ], - }, - { - id: 'cat-income-salary', - name: 'Salary & Professional', - type: 'income', - icon: 'briefcase', - color: '#10B981', - sort_order: 8, - subcategories: [ - { id: 'cat-inc-fulltime', name: 'Full-time Salary', icon: 'wallet' }, - { id: 'cat-inc-bonus', name: 'Bonus & Incentives', icon: 'award' }, - { id: 'cat-inc-freelance', name: 'Freelance & Consulting', icon: 'laptop' }, - ], - }, - { - id: 'cat-income-investment', - name: 'Investments & Returns', - type: 'income', - icon: 'trending-up', - color: '#06B6D4', - sort_order: 9, - subcategories: [ - { id: 'cat-inc-dividend', name: 'Dividends & Capital Gains', icon: 'coins' }, - { id: 'cat-inc-interest', name: 'Interest Income', icon: 'piggy-bank' }, - ], - }, - { - id: 'cat-income-other', - name: 'Other Income', - type: 'income', - icon: 'plus-circle', - color: '#14B8A6', - sort_order: 10, - subcategories: [ - { id: 'cat-inc-refund', name: 'Refunds & Reimbursements', icon: 'rotate-ccw' }, - { id: 'cat-inc-gifts', name: 'Gifts & Grants', icon: 'gift' }, - ], - }, - ]; + { + id: 'cat-food', + name: 'Food & Dining', + type: 'expense', + icon: 'utensils', + color: '#EF4444', + sort_order: 1, + subcategories: [ + { id: 'cat-food-groceries', name: 'Groceries', icon: 'shopping-cart' }, + { id: 'cat-food-restaurants', name: 'Restaurants', icon: 'coffee' }, + { id: 'cat-food-delivery', name: 'Food Delivery', icon: 'bike' }, + { id: 'cat-food-coffee', name: 'Coffee & Snacks', icon: 'cup-soda' }, + ], + }, + { + id: 'cat-transport', + name: 'Transportation', + type: 'expense', + icon: 'car', + color: '#F59E0B', + sort_order: 2, + subcategories: [ + { id: 'cat-trans-fuel', name: 'Fuel', icon: 'fuel' }, + { id: 'cat-trans-cab', name: 'Cab & Auto', icon: 'car-taxi' }, + { id: 'cat-trans-public', name: 'Public Transit', icon: 'train' }, + { id: 'cat-trans-maint', name: 'Vehicle Maintenance', icon: 'wrench' }, + ], + }, + { + id: 'cat-housing', + name: 'Housing & Utilities', + type: 'expense', + icon: 'home', + color: '#3B82F6', + sort_order: 3, + subcategories: [ + { id: 'cat-house-rent', name: 'Rent', icon: 'key' }, + { id: 'cat-house-elec', name: 'Electricity & Gas', icon: 'zap' }, + { id: 'cat-house-net', name: 'Internet & Mobile', icon: 'wifi' }, + { id: 'cat-house-maint', name: 'Maintenance', icon: 'tool' }, + ], + }, + { + id: 'cat-shopping', + name: 'Shopping & Electronics', + type: 'expense', + icon: 'shopping-bag', + color: '#EC4899', + sort_order: 4, + subcategories: [ + { id: 'cat-shop-clothes', name: 'Clothing', icon: 'shirt' }, + { id: 'cat-shop-gadgets', name: 'Electronics', icon: 'laptop' }, + { id: 'cat-shop-home', name: 'Home Essentials', icon: 'package' }, + ], + }, + { + id: 'cat-entertainment', + name: 'Entertainment & Leisure', + type: 'expense', + icon: 'film', + color: '#8B5CF6', + sort_order: 5, + subcategories: [ + { id: 'cat-ent-streaming', name: 'Streaming Services', icon: 'tv' }, + { id: 'cat-ent-movies', name: 'Movies & Events', icon: 'ticket' }, + { id: 'cat-ent-games', name: 'Games & Apps', icon: 'gamepad' }, + ], + }, + { + id: 'cat-health', + name: 'Health & Wellness', + type: 'expense', + icon: 'heart-pulse', + color: '#10B981', + sort_order: 6, + subcategories: [ + { id: 'cat-hlth-meds', name: 'Medicines & Pharmacy', icon: 'pill' }, + { id: 'cat-hlth-doctor', name: 'Doctor & Hospital', icon: 'stethoscope' }, + { id: 'cat-hlth-fitness', name: 'Gym & Fitness', icon: 'activity' }, + ], + }, + { + id: 'cat-finance', + name: 'Financial Expenses', + type: 'expense', + icon: 'credit-card', + color: '#64748B', + sort_order: 7, + subcategories: [ + { id: 'cat-fin-emi', name: 'Loan EMI / Interest', icon: 'percent' }, + { id: 'cat-fin-charges', name: 'Bank Charges & Fees', icon: 'receipt' }, + { id: 'cat-fin-insurance', name: 'Insurance Premium', icon: 'shield' }, + { id: 'cat-fin-tax', name: 'Taxes', icon: 'file-text' }, + ], + }, + { + id: 'cat-savings', + name: 'Savings & Goals', + type: 'expense', + icon: 'piggy-bank', + color: '#06B6D4', + sort_order: 8, + subcategories: [ + { id: 'cat-sav-goal', name: 'Savings Goal Contribution', icon: 'target' }, + { id: 'cat-sav-invest', name: 'Investments & Mutual Funds', icon: 'trending-up' }, + { id: 'cat-sav-emergency', name: 'Emergency Fund', icon: 'shield-check' }, + ], + }, + { + id: 'cat-income-salary', + name: 'Salary & Professional', + type: 'income', + icon: 'briefcase', + color: '#10B981', + sort_order: 8, + subcategories: [ + { id: 'cat-inc-fulltime', name: 'Full-time Salary', icon: 'wallet' }, + { id: 'cat-inc-bonus', name: 'Bonus & Incentives', icon: 'award' }, + { id: 'cat-inc-freelance', name: 'Freelance & Consulting', icon: 'laptop' }, + ], + }, + { + id: 'cat-income-investment', + name: 'Investments & Returns', + type: 'income', + icon: 'trending-up', + color: '#06B6D4', + sort_order: 9, + subcategories: [ + { id: 'cat-inc-dividend', name: 'Dividends & Capital Gains', icon: 'coins' }, + { id: 'cat-inc-interest', name: 'Interest Income', icon: 'piggy-bank' }, + ], + }, + { + id: 'cat-income-other', + name: 'Other Income', + type: 'income', + icon: 'plus-circle', + color: '#14B8A6', + sort_order: 10, + subcategories: [ + { id: 'cat-inc-refund', name: 'Refunds & Reimbursements', icon: 'rotate-ccw' }, + { id: 'cat-inc-gifts', name: 'Gifts & Grants', icon: 'gift' }, + ], + }, + ]; const insertStmt = db.prepare(` INSERT OR IGNORE INTO categories (id, user_id, parent_id, name, type, icon, color, sort_order)