From 629acebc995d4c6ba8f789ebf268a6c277821cee Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Fri, 2 Oct 2026 01:25:02 +0530 Subject: [PATCH 1/3] feat(analytics): tag contribution within tagged expenses & correct loan remaining balance from principal + net interest --- src/app/loans/page.tsx | 12 +++---- src/app/reports/page.tsx | 10 +++--- src/lib/finance-service.ts | 72 +++++++++++++++----------------------- 3 files changed, 41 insertions(+), 53 deletions(-) diff --git a/src/app/loans/page.tsx b/src/app/loans/page.tsx index 50f20f3..2dcb542 100644 --- a/src/app/loans/page.tsx +++ b/src/app/loans/page.tsx @@ -445,9 +445,9 @@ export default function LoansPage() {
- {isEmi ? 'REMAINING BALANCE' : 'OUTSTANDING PRINCIPAL'} + {isEmi ? 'REMAINING BALANCE' : 'OUTSTANDING BALANCE'}
- +
@@ -469,8 +469,8 @@ export default function LoansPage() {
- Principal Repaid: ₹{Math.round(loan.paidPrincipal / 100).toLocaleString('en-IN')} of ₹ - {Math.round(loan.principal / 100).toLocaleString('en-IN')} + Repaid: ₹{Math.round((loan.totalPaid ?? loan.paidPrincipal) / 100).toLocaleString('en-IN')} of ₹ + {Math.round((loan.totalPayable ?? loan.principal) / 100).toLocaleString('en-IN')} {loan.progressPercent}% Completed
@@ -517,8 +517,8 @@ export default function LoansPage() { ₹{Math.round(row.interestComponent / 100).toLocaleString('en-IN')} - - ₹{Math.round(row.remainingPrincipal / 100).toLocaleString('en-IN')} + + ₹{Math.round((row.remainingBalance ?? row.remainingPrincipal) / 100).toLocaleString('en-IN')} ))} diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index b88b5b9..21e8d36 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -356,7 +356,7 @@ function ExpenseDonutChart({ letterSpacing="0.04em" style={{ textTransform: 'uppercase' }} > - {activeSlice ? (activeSlice.name.length > 14 ? activeSlice.name.substring(0, 12) + '…' : activeSlice.name) : 'TOTAL SPENT'} + {activeSlice ? (activeSlice.name.length > 14 ? activeSlice.name.substring(0, 12) + '…' : activeSlice.name) : (analyticsLabel === 'Tag' ? 'TOTAL TAGGED' : 'TOTAL SPENT')} - {activeSlice ? `${activeSlice.percentage}% of total` : `${slices.length} ${analyticsLabel.toLowerCase()}s`} + {activeSlice ? `${activeSlice.percentage}% of ${analyticsLabel === 'Tag' ? 'tagged' : 'total'}` : `${slices.length} ${analyticsLabel.toLowerCase()}s`}
@@ -493,7 +493,9 @@ export default function ReportsPage() { const savingsRate = totalMonthIncome > 0 ? Math.round(((totalMonthIncome - totalMonthSpend) / totalMonthIncome) * 100) : 0; // Active analytics data based on view mode + const totalTaggedSpend = useMemo(() => spendingByTag.reduce((acc: number, t: any) => acc + (t.total || 0), 0), [spendingByTag]); const activeAnalytics = analyticsView === 'category' ? spendingByCategory : spendingByTag; + const activeAnalyticsTotal = analyticsView === 'category' ? totalMonthSpend : totalTaggedSpend; const analyticsLabel = analyticsView === 'category' ? 'Category' : 'Tag'; return ( @@ -1101,7 +1103,7 @@ export default function ReportsPage() { > { - const paidPrincipal = loan.principal - loan.outstanding_principal; - const progressPercent = loan.principal > 0 - ? Math.round((paidPrincipal / loan.principal) * 100) - : 0; const amortization = this.calculateLoanAmortization( loan.principal, loan.interest_rate, @@ -1790,10 +1795,24 @@ export class FinanceService { loan.start_date ); + const totalPayable = amortization.totalRepayment; // Principal + Net Interest + const remainingRatio = loan.principal > 0 ? (loan.outstanding_principal / loan.principal) : 0; + // Remaining total balance (principal + remaining interest) + const remainingBalance = Math.round(totalPayable * remainingRatio); + const totalPaid = Math.max(0, totalPayable - remainingBalance); + const paidPrincipal = loan.principal - loan.outstanding_principal; + const progressPercent = totalPayable > 0 + ? Math.min(100, Math.round((totalPaid / totalPayable) * 100)) + : 0; + return { ...loan, type: loan.type || 'loan', notes: loan.notes || '', + totalPayable, + totalInterest: amortization.totalInterest, + totalPaid, + remainingBalance, paidPrincipal, progressPercent, amortization, @@ -2063,17 +2082,6 @@ export class FinanceService { getSpendingByTag(userId: string, monthStr?: string) { const month = monthStr || new Date().toISOString().substring(0, 7); - // Total expense for the month across all transactions - const totalRow = this.db.prepare(` - SELECT COALESCE(SUM(amount), 0) as total - FROM transactions - WHERE user_id = ? - AND type = 'expense' - AND is_deleted = 0 - AND date LIKE ? - `).get(userId, `${month}%`) as any; - const totalExpense = totalRow?.total || 0; - // Tagged transactions const taggedRows = this.db.prepare(` SELECT tg.id, tg.name, tg.color, SUM(t.amount) as total, COUNT(t.id) as count @@ -2088,34 +2096,12 @@ export class FinanceService { ORDER BY total DESC `).all(userId, `${month}%`) as any[]; - // Untagged transactions (expense transactions with no tags in transaction_tags) - const untaggedRow = this.db.prepare(` - SELECT COALESCE(SUM(t.amount), 0) as total, COUNT(t.id) as count - FROM transactions t - WHERE t.user_id = ? - AND t.type = 'expense' - AND t.is_deleted = 0 - AND t.date LIKE ? - AND NOT EXISTS ( - SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id - ) - `).get(userId, `${month}%`) as any; - - const allRows = [...taggedRows]; - if (untaggedRow && untaggedRow.total > 0) { - allRows.push({ - id: 'untagged', - name: 'Untagged', - color: '#94A3B8', - total: untaggedRow.total, - count: untaggedRow.count, - }); - allRows.sort((a: any, b: any) => b.total - a.total); - } + // Total of tagged expenses only + const totalTaggedExpense = taggedRows.reduce((sum: number, r: any) => sum + r.total, 0); - return allRows.map((r: any) => ({ + return taggedRows.map((r: any) => ({ ...r, - percentage: totalExpense > 0 ? Math.round((r.total / totalExpense) * 100) : 0, + percentage: totalTaggedExpense > 0 ? Math.round((r.total / totalTaggedExpense) * 100) : 0, })); } From 616632bccd8d223b9529bc2bc65b04283de5ccde Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Fri, 2 Oct 2026 01:33:44 +0530 Subject: [PATCH 2/3] fix(transfers): auto-attach cc-repayment tag to credit card transfers and parse note tags --- src/app/accounts/page.tsx | 2 +- src/app/api/transfers/route.ts | 1 + src/components/TransactionModal.tsx | 29 ++++++++- src/lib/db.ts | 18 ++++++ src/lib/finance-service.ts | 91 +++++++++++++++++++++++++---- tests/finance-service.test.ts | 41 +++++++++++++ 6 files changed, 166 insertions(+), 16 deletions(-) diff --git a/src/app/accounts/page.tsx b/src/app/accounts/page.tsx index fdb58d9..e1a4c25 100644 --- a/src/app/accounts/page.tsx +++ b/src/app/accounts/page.tsx @@ -442,7 +442,7 @@ export default function AccountsPage() {
diff --git a/src/lib/db.ts b/src/lib/db.ts index 7cf938f..f84f04b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -147,7 +147,9 @@ export function initSchema(db: DatabaseSync) { const hasGoalNotes = (db as any).prepare("SELECT 1 FROM pragma_table_info('goals') WHERE name = 'notes'").get(); const hasLoanType = (db as any).prepare("SELECT 1 FROM pragma_table_info('loans') WHERE name = 'type'").get(); const hasLoanNotes = (db as any).prepare("SELECT 1 FROM pragma_table_info('loans') WHERE name = 'notes'").get(); - if (hasGoalNotes && hasLoanType && hasLoanNotes) { + const hasTxLoanId = (db as any).prepare("SELECT 1 FROM pragma_table_info('transactions') WHERE name = 'loan_id'").get(); + const hasLoanPayments = (db as any).prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='loan_payments'").get(); + if (hasGoalNotes && hasLoanType && hasLoanNotes && hasTxLoanId && hasLoanPayments) { isFullyMigrated = true; } } catch { @@ -240,6 +242,7 @@ export function initSchema(db: DatabaseSync) { transfer_group_id TEXT, -- pairs the 2 sides of a transfer transfer_peer_account_id TEXT, -- for display convenience destination_account_id TEXT, -- for outgoing transfer target + loan_id TEXT, is_deleted INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), @@ -377,6 +380,7 @@ export function initSchema(db: DatabaseSync) { name TEXT NOT NULL, principal INTEGER NOT NULL, outstanding_principal INTEGER NOT NULL, + initial_outstanding_principal INTEGER DEFAULT 0, interest_rate REAL NOT NULL, -- percentage e.g. 8.5 emi_amount INTEGER NOT NULL, tenure_months INTEGER NOT NULL, @@ -389,6 +393,21 @@ export function initSchema(db: DatabaseSync) { FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS loan_payments ( + id TEXT PRIMARY KEY, + loan_id TEXT NOT NULL, + user_id TEXT NOT NULL, + transaction_id TEXT, + transfer_group_id TEXT, + amount INTEGER NOT NULL DEFAULT 0, + principal_paid INTEGER NOT NULL DEFAULT 0, + interest_paid INTEGER NOT NULL DEFAULT 0, + payment_date TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (loan_id) REFERENCES loans(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS reconciliations ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -456,6 +475,7 @@ export function initSchema(db: DatabaseSync) { `ALTER TABLE transactions ADD COLUMN recurring_id TEXT`, `ALTER TABLE transactions ADD COLUMN transfer_peer_account_id TEXT`, `ALTER TABLE transactions ADD COLUMN destination_account_id TEXT`, + `ALTER TABLE transactions ADD COLUMN loan_id TEXT`, `ALTER TABLE budgets ADD COLUMN period_type TEXT DEFAULT 'monthly'`, `ALTER TABLE budgets ADD COLUMN period_start TEXT`, `ALTER TABLE budgets ADD COLUMN period_end TEXT`, @@ -464,10 +484,16 @@ export function initSchema(db: DatabaseSync) { `ALTER TABLE investments ADD COLUMN cost_basis INTEGER DEFAULT 0`, `ALTER TABLE loans ADD COLUMN principal INTEGER DEFAULT 0`, `ALTER TABLE loans ADD COLUMN outstanding_principal INTEGER DEFAULT 0`, + `ALTER TABLE loans ADD COLUMN initial_outstanding_principal INTEGER DEFAULT 0`, `ALTER TABLE loans ADD COLUMN tenure_months INTEGER DEFAULT 12`, `ALTER TABLE loans ADD COLUMN emi_day INTEGER DEFAULT 5`, `ALTER TABLE loans ADD COLUMN type TEXT DEFAULT 'loan'`, `ALTER TABLE loans ADD COLUMN notes TEXT`, + `CREATE INDEX IF NOT EXISTS idx_loan_payments_loan ON loan_payments(loan_id)`, + `CREATE INDEX IF NOT EXISTS idx_loan_payments_user ON loan_payments(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_loan_payments_tx ON loan_payments(transaction_id)`, + `CREATE INDEX IF NOT EXISTS idx_loan_payments_tg ON loan_payments(transfer_group_id)`, + `CREATE INDEX IF NOT EXISTS idx_tx_loan_id ON transactions(loan_id)`, ]; for (const sql of safeAlterations) { diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index 8824e37..05d9e90 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -34,6 +34,7 @@ export interface CreateTransactionDTO { status?: 'pending' | 'cleared' | 'reconciled'; splits?: Array<{ categoryId: string; amount: number; notes?: string }>; tags?: string[]; + loanId?: string; } export class FinanceService { @@ -532,6 +533,7 @@ export class FinanceService { status = 'cleared', splits = [], tags = [], + loanId, } = dto; if (amount <= 0) { @@ -570,8 +572,8 @@ export class FinanceService { this.db.prepare(` INSERT INTO transactions ( id, user_id, account_id, type, amount, currency, date, - notes, status, transfer_group_id, destination_account_id - ) VALUES (?, ?, ?, 'transfer', ?, ?, ?, ?, ?, ?, ?) + notes, status, transfer_group_id, destination_account_id, loan_id + ) VALUES (?, ?, ?, 'transfer', ?, ?, ?, ?, ?, ?, ?, ?) `).run( outTxId, userId, @@ -582,15 +584,16 @@ export class FinanceService { notes || `Transfer to ${destAccount.name}`, status, transferGroupId, - destinationAccountId + destinationAccountId, + loanId || null ); // 2. Incoming transfer leg this.db.prepare(` INSERT INTO transactions ( id, user_id, account_id, type, amount, currency, date, - notes, status, transfer_group_id, transfer_peer_account_id - ) VALUES (?, ?, ?, 'transfer', ?, ?, ?, ?, ?, ?, ?) + notes, status, transfer_group_id, transfer_peer_account_id, loan_id + ) VALUES (?, ?, ?, 'transfer', ?, ?, ?, ?, ?, ?, ?, ?) `).run( inTxId, userId, @@ -601,7 +604,8 @@ export class FinanceService { notes || `Transfer from ${account.name}`, status, transferGroupId, - accountId + accountId, + loanId || null ); // Collect tags: user-provided tags + tags extracted from notes @@ -663,8 +667,8 @@ export class FinanceService { this.db.prepare(` INSERT INTO transactions ( id, user_id, account_id, type, amount, currency, date, - merchant_id, merchant_name, category_id, notes, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + merchant_id, merchant_name, category_id, notes, status, loan_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( txId, userId, @@ -677,7 +681,8 @@ export class FinanceService { merchantName || null, categoryId || null, notes || null, - status + status, + loanId || null ); // Save splits if provided @@ -1112,30 +1117,76 @@ export class FinanceService { const tx = this.getTransactionById(id, userId); if (!tx) throw new Error('Transaction not found'); + const loanIdsToRecalc = new Set(); + if (tx.loan_id) loanIdsToRecalc.add(tx.loan_id); + + try { + const linkedLoanPayments = this.db.prepare(` + SELECT DISTINCT loan_id + FROM loan_payments + WHERE user_id = ? AND (transaction_id = ? OR (transfer_group_id IS NOT NULL AND transfer_group_id = ?)) + `).all(userId, id, tx.transfer_group_id || '') as any[]; + for (const lp of linkedLoanPayments) { + if (lp.loan_id) loanIdsToRecalc.add(lp.loan_id); + } + } catch {} + if (tx.transfer_group_id) { // It's a transfer pair! Delete both legs if (permanent) { this.db.prepare('DELETE FROM transactions WHERE transfer_group_id = ?').run(tx.transfer_group_id); + try { + this.db.prepare('DELETE FROM loan_payments WHERE transfer_group_id = ?').run(tx.transfer_group_id); + } catch {} } else { this.db.prepare('UPDATE transactions SET is_deleted = 1 WHERE transfer_group_id = ?').run(tx.transfer_group_id); } if (tx.account_id) this.recalculateAccountBalance(tx.account_id); if (tx.destination_account_id) this.recalculateAccountBalance(tx.destination_account_id); if (tx.transfer_peer_account_id) this.recalculateAccountBalance(tx.transfer_peer_account_id); + + for (const loanId of loanIdsToRecalc) { + this.recalculateLoanBalance(loanId, userId); + } + return { success: true, undoId: tx.transfer_group_id, isTransfer: true }; } if (permanent) { this.db.prepare('DELETE FROM transactions WHERE id = ? AND user_id = ?').run(id, userId); + try { + this.db.prepare('DELETE FROM loan_payments WHERE transaction_id = ?').run(id); + } catch {} } else { this.db.prepare('UPDATE transactions SET is_deleted = 1 WHERE id = ? AND user_id = ?').run(id, userId); } this.recalculateAccountBalance(tx.account_id); + + for (const loanId of loanIdsToRecalc) { + this.recalculateLoanBalance(loanId, userId); + } + return { success: true, undoId: id, isTransfer: false }; } restoreTransaction(idOrGroupId: string, userId: string) { + const loanIdsToRecalc = new Set(); + try { + const linkedTxs = this.db.prepare(` + SELECT loan_id FROM transactions WHERE user_id = ? AND (id = ? OR transfer_group_id = ?) + `).all(userId, idOrGroupId, idOrGroupId) as any[]; + for (const t of linkedTxs) { + if (t.loan_id) loanIdsToRecalc.add(t.loan_id); + } + const linkedLps = this.db.prepare(` + SELECT loan_id FROM loan_payments WHERE user_id = ? AND (transaction_id = ? OR transfer_group_id = ?) + `).all(userId, idOrGroupId, idOrGroupId) as any[]; + for (const lp of linkedLps) { + if (lp.loan_id) loanIdsToRecalc.add(lp.loan_id); + } + } catch {} + this.db.prepare(` UPDATE transactions SET is_deleted = 0 WHERE user_id = ? AND (id = ? OR transfer_group_id = ?) @@ -1150,6 +1201,10 @@ export class FinanceService { this.recalculateAccountBalance(a.account_id); } + for (const loanId of loanIdsToRecalc) { + this.recalculateLoanBalance(loanId, userId); + } + return { restored: true }; } @@ -1854,6 +1909,29 @@ export class FinanceService { ORDER BY l.created_at DESC `).all(userId) as any[]; + // Get active loan payments counts + const paymentCountsMap = new Map(); + try { + const pCounts = this.db.prepare(` + SELECT lp.loan_id, COUNT(DISTINCT lp.id) as count + FROM loan_payments lp + LEFT JOIN transactions t_single ON (lp.transaction_id IS NOT NULL AND t_single.id = lp.transaction_id) + LEFT JOIN transactions t_group ON (lp.transfer_group_id IS NOT NULL AND t_group.transfer_group_id = lp.transfer_group_id) + WHERE lp.user_id = ? + AND ( + (lp.transaction_id IS NOT NULL AND (t_single.is_deleted = 0 OR t_single.is_deleted IS NULL)) + OR + (lp.transfer_group_id IS NOT NULL AND (t_group.is_deleted = 0 OR t_group.is_deleted IS NULL)) + OR + (lp.transaction_id IS NULL AND lp.transfer_group_id IS NULL) + ) + GROUP BY lp.loan_id + `).all(userId) as any[]; + for (const pc of pCounts) { + paymentCountsMap.set(pc.loan_id, pc.count); + } + } catch {} + return loans.map(loan => { const amortization = this.calculateLoanAmortization( loan.principal, @@ -1872,6 +1950,17 @@ export class FinanceService { ? Math.min(100, Math.round((totalPaid / totalPayable) * 100)) : 0; + const paidCount = paymentCountsMap.get(loan.id) ?? ( + loan.principal > 0 + ? Math.min(loan.tenure_months, Math.round((paidPrincipal / loan.principal) * loan.tenure_months)) + : 0 + ); + + const scheduleWithStatus = amortization.schedule.map((row: any) => ({ + ...row, + isPaid: row.monthNumber <= paidCount, + })); + return { ...loan, type: loan.type || 'loan', @@ -1882,7 +1971,11 @@ export class FinanceService { remainingBalance, paidPrincipal, progressPercent, - amortization, + paidCount, + amortization: { + ...amortization, + schedule: scheduleWithStatus, + }, }; }); } @@ -1906,9 +1999,9 @@ export class FinanceService { const runInsert = () => { this.db.prepare(` INSERT INTO loans ( - id, user_id, account_id, name, principal, outstanding_principal, + id, user_id, account_id, name, principal, outstanding_principal, initial_outstanding_principal, interest_rate, emi_amount, tenure_months, start_date, emi_day, type, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, data.userId, @@ -1916,6 +2009,7 @@ export class FinanceService { data.name, data.principal, data.outstandingPrincipal, + data.outstandingPrincipal || data.principal, data.interestRate, amort.monthlyEmi, data.tenureMonths, @@ -1932,6 +2026,7 @@ export class FinanceService { if (err?.message?.includes('no column named') || err?.message?.includes('table loans')) { try { this.db.exec(`ALTER TABLE loans ADD COLUMN principal INTEGER DEFAULT 0`); } catch {} try { this.db.exec(`ALTER TABLE loans ADD COLUMN outstanding_principal INTEGER DEFAULT 0`); } catch {} + try { this.db.exec(`ALTER TABLE loans ADD COLUMN initial_outstanding_principal INTEGER DEFAULT 0`); } catch {} try { this.db.exec(`ALTER TABLE loans ADD COLUMN tenure_months INTEGER DEFAULT 12`); } catch {} try { this.db.exec(`ALTER TABLE loans ADD COLUMN emi_day INTEGER DEFAULT 5`); } catch {} try { this.db.exec(`ALTER TABLE loans ADD COLUMN type TEXT DEFAULT 'loan'`); } catch {} @@ -1946,6 +2041,51 @@ export class FinanceService { return id; } + recalculateLoanBalance(loanId: string, userId: string) { + const loan = this.db.prepare('SELECT * FROM loans WHERE id = ? AND user_id = ?').get(loanId, userId) as any; + if (!loan) return; + + let totalPrincipalPaid = 0; + try { + const payments = this.db.prepare(` + SELECT lp.* + FROM loan_payments lp + LEFT JOIN transactions t_single ON (lp.transaction_id IS NOT NULL AND t_single.id = lp.transaction_id) + LEFT JOIN transactions t_group ON (lp.transfer_group_id IS NOT NULL AND t_group.transfer_group_id = lp.transfer_group_id) + WHERE lp.loan_id = ? + AND lp.user_id = ? + AND ( + (lp.transaction_id IS NOT NULL AND (t_single.is_deleted = 0 OR t_single.is_deleted IS NULL)) + OR + (lp.transfer_group_id IS NOT NULL AND (t_group.is_deleted = 0 OR t_group.is_deleted IS NULL)) + OR + (lp.transaction_id IS NULL AND lp.transfer_group_id IS NULL) + ) + GROUP BY lp.id + `).all(loanId, userId) as any[]; + + for (const p of payments) { + totalPrincipalPaid += (p.principal_paid || 0); + } + } catch { + // Fallback if loan_payments table doesn't exist yet + } + + const basePrincipal = (loan.initial_outstanding_principal && loan.initial_outstanding_principal > 0) + ? loan.initial_outstanding_principal + : loan.principal; + + const newOutstanding = Math.max(0, basePrincipal - totalPrincipalPaid); + + this.db.prepare(` + UPDATE loans + SET outstanding_principal = ? + WHERE id = ? AND user_id = ? + `).run(newOutstanding, loanId, userId); + + return newOutstanding; + } + recordLoanPayment(params: { userId: string; loanId: string; @@ -1965,39 +2105,85 @@ export class FinanceService { principalComponent = loan.outstanding_principal; } - // Find a relevant category for EMI payments - let emiCat = this.db.prepare("SELECT id FROM categories WHERE user_id = ? AND (name LIKE '%loan%' OR name LIKE '%emi%' OR name LIKE '%debt%') LIMIT 1").get(params.userId) as any; - if (!emiCat) { - emiCat = this.db.prepare("SELECT id FROM categories WHERE user_id = ? AND type = 'expense' LIMIT 1").get(params.userId) as any; - } - const isEmi = loan.type === 'emi'; const txName = isEmi ? `EMI: ${loan.name}` : `Loan EMI: ${loan.name}`; const paymentDate = params.date || new Date().toISOString().substring(0, 10); + const notes = `Installment payment for ${isEmi ? 'Purchase EMI' : 'Loan'} "${loan.name}" (Principal: ₹${Math.round(principalComponent / 100)}, Interest: ₹${Math.round(interestComponent / 100)})`; - // 1. Record expense in account transactions (reduces account balance and liquid cash) - const txId = this.createTransaction({ - userId: params.userId, - accountId: params.accountId, - type: 'expense', - amount: paymentAmount, - date: paymentDate, - merchantName: txName, - categoryId: emiCat?.id, - notes: `Installment payment for ${isEmi ? 'Purchase EMI' : 'Loan'} "${loan.name}" (Principal: ₹${Math.round(principalComponent / 100)}, Interest: ₹${Math.round(interestComponent / 100)})`, - }); + let txId = ''; + let transferGroupId: string | null = null; - // 2. Reduce outstanding principal on loan (reduces liabilities) - const newOutstanding = Math.max(0, loan.outstanding_principal - principalComponent); - this.db.prepare(` - UPDATE loans - SET outstanding_principal = ? - WHERE id = ? AND user_id = ? - `).run(newOutstanding, params.loanId, params.userId); + const linkedAccount = loan.account_id ? this.getAccountById(loan.account_id, params.userId) : null; + const isTransfer = Boolean(linkedAccount && linkedAccount.id !== params.accountId); + + if (isTransfer) { + const isCc = linkedAccount?.type === 'credit_card'; + const tags = ['loan-repayment', 'emi']; + if (isCc) tags.push('cc-repayment'); + + const transferResult = this.createTransaction({ + userId: params.userId, + accountId: params.accountId, // Source account (e.g. Bank) + destinationAccountId: loan.account_id, // Target account (e.g. Credit Card / Loan) + type: 'transfer', + amount: paymentAmount, + date: paymentDate, + notes, + tags, + loanId: params.loanId, + }); + + transferGroupId = transferResult.transferGroupId; + txId = transferResult.id; + } else { + // Find a relevant category for EMI payments + let emiCat = this.db.prepare("SELECT id FROM categories WHERE user_id = ? AND (name LIKE '%loan%' OR name LIKE '%emi%' OR name LIKE '%debt%') LIMIT 1").get(params.userId) as any; + if (!emiCat) { + emiCat = this.db.prepare("SELECT id FROM categories WHERE user_id = ? AND type = 'expense' LIMIT 1").get(params.userId) as any; + } + + txId = this.createTransaction({ + userId: params.userId, + accountId: params.accountId, + type: 'expense', + amount: paymentAmount, + date: paymentDate, + merchantName: txName, + categoryId: emiCat?.id, + notes, + tags: ['loan-repayment', 'emi'], + loanId: params.loanId, + }); + } + + // Record into loan_payments table + const paymentRecordId = `lp_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`; + try { + this.db.prepare(` + INSERT INTO loan_payments ( + id, loan_id, user_id, transaction_id, transfer_group_id, + amount, principal_paid, interest_paid, payment_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + paymentRecordId, + params.loanId, + params.userId, + txId, + transferGroupId, + paymentAmount, + principalComponent, + interestComponent, + paymentDate + ); + } catch {} + + // Recalculate loan balance + const newOutstanding = this.recalculateLoanBalance(params.loanId, params.userId) ?? Math.max(0, loan.outstanding_principal - principalComponent); return { success: true, transactionId: txId, + transferGroupId, principalPaid: principalComponent, interestPaid: interestComponent, newOutstandingPrincipal: newOutstanding, diff --git a/tests/finance-service.test.ts b/tests/finance-service.test.ts index d0995b2..2f70a12 100644 --- a/tests/finance-service.test.ts +++ b/tests/finance-service.test.ts @@ -398,8 +398,8 @@ describe('Finance Domain Business Logic Tests', () => { expect(updated?.current_balance).toBe(4950000); }); - it('records loan & EMI payment, decreasing liquid cash and loan liabilities synchronously', () => { - // 1. Create a bank account with ₹1,00,000 + it('records loan & EMI payment as a transfer from bank to credit card and updates balances', () => { + // 1. Create a bank account with ₹1,00,000 and a credit card account with ₹60,000 debt const bank = service.createAccount({ userId, name: 'Axis Bank Checking', @@ -407,10 +407,18 @@ describe('Finance Domain Business Logic Tests', () => { openingBalance: 10000000, // ₹1,00,000 })!; - // 2. Create a purchase EMI for MacBook (0% No-Cost EMI: ₹60,000 over 6 months -> ₹10,000/mo) + const card = service.createAccount({ + userId, + name: 'HDFC Regalia CC', + type: 'credit_card', + openingBalance: 6000000, // ₹60,000 debt + creditLimit: 20000000, // ₹2,00,000 + })!; + + // 2. Create a purchase EMI for MacBook on the credit card (0% No-Cost EMI: ₹60,000 over 6 months -> ₹10,000/mo) const emiId = service.createLoan({ userId, - accountId: bank.id, + accountId: card.id, name: 'MacBook Air M3', principal: 6000000, // ₹60,000 outstandingPrincipal: 6000000, @@ -420,12 +428,7 @@ describe('Finance Domain Business Logic Tests', () => { type: 'emi', }); - const metricsBefore = service.getDashboardMetrics(userId); - expect(metricsBefore.cashBalance).toBe(10000000); // ₹1,00,000 - expect(metricsBefore.totalLiabilities).toBe(6000000); // ₹60,000 - expect(metricsBefore.netWorth).toBe(10000000 - 6000000); // ₹40,000 - - // 3. Record EMI installment payment of ₹10,000 + // 3. Record EMI installment payment of ₹10,000 from bank to CC const paymentResult = service.recordLoanPayment({ userId, loanId: emiId, @@ -435,17 +438,58 @@ describe('Finance Domain Business Logic Tests', () => { }); expect(paymentResult.success).toBe(true); + expect(paymentResult.transferGroupId).toBeDefined(); expect(paymentResult.newOutstandingPrincipal).toBe(5000000); // ₹50,000 remaining - // 4. Verify that bank balance (liquid cash) decreased by ₹10,000 + // 4. Verify bank balance decreased by ₹10,000 const bankAfter = service.getAccountById(bank.id, userId); expect(bankAfter?.current_balance).toBe(9000000); // ₹90,000 - // 5. Verify that dashboard metrics reflect updated liquid cash and reduced liabilities - const metricsAfter = service.getDashboardMetrics(userId); - expect(metricsAfter.cashBalance).toBe(9000000); // ₹90,000 liquid cash - expect(metricsAfter.totalLiabilities).toBe(5000000); // ₹50,000 remaining liabilities - expect(metricsAfter.netWorth).toBe(9000000 - 5000000); // ₹40,000 (net worth preserved!) + // 5. Verify credit card debt decreased by ₹10,000 (from ₹60,000 to ₹50,000) + const cardAfter = service.getAccountById(card.id, userId); + expect(cardAfter?.current_balance).toBe(5000000); // ₹50,000 + + // 6. Verify transfer transaction tags include cc-repayment and loan-repayment + const tx = service.getTransactionById(paymentResult.transactionId, userId); + expect(tx?.type).toBe('transfer'); + expect(tx?.tags).toContain('cc-repayment'); + expect(tx?.tags).toContain('loan-repayment'); + expect(tx?.tags).toContain('emi'); + + // 7. Verify loans list returns updated outstanding and paid status + const loans = service.getLoans(userId); + const emiLoan = loans.find(l => l.id === emiId); + expect(emiLoan?.outstanding_principal).toBe(5000000); + expect(emiLoan?.paidPrincipal).toBe(1000000); + expect(emiLoan?.amortization.schedule[0].isPaid).toBe(true); + expect(emiLoan?.amortization.schedule[1].isPaid).toBe(false); + + // 8. Delete the repayment transaction and verify loan balance & accounts are restored + service.deleteTransaction(paymentResult.transactionId, userId); + + const bankRestored = service.getAccountById(bank.id, userId); + expect(bankRestored?.current_balance).toBe(10000000); // restored to ₹1,00,000 + + const cardRestored = service.getAccountById(card.id, userId); + expect(cardRestored?.current_balance).toBe(6000000); // restored to ₹60,000 debt + + const loansAfterDelete = service.getLoans(userId); + const emiLoanAfterDelete = loansAfterDelete.find(l => l.id === emiId); + expect(emiLoanAfterDelete?.outstanding_principal).toBe(6000000); // restored to ₹60,000! + expect(emiLoanAfterDelete?.paidPrincipal).toBe(0); + expect(emiLoanAfterDelete?.amortization.schedule[0].isPaid).toBe(false); + + // 9. Restore the deleted transaction and verify loan balance & accounts re-apply + service.restoreTransaction(paymentResult.transferGroupId!, userId); + + const bankReapplied = service.getAccountById(bank.id, userId); + expect(bankReapplied?.current_balance).toBe(9000000); + + const loansAfterRestore = service.getLoans(userId); + const emiLoanAfterRestore = loansAfterRestore.find(l => l.id === emiId); + expect(emiLoanAfterRestore?.outstanding_principal).toBe(5000000); + expect(emiLoanAfterRestore?.paidPrincipal).toBe(1000000); + expect(emiLoanAfterRestore?.amortization.schedule[0].isPaid).toBe(true); }); it('updates an existing expense transaction and recalculates account balances accurately', () => {