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() {
openTransactionModal('transfer', undefined)}
+ onClick={() => openTransactionModal('transfer', card.id)}
title="Pay credit card bill"
style={{ fontSize: '0.75rem', color: 'var(--brand-primary)', fontWeight: 600 }}
>
diff --git a/src/app/api/transfers/route.ts b/src/app/api/transfers/route.ts
index 0fdc02d..69b4d2b 100644
--- a/src/app/api/transfers/route.ts
+++ b/src/app/api/transfers/route.ts
@@ -25,6 +25,7 @@ export async function POST(req: Request) {
date: body.date,
destinationAccountId: body.toAccountId,
notes: body.notes,
+ tags: body.tags,
});
return NextResponse.json({ transfer: result }, { status: 201 });
diff --git a/src/app/loans/page.tsx b/src/app/loans/page.tsx
index 50f20f3..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={{
@@ -445,9 +446,9 @@ export default function LoansPage() {
- {isEmi ? 'REMAINING BALANCE' : 'OUTSTANDING PRINCIPAL'}
+ {isEmi ? 'REMAINING BALANCE' : 'OUTSTANDING BALANCE'}
-
+
@@ -469,8 +470,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
@@ -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.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() {
+ {payModalLoan.account_name && (
+
+
+
+ Target Account: {payModalLoan.account_name}
+
+
Repayment Transfer
+
+ )}
+
- PAY FROM ACCOUNT (LIQUID CASH) *
+ PAY FROM ACCOUNT (LIQUID CASH / BANK) *
{accounts.map(a => (
- {a.name} (Balance: ₹{Math.round(a.current_balance / 100).toLocaleString('en-IN')})
+ {a.name} ({a.type}) - Balance: ₹{Math.round(a.current_balance / 100).toLocaleString('en-IN')}
))}
- This will record an expense transaction and automatically deduct ₹{Math.round(payModalLoan.emi_amount / 100).toLocaleString('en-IN')} from your liquid cash.
+ This will record a repayment transfer from your selected account to {payModalLoan.account_name || 'the linked debt account'}, reducing both your liquid cash and debt.
diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx
index 4afcca2..a2b0708 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() {
>
{
setAccounts(d.accounts);
if (!editingTransaction) {
if (preselectedAccountId) {
- setAccountId(preselectedAccountId);
+ const preselectedAcc = d.accounts.find((a: any) => a.id === preselectedAccountId);
+ if (preselectedAcc && preselectedAcc.type === 'credit_card' && transactionModalType === 'transfer') {
+ const defaultBank = d.accounts.find((a: any) => a.id !== preselectedAcc.id && a.type !== 'credit_card') || d.accounts.find((a: any) => a.id !== preselectedAcc.id);
+ if (defaultBank) setAccountId(defaultBank.id);
+ setDestinationAccountId(preselectedAcc.id);
+ setSelectedTags(prev => prev.some(t => t.name.toLowerCase() === 'cc-repayment') ? prev : [...prev, { id: 'cc-repayment', name: 'cc-repayment', color: '#3B82F6' }]);
+ } else {
+ setAccountId(preselectedAccountId);
+ const otherAcc = d.accounts.find((a: any) => a.id !== preselectedAccountId);
+ if (otherAcc) setDestinationAccountId(otherAcc.id);
+ }
} else {
const defaultAcc = d.accounts.find((a: any) => Boolean(a.is_default)) || d.accounts[0];
if (defaultAcc) {
setAccountId(defaultAcc.id);
const otherAcc = d.accounts.find((a: any) => a.id !== defaultAcc.id);
- if (otherAcc) setDestinationAccountId(otherAcc.id);
+ if (otherAcc) {
+ setDestinationAccountId(otherAcc.id);
+ if (otherAcc.type === 'credit_card' && transactionModalType === 'transfer') {
+ setSelectedTags(prev => prev.some(t => t.name.toLowerCase() === 'cc-repayment') ? prev : [...prev, { id: 'cc-repayment', name: 'cc-repayment', color: '#3B82F6' }]);
+ }
+ }
}
}
}
@@ -517,6 +532,7 @@ export const TransactionModal = () => {
amount: minorAmount,
date,
notes,
+ tags: selectedTags.map(t => t.name),
}),
});
const data = await res.json();
@@ -1006,7 +1022,14 @@ export const TransactionModal = () => {
setDestinationAccountId(e.target.value)}
+ onChange={e => {
+ const newDestId = e.target.value;
+ setDestinationAccountId(newDestId);
+ const destAcc = accounts.find(a => a.id === newDestId);
+ if (destAcc && destAcc.type === 'credit_card') {
+ setSelectedTags(prev => prev.some(t => t.name.toLowerCase() === 'cc-repayment') ? prev : [...prev, { id: 'cc-repayment', name: 'cc-repayment', color: '#3B82F6' }]);
+ }
+ }}
>
{accounts.map(acc => {
const bal = acc.isLiability ? (acc.totalDebt ?? acc.current_balance) : acc.current_balance;
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 00a481e..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) {
@@ -481,6 +507,24 @@ export function initSchema(db: DatabaseSync) {
try {
db.exec(`UPDATE users SET owner_email = email WHERE owner_email IS NULL OR owner_email = ''`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_owner_email ON users(owner_email)`);
+
+ // Ensure cc-repayment tag exists for users who have credit cards
+ db.exec(`
+ INSERT OR IGNORE INTO tags (id, user_id, name, color)
+ SELECT DISTINCT 'tag_' || a.user_id || '_ccrepayment', a.user_id, 'cc-repayment', '#3B82F6'
+ FROM accounts a
+ WHERE a.type = 'credit_card';
+ `);
+
+ // Backfill cc-repayment tag link on credit card transfers
+ db.exec(`
+ INSERT OR IGNORE INTO transaction_tags (transaction_id, tag_id)
+ SELECT t.id, tg.id
+ FROM transactions t
+ JOIN accounts a ON a.id = COALESCE(t.destination_account_id, t.transfer_peer_account_id)
+ JOIN tags tg ON tg.user_id = t.user_id AND LOWER(tg.name) = 'cc-repayment'
+ WHERE t.type = 'transfer' AND a.type = 'credit_card';
+ `);
} catch {
// Ignore
}
diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts
index 04ec9da..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,16 +604,41 @@ export class FinanceService {
notes || `Transfer from ${account.name}`,
status,
transferGroupId,
- accountId
+ accountId,
+ loanId || null
);
+ // Collect tags: user-provided tags + tags extracted from notes
+ const finalTags: string[] = Array.isArray(tags) ? [...tags] : [];
+
+ if (notes) {
+ const matches = (notes.match(/#[a-zA-Z0-9_\-]+/g) || []).map((t: string) => t.replace(/^#/, ''));
+ finalTags.push(...matches);
+ }
+
+ // Auto-detect Credit Card repayment (destAccount or account is credit card, or notes indicate cc repayment)
+ const isCcRepayment =
+ destAccount.type === 'credit_card' ||
+ account.type === 'credit_card' ||
+ /cc[-_\s]?repay|credit\s*card\s*repay|credit\s*card\s*bill|cc\s*bill|card\s*payment/i.test(notes || '');
+
+ if (isCcRepayment) {
+ const hasCcTag = finalTags.some(t => {
+ const c = t.trim().replace(/^#/, '').toLowerCase();
+ return c === 'cc-repayment' || c === 'cc_repayment' || c === 'ccrepayment';
+ });
+ if (!hasCcTag) {
+ finalTags.push('cc-repayment');
+ }
+ }
+
// Save tags for transfer legs
- if (tags.length > 0) {
+ if (finalTags.length > 0) {
const linkInsert = this.db.prepare(`
INSERT OR IGNORE INTO transaction_tags (transaction_id, tag_id) VALUES (?, ?)
`);
- for (const tag of tags) {
+ for (const tag of finalTags) {
const cleanTag = tag.trim().replace(/^#/, '');
if (!cleanTag) continue;
const tagObj = this.createTag(userId, cleanTag);
@@ -639,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,
@@ -653,7 +681,8 @@ export class FinanceService {
merchantName || null,
categoryId || null,
notes || null,
- status
+ status,
+ loanId || null
);
// Save splits if provided
@@ -668,13 +697,19 @@ export class FinanceService {
}
}
- // Save tags
- if (tags.length > 0) {
+ // Save tags (user provided + extracted from notes)
+ const finalTags: string[] = Array.isArray(tags) ? [...tags] : [];
+ if (notes) {
+ const matches = (notes.match(/#[a-zA-Z0-9_\-]+/g) || []).map((t: string) => t.replace(/^#/, ''));
+ finalTags.push(...matches);
+ }
+
+ if (finalTags.length > 0) {
const linkInsert = this.db.prepare(`
INSERT OR IGNORE INTO transaction_tags (transaction_id, tag_id) VALUES (?, ?)
`);
- for (const tag of tags) {
+ for (const tag of finalTags) {
const cleanTag = tag.trim().replace(/^#/, '');
if (!cleanTag) continue;
const tagObj = this.createTag(userId, cleanTag);
@@ -787,16 +822,39 @@ export class FinanceService {
`).run(newDestAccountId, newAmount, newDate, newNotes, newAccountId, newStatus, inLeg.id);
}
- // Update tags if provided for transfer legs
- if (data.tags !== undefined) {
- const txIds = [outLeg?.id, inLeg?.id].filter(Boolean);
+ // Update tags for transfer legs
+ let tagsToApply = data.tags !== undefined ? [...data.tags] : undefined;
+ if (tagsToApply !== undefined && newNotes) {
+ const matches = (newNotes.match(/#[a-zA-Z0-9_\-]+/g) || []).map((t: string) => t.replace(/^#/, ''));
+ tagsToApply.push(...matches);
+ }
+
+ const destAcc = this.getAccountById(newDestAccountId, userId);
+ const srcAcc = this.getAccountById(newAccountId, userId);
+ const isCcRepayment =
+ (destAcc && destAcc.type === 'credit_card') ||
+ (srcAcc && srcAcc.type === 'credit_card') ||
+ /cc[-_\s]?repay|credit\s*card\s*repay|credit\s*card\s*bill|cc\s*bill|card\s*payment/i.test(newNotes || '');
+
+ if (tagsToApply !== undefined && isCcRepayment) {
+ const hasCcTag = tagsToApply.some(t => {
+ const c = t.trim().replace(/^#/, '').toLowerCase();
+ return c === 'cc-repayment' || c === 'cc_repayment' || c === 'ccrepayment';
+ });
+ if (!hasCcTag) {
+ tagsToApply.push('cc-repayment');
+ }
+ }
+
+ const txIds = [outLeg?.id, inLeg?.id].filter(Boolean);
+ if (tagsToApply !== undefined) {
for (const tid of txIds) {
this.db.prepare('DELETE FROM transaction_tags WHERE transaction_id = ?').run(tid);
- if (data.tags.length > 0) {
+ if (tagsToApply.length > 0) {
const linkInsert = this.db.prepare(`
INSERT OR IGNORE INTO transaction_tags (transaction_id, tag_id) VALUES (?, ?)
`);
- for (const tag of data.tags) {
+ for (const tag of tagsToApply) {
const cleanTag = tag.trim().replace(/^#/, '');
if (!cleanTag) continue;
const tagObj = this.createTag(userId, cleanTag);
@@ -804,6 +862,14 @@ export class FinanceService {
}
}
}
+ } else if (isCcRepayment) {
+ const tagObj = this.createTag(userId, 'cc-repayment');
+ const linkInsert = this.db.prepare(`
+ INSERT OR IGNORE INTO transaction_tags (transaction_id, tag_id) VALUES (?, ?)
+ `);
+ for (const tid of txIds) {
+ linkInsert.run(tid, tagObj.id);
+ }
}
oldAccountsToRecalc.add(newAccountId);
@@ -856,12 +922,18 @@ export class FinanceService {
// Update tags if provided
if (data.tags !== undefined) {
+ const expenseTags = [...data.tags];
+ if (newNotes) {
+ const matches = (newNotes.match(/#[a-zA-Z0-9_\-]+/g) || []).map((t: string) => t.replace(/^#/, ''));
+ expenseTags.push(...matches);
+ }
+
this.db.prepare('DELETE FROM transaction_tags WHERE transaction_id = ?').run(id);
- if (data.tags.length > 0) {
+ if (expenseTags.length > 0) {
const linkInsert = this.db.prepare(`
INSERT OR IGNORE INTO transaction_tags (transaction_id, tag_id) VALUES (?, ?)
`);
- for (const tag of data.tags) {
+ for (const tag of expenseTags) {
const cleanTag = tag.trim().replace(/^#/, '');
if (!cleanTag) continue;
const tagObj = this.createTag(userId, cleanTag);
@@ -1045,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 = ?)
@@ -1083,6 +1201,10 @@ export class FinanceService {
this.recalculateAccountBalance(a.account_id);
}
+ for (const loanId of loanIdsToRecalc) {
+ this.recalculateLoanBalance(loanId, userId);
+ }
+
return { restored: true };
}
@@ -1196,7 +1318,7 @@ export class FinanceService {
principalComponent = remainingPrincipal;
emi = principalComponent + interestComponent;
}
- remainingPrincipal -= principalComponent;
+ remainingPrincipal = Math.max(0, remainingPrincipal - principalComponent);
totalInterest += interestComponent;
const dateObj = new Date(year, month - 1 + i, 1);
@@ -1212,13 +1334,22 @@ export class FinanceService {
});
}
+ const totalRepayment = principal + totalInterest;
+
+ // Remaining balance after each installment, subtracted from Principal + Net Interest
+ let cumulativePaid = 0;
+ for (const entry of schedule) {
+ cumulativePaid += entry.emi;
+ (entry as any).remainingBalance = Math.max(0, totalRepayment - cumulativePaid);
+ }
+
return {
principal,
annualInterestRate,
tenureMonths,
monthlyEmi: emi,
totalInterest,
- totalRepayment: principal + totalInterest,
+ totalRepayment,
schedule,
};
}
@@ -1778,11 +1909,30 @@ 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
- ? Math.round((paidPrincipal / loan.principal) * 100)
- : 0;
const amortization = this.calculateLoanAmortization(
loan.principal,
loan.interest_rate,
@@ -1790,13 +1940,42 @@ 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;
+
+ 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 || '',
+ totalPayable,
+ totalInterest: amortization.totalInterest,
+ totalPaid,
+ remainingBalance,
paidPrincipal,
progressPercent,
- amortization,
+ paidCount,
+ amortization: {
+ ...amortization,
+ schedule: scheduleWithStatus,
+ },
};
});
}
@@ -1820,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,
@@ -1830,6 +2009,7 @@ export class FinanceService {
data.name,
data.principal,
data.outstandingPrincipal,
+ data.outstandingPrincipal || data.principal,
data.interestRate,
amort.monthlyEmi,
data.tenureMonths,
@@ -1846,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 {}
@@ -1860,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;
@@ -1879,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,
@@ -2063,17 +2335,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 +2349,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;
+ // Total of tagged expenses only
+ const totalTaggedExpense = taggedRows.reduce((sum: number, r: any) => sum + r.total, 0);
- 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);
- }
-
- 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,
}));
}
diff --git a/tests/finance-service.test.ts b/tests/finance-service.test.ts
index 029bee8..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', () => {
@@ -596,6 +640,47 @@ describe('Finance Domain Business Logic Tests', () => {
}
});
+ it('automatically attaches cc-repayment tag to credit card transfers without explicit tags', () => {
+ const sbi = service.createAccount({
+ userId,
+ name: 'SBI Bank',
+ type: 'savings',
+ openingBalance: 10000000,
+ })!;
+
+ const card = service.createAccount({
+ userId,
+ name: 'Axis Bank Credit Card',
+ type: 'credit_card',
+ openingBalance: 0,
+ creditLimit: 5000000,
+ })!;
+
+ // Create transfer to credit card with NO explicit tags passed
+ const transfer = service.createTransaction({
+ userId,
+ accountId: sbi.id,
+ destinationAccountId: card.id,
+ type: 'transfer',
+ amount: 1500000,
+ date: '2026-03-20',
+ notes: 'Monthly Bill payment #march-bill',
+ });
+
+ expect(transfer.type).toBe('transfer');
+
+ // Check that both legs got auto-attached 'cc-repayment' AND '#march-bill' from notes
+ const txList = service.getTransactions(userId, { accountId: card.id });
+ expect(txList.length).toBe(1);
+ expect(txList[0].tags).toContain('cc-repayment');
+ expect(txList[0].tags).toContain('march-bill');
+
+ const sbiTxList = service.getTransactions(userId, { accountId: sbi.id });
+ expect(sbiTxList.length).toBe(1);
+ expect(sbiTxList[0].tags).toContain('cc-repayment');
+ expect(sbiTxList[0].tags).toContain('march-bill');
+ });
+
it('supports updating tag color, renaming, and merging duplicates safely', () => {
const bank = service.createAccount({
userId,