-
+
-
+
+
+ •
+
+
@@ -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() {