This commit is contained in:
Arkaprabha Chakraborty
2026-02-18 13:16:51 +05:30
commit 53742d0134
102 changed files with 22090 additions and 0 deletions

167
src/db/database.ts Normal file
View File

@@ -0,0 +1,167 @@
import SQLite, {
SQLiteDatabase,
Transaction as SQLTransaction,
ResultSet,
} from 'react-native-sqlite-storage';
// Enable promise-based API
SQLite.enablePromise(true);
const DATABASE_NAME = 'expensso.db';
const DATABASE_VERSION = '1.0';
const DATABASE_DISPLAY_NAME = 'Expensso Database';
const DATABASE_SIZE = 200000;
let db: SQLiteDatabase | null = null;
// ─── Open / Get Database ─────────────────────────────────────────────
export async function getDatabase(): Promise<SQLiteDatabase> {
if (db) {
return db;
}
db = await SQLite.openDatabase({
name: DATABASE_NAME,
location: 'default',
});
await createTables(db);
return db;
}
// ─── Schema Creation ─────────────────────────────────────────────────
async function createTables(database: SQLiteDatabase): Promise<void> {
await database.transaction(async (tx: SQLTransaction) => {
// Categories table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'dots-horizontal',
color TEXT NOT NULL DEFAULT '#95A5A6',
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Transactions table (the ledger)
tx.executeSql(`
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'INR',
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
category_id TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'UPI',
note TEXT DEFAULT '',
date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
`);
// Transaction impacts table (links transactions to net-worth entries)
tx.executeSql(`
CREATE TABLE IF NOT EXISTS transaction_impacts (
transaction_id TEXT PRIMARY KEY,
target_type TEXT NOT NULL CHECK(target_type IN ('asset', 'liability')),
target_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK(operation IN ('add', 'subtract')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
);
`);
// Assets table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
current_value REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
note TEXT DEFAULT '',
last_updated TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Liabilities table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS liabilities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
outstanding_amount REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
interest_rate REAL NOT NULL DEFAULT 0,
emi_amount REAL NOT NULL DEFAULT 0,
note TEXT DEFAULT '',
last_updated TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Net-worth snapshots for historical tracking
tx.executeSql(`
CREATE TABLE IF NOT EXISTS net_worth_snapshots (
id TEXT PRIMARY KEY,
total_assets REAL NOT NULL DEFAULT 0,
total_liabilities REAL NOT NULL DEFAULT 0,
net_worth REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
snapshot_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Indexes for performance
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(type);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transaction_impacts_target ON transaction_impacts(target_type, target_id);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON net_worth_snapshots(snapshot_date);
`);
});
}
// ─── Generic Helpers ─────────────────────────────────────────────────
export async function executeSql(
sql: string,
params: any[] = [],
): Promise<ResultSet> {
const database = await getDatabase();
const [result] = await database.executeSql(sql, params);
return result;
}
export function rowsToArray<T>(result: ResultSet): T[] {
const rows: T[] = [];
for (let i = 0; i < result.rows.length; i++) {
rows.push(result.rows.item(i) as T);
}
return rows;
}
// ─── Close Database ──────────────────────────────────────────────────
export async function closeDatabase(): Promise<void> {
if (db) {
await db.close();
db = null;
}
}

2
src/db/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export {getDatabase, executeSql, rowsToArray, closeDatabase} from './database';
export * from './queries';

445
src/db/queries.ts Normal file
View File

@@ -0,0 +1,445 @@
import {executeSql, rowsToArray} from './database';
import {
Category,
Transaction,
Asset,
Liability,
NetWorthSnapshot,
TransactionImpact,
NetWorthTargetType,
ImpactOperation,
} from '../types';
import {generateId} from '../utils';
import {DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES} from '../constants';
// ═══════════════════════════════════════════════════════════════════════
// CATEGORIES
// ═══════════════════════════════════════════════════════════════════════
export async function seedDefaultCategories(): Promise<void> {
const result = await executeSql('SELECT COUNT(*) as count FROM categories');
const count = result.rows.item(0).count;
if (count > 0) {return;}
const allCategories = [...DEFAULT_EXPENSE_CATEGORIES, ...DEFAULT_INCOME_CATEGORIES];
for (const cat of allCategories) {
await executeSql(
'INSERT INTO categories (id, name, icon, color, type, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[generateId(), cat.name, cat.icon, cat.color, cat.type, cat.isDefault ? 1 : 0],
);
}
}
export async function getCategories(type?: 'income' | 'expense'): Promise<Category[]> {
let sql = 'SELECT id, name, icon, color, type, is_default as isDefault, created_at as createdAt FROM categories';
const params: any[] = [];
if (type) {
sql += ' WHERE type = ?';
params.push(type);
}
sql += ' ORDER BY is_default DESC, name ASC';
const result = await executeSql(sql, params);
return rowsToArray<Category>(result);
}
export async function insertCategory(cat: Omit<Category, 'id' | 'createdAt'>): Promise<string> {
const id = generateId();
await executeSql(
'INSERT INTO categories (id, name, icon, color, type, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[id, cat.name, cat.icon, cat.color, cat.type, cat.isDefault ? 1 : 0],
);
return id;
}
// ═══════════════════════════════════════════════════════════════════════
// TRANSACTIONS
// ═══════════════════════════════════════════════════════════════════════
export async function getTransactions(options?: {
type?: 'income' | 'expense';
fromDate?: string;
toDate?: string;
categoryId?: string;
limit?: number;
offset?: number;
}): Promise<Transaction[]> {
let sql = `
SELECT
t.id, t.amount, t.currency, t.type, t.category_id as categoryId,
t.payment_method as paymentMethod, t.note, t.date,
t.created_at as createdAt, t.updated_at as updatedAt,
c.name as categoryName, c.icon as categoryIcon, c.color as categoryColor
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE 1=1
`;
const params: any[] = [];
if (options?.type) {
sql += ' AND t.type = ?';
params.push(options.type);
}
if (options?.fromDate) {
sql += ' AND t.date >= ?';
params.push(options.fromDate);
}
if (options?.toDate) {
sql += ' AND t.date <= ?';
params.push(options.toDate);
}
if (options?.categoryId) {
sql += ' AND t.category_id = ?';
params.push(options.categoryId);
}
sql += ' ORDER BY t.date DESC, t.created_at DESC';
if (options?.limit) {
sql += ' LIMIT ?';
params.push(options.limit);
}
if (options?.offset) {
sql += ' OFFSET ?';
params.push(options.offset);
}
const result = await executeSql(sql, params);
return rowsToArray<Transaction>(result);
}
export async function getTransactionById(id: string): Promise<Transaction | null> {
const result = await executeSql(
`SELECT
t.id, t.amount, t.currency, t.type, t.category_id as categoryId,
t.payment_method as paymentMethod, t.note, t.date,
t.created_at as createdAt, t.updated_at as updatedAt,
c.name as categoryName, c.icon as categoryIcon, c.color as categoryColor
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.id = ?`,
[id],
);
if (result.rows.length === 0) {
return null;
}
return result.rows.item(0) as Transaction;
}
export async function insertTransaction(
txn: Omit<Transaction, 'id' | 'createdAt' | 'updatedAt' | 'categoryName' | 'categoryIcon' | 'categoryColor'>,
): Promise<string> {
const id = generateId();
await executeSql(
`INSERT INTO transactions (id, amount, currency, type, category_id, payment_method, note, date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[id, txn.amount, txn.currency, txn.type, txn.categoryId, txn.paymentMethod, txn.note, txn.date],
);
return id;
}
export async function updateTransaction(
id: string,
txn: Partial<Omit<Transaction, 'id' | 'createdAt'>>,
): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (txn.amount !== undefined) { fields.push('amount = ?'); params.push(txn.amount); }
if (txn.currency) { fields.push('currency = ?'); params.push(txn.currency); }
if (txn.type) { fields.push('type = ?'); params.push(txn.type); }
if (txn.categoryId) { fields.push('category_id = ?'); params.push(txn.categoryId); }
if (txn.paymentMethod) { fields.push('payment_method = ?'); params.push(txn.paymentMethod); }
if (txn.note !== undefined) { fields.push('note = ?'); params.push(txn.note); }
if (txn.date) { fields.push('date = ?'); params.push(txn.date); }
fields.push("updated_at = datetime('now')");
params.push(id);
await executeSql(`UPDATE transactions SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteTransaction(id: string): Promise<void> {
await executeSql('DELETE FROM transactions WHERE id = ?', [id]);
}
export async function saveTransactionImpact(input: TransactionImpact): Promise<void> {
await executeSql(
`INSERT OR REPLACE INTO transaction_impacts (transaction_id, target_type, target_id, operation)
VALUES (?, ?, ?, ?)`,
[input.transactionId, input.targetType, input.targetId, input.operation],
);
}
export async function getTransactionImpact(transactionId: string): Promise<TransactionImpact | null> {
const result = await executeSql(
`SELECT transaction_id as transactionId, target_type as targetType, target_id as targetId, operation
FROM transaction_impacts
WHERE transaction_id = ?`,
[transactionId],
);
if (result.rows.length === 0) {
return null;
}
return result.rows.item(0) as TransactionImpact;
}
export async function deleteTransactionImpact(transactionId: string): Promise<void> {
await executeSql('DELETE FROM transaction_impacts WHERE transaction_id = ?', [transactionId]);
}
export async function applyTargetImpact(
targetType: NetWorthTargetType,
targetId: string,
operation: ImpactOperation,
amount: number,
): Promise<void> {
if (amount <= 0) {
return;
}
if (targetType === 'asset') {
if (operation === 'add') {
await executeSql(
`UPDATE assets
SET current_value = current_value + ?,
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
await executeSql(
`UPDATE assets
SET current_value = MAX(current_value - ?, 0),
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
if (operation === 'add') {
await executeSql(
`UPDATE liabilities
SET outstanding_amount = outstanding_amount + ?,
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
await executeSql(
`UPDATE liabilities
SET outstanding_amount = MAX(outstanding_amount - ?, 0),
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
}
export async function reverseTargetImpact(impact: TransactionImpact, amount: number): Promise<void> {
const reverseOperation: ImpactOperation = impact.operation === 'add' ? 'subtract' : 'add';
await applyTargetImpact(impact.targetType, impact.targetId, reverseOperation, amount);
}
export async function getMonthlyTotals(
type: 'income' | 'expense',
year: number,
month: number,
): Promise<number> {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate =
month === 12
? `${year + 1}-01-01`
: `${year}-${String(month + 1).padStart(2, '0')}-01`;
const result = await executeSql(
'SELECT COALESCE(SUM(amount), 0) as total FROM transactions WHERE type = ? AND date >= ? AND date < ?',
[type, startDate, endDate],
);
return result.rows.item(0).total;
}
export async function getSpendingByCategory(
fromDate: string,
toDate: string,
): Promise<{categoryName: string; categoryColor: string; categoryIcon: string; total: number}[]> {
const result = await executeSql(
`SELECT c.name as categoryName, c.color as categoryColor, c.icon as categoryIcon,
SUM(t.amount) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.type = 'expense' AND t.date >= ? AND t.date < ?
GROUP BY t.category_id
ORDER BY total DESC`,
[fromDate, toDate],
);
return rowsToArray(result);
}
export async function getMonthlySpendingTrend(months: number = 6): Promise<{month: string; total: number}[]> {
const result = await executeSql(
`SELECT strftime('%Y-%m', date) as month, SUM(amount) as total
FROM transactions
WHERE type = 'expense'
AND date >= date('now', '-' || ? || ' months')
GROUP BY strftime('%Y-%m', date)
ORDER BY month ASC`,
[months],
);
return rowsToArray(result);
}
// ═══════════════════════════════════════════════════════════════════════
// ASSETS
// ═══════════════════════════════════════════════════════════════════════
export async function getAssets(): Promise<Asset[]> {
const result = await executeSql(
`SELECT id, name, type, current_value as currentValue, currency,
note, last_updated as lastUpdated, created_at as createdAt
FROM assets
ORDER BY current_value DESC`,
);
return rowsToArray<Asset>(result);
}
export async function insertAsset(asset: Omit<Asset, 'id' | 'createdAt' | 'lastUpdated'>): Promise<string> {
const id = generateId();
await executeSql(
'INSERT INTO assets (id, name, type, current_value, currency, note) VALUES (?, ?, ?, ?, ?, ?)',
[id, asset.name, asset.type, asset.currentValue, asset.currency, asset.note],
);
return id;
}
export async function updateAsset(id: string, asset: Partial<Omit<Asset, 'id' | 'createdAt'>>): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (asset.name) { fields.push('name = ?'); params.push(asset.name); }
if (asset.type) { fields.push('type = ?'); params.push(asset.type); }
if (asset.currentValue !== undefined) { fields.push('current_value = ?'); params.push(asset.currentValue); }
if (asset.currency) { fields.push('currency = ?'); params.push(asset.currency); }
if (asset.note !== undefined) { fields.push('note = ?'); params.push(asset.note); }
fields.push("last_updated = datetime('now')");
params.push(id);
await executeSql(`UPDATE assets SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteAsset(id: string): Promise<void> {
await executeSql('DELETE FROM assets WHERE id = ?', [id]);
}
export async function getTotalAssets(): Promise<number> {
const result = await executeSql('SELECT COALESCE(SUM(current_value), 0) as total FROM assets');
return result.rows.item(0).total;
}
// ═══════════════════════════════════════════════════════════════════════
// LIABILITIES
// ═══════════════════════════════════════════════════════════════════════
export async function getLiabilities(): Promise<Liability[]> {
const result = await executeSql(
`SELECT id, name, type, outstanding_amount as outstandingAmount, currency,
interest_rate as interestRate, emi_amount as emiAmount,
note, last_updated as lastUpdated, created_at as createdAt
FROM liabilities
ORDER BY outstanding_amount DESC`,
);
return rowsToArray<Liability>(result);
}
export async function insertLiability(
liability: Omit<Liability, 'id' | 'createdAt' | 'lastUpdated'>,
): Promise<string> {
const id = generateId();
await executeSql(
`INSERT INTO liabilities (id, name, type, outstanding_amount, currency, interest_rate, emi_amount, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
id, liability.name, liability.type, liability.outstandingAmount,
liability.currency, liability.interestRate, liability.emiAmount, liability.note,
],
);
return id;
}
export async function updateLiability(
id: string,
liability: Partial<Omit<Liability, 'id' | 'createdAt'>>,
): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (liability.name) { fields.push('name = ?'); params.push(liability.name); }
if (liability.type) { fields.push('type = ?'); params.push(liability.type); }
if (liability.outstandingAmount !== undefined) { fields.push('outstanding_amount = ?'); params.push(liability.outstandingAmount); }
if (liability.currency) { fields.push('currency = ?'); params.push(liability.currency); }
if (liability.interestRate !== undefined) { fields.push('interest_rate = ?'); params.push(liability.interestRate); }
if (liability.emiAmount !== undefined) { fields.push('emi_amount = ?'); params.push(liability.emiAmount); }
if (liability.note !== undefined) { fields.push('note = ?'); params.push(liability.note); }
fields.push("last_updated = datetime('now')");
params.push(id);
await executeSql(`UPDATE liabilities SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteLiability(id: string): Promise<void> {
await executeSql('DELETE FROM liabilities WHERE id = ?', [id]);
}
export async function getTotalLiabilities(): Promise<number> {
const result = await executeSql('SELECT COALESCE(SUM(outstanding_amount), 0) as total FROM liabilities');
return result.rows.item(0).total;
}
// ═══════════════════════════════════════════════════════════════════════
// NET WORTH SNAPSHOTS
// ═══════════════════════════════════════════════════════════════════════
export async function saveNetWorthSnapshot(
totalAssets: number,
totalLiabilities: number,
currency: string,
): Promise<string> {
const id = generateId();
const netWorth = totalAssets - totalLiabilities;
const today = new Date().toISOString().split('T')[0];
// Upsert: delete any existing snapshot for today then insert
await executeSql('DELETE FROM net_worth_snapshots WHERE snapshot_date = ?', [today]);
await executeSql(
`INSERT INTO net_worth_snapshots (id, total_assets, total_liabilities, net_worth, currency, snapshot_date)
VALUES (?, ?, ?, ?, ?, ?)`,
[id, totalAssets, totalLiabilities, netWorth, currency, today],
);
return id;
}
export async function getNetWorthHistory(months: number = 12): Promise<NetWorthSnapshot[]> {
const result = await executeSql(
`SELECT id, total_assets as totalAssets, total_liabilities as totalLiabilities,
net_worth as netWorth, currency, snapshot_date as snapshotDate,
created_at as createdAt
FROM net_worth_snapshots
WHERE snapshot_date >= date('now', '-' || ? || ' months')
ORDER BY snapshot_date ASC`,
[months],
);
return rowsToArray<NetWorthSnapshot>(result);
}