-
+
-
+
+
+ •
+
+
@@ -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/db.ts b/src/lib/db.ts
index 00a481e..27da554 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;
}
@@ -147,7 +238,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 +333,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 +471,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 +484,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 +566,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 +575,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) {
@@ -506,152 +623,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)
diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts
index 04ec9da..df9c481 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 {
@@ -452,6 +453,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(`
@@ -532,6 +702,7 @@ export class FinanceService {
status = 'cleared',
splits = [],
tags = [],
+ loanId,
} = dto;
if (amount <= 0) {
@@ -570,8 +741,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 +753,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 +773,8 @@ export class FinanceService {
notes || `Transfer from ${account.name}`,
status,
transferGroupId,
- accountId
+ accountId,
+ loanId || null
);
// Save tags for transfer legs
@@ -639,8 +812,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,
@@ -653,7 +826,8 @@ export class FinanceService {
merchantName || null,
categoryId || null,
notes || null,
- status
+ status,
+ loanId || null
);
// Save splits if provided
@@ -1045,30 +1219,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 = ?)
@@ -1083,6 +1303,10 @@ export class FinanceService {
this.recalculateAccountBalance(a.account_id);
}
+ for (const loanId of loanIdsToRecalc) {
+ this.recalculateLoanBalance(loanId, userId);
+ }
+
return { restored: true };
}
@@ -1778,6 +2002,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 paidPrincipal = loan.principal - loan.outstanding_principal;
const progressPercent = loan.principal > 0
@@ -1790,13 +2037,27 @@ export class FinanceService {
loan.start_date
);
+ 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',
notes: loan.notes || '',
paidPrincipal,
progressPercent,
- amortization,
+ paidCount,
+ amortization: {
+ ...amortization,
+ schedule: scheduleWithStatus,
+ },
};
});
}
@@ -1820,9 +2081,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,
@@ -1830,6 +2091,7 @@ export class FinanceService {
data.name,
data.principal,
data.outstandingPrincipal,
+ data.outstandingPrincipal || data.principal,
data.interestRate,
amort.monthlyEmi,
data.tenureMonths,
@@ -1846,6 +2108,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 {}
@@ -1860,6 +2123,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;
@@ -1879,39 +2187,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,
@@ -2050,7 +2404,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[];
@@ -2084,7 +2438,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[];
@@ -2213,7 +2567,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/finance-service.test.ts b/tests/finance-service.test.ts
index 029bee8..e40791e 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', () => {
@@ -836,6 +880,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);
+ });
});
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'),
+ },
+ },
+});