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); + }); });