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