import { getDb } from "./db"; import { categorizeBatch } from "./categorizationService"; import { isLinkedTransactionFkError, type LinkedTransferTooltipRow, } from "./balance.service"; import type { Transaction, TransactionRow, TransactionFilters, TransactionSort, TransactionPageResult, Category, ImportSource, SplitChild, } from "../shared/types"; /** * Thrown when a deletion path is blocked by `balance_account_transfers.transaction_id` * FK RESTRICT. Carries the offending `transaction_id`(s) so the UI can format a * precise message ("Cette transaction est liée au compte de bilan X — déliez-la * avant de supprimer") and can offer a deep link to the linked account. * * `linkedAccounts` is best-effort: when known (single-row delete) the array * lists every account currently linking the transaction. For bulk deletes * the array may be empty — the UI just shows the generic message in that * case. */ export class TransactionLinkedToBalanceError extends Error { readonly code = "transaction_linked_to_balance_account" as const; readonly transactionId: number | null; readonly linkedAccounts: LinkedTransferTooltipRow[]; constructor( transactionId: number | null, linkedAccounts: LinkedTransferTooltipRow[], message?: string ) { super( message ?? "Transaction is linked to one or more balance accounts; unlink before deleting" ); this.name = "TransactionLinkedToBalanceError"; this.transactionId = transactionId; this.linkedAccounts = linkedAccounts; } } /** * Delete one transaction by id. Throws `TransactionLinkedToBalanceError` if * the transaction has any row in `balance_account_transfers` (FK RESTRICT) * — UI surfaces "Cette transaction est liée au compte de bilan X — déliez-la * avant de supprimer" with a link to the offending account. * * Pre-checks the link table so the error carries account names; falls back * to the FK-error pattern matcher if the constraint fires for any other * reason. */ export async function deleteTransaction(transactionId: number): Promise { const db = await getDb(); // Pre-check: if any transfer references this transaction, surface a clean // typed error WITHOUT touching the row. Cheaper than catching the FK // exception and provides the account names for the UI message. const linked = await db.select< Array<{ account_id: number; account_name: string; direction: "in" | "out" }> >( `SELECT bat.account_id AS account_id, a.name AS account_name, bat.direction AS direction FROM balance_account_transfers bat JOIN balance_accounts a ON a.id = bat.account_id WHERE bat.transaction_id = $1`, [transactionId] ); if (linked.length > 0) { throw new TransactionLinkedToBalanceError( transactionId, linked.map((l) => ({ transaction_id: transactionId, account_id: l.account_id, account_name: l.account_name, direction: l.direction, })) ); } try { await db.execute("DELETE FROM transactions WHERE id = $1", [transactionId]); } catch (err) { // Defensive: a race could have linked the transaction between the // SELECT and the DELETE. Surface the typed error in that case too. if (isLinkedTransactionFkError(err)) { throw new TransactionLinkedToBalanceError(transactionId, []); } throw err; } } export async function insertBatch( transactions: Array<{ date: string; description: string; amount: number; source_id: number; file_id: number; original_description: string; category_id?: number | null; supplier_id?: number | null; }>, onProgress?: (inserted: number) => void ): Promise { const db = await getDb(); let insertedCount = 0; for (const tx of transactions) { await db.execute( `INSERT INTO transactions (date, description, amount, source_id, file_id, original_description, category_id, supplier_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ tx.date, tx.description, tx.amount, tx.source_id, tx.file_id, tx.original_description, tx.category_id ?? null, tx.supplier_id ?? null, ] ); insertedCount++; if (onProgress && insertedCount % 10 === 0) { onProgress(insertedCount); } } if (onProgress && insertedCount % 10 !== 0) { onProgress(insertedCount); } return insertedCount; } export async function findDuplicates( rows: Array<{ date: string; description: string; amount: number }> ): Promise< Array<{ rowIndex: number; existingTransactionId: number; date: string; description: string; amount: number; }> > { const db = await getDb(); const duplicates: Array<{ rowIndex: number; existingTransactionId: number; date: string; description: string; amount: number; }> = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const existing = await db.select( `SELECT id FROM transactions WHERE date = $1 AND description = $2 AND amount = $3 LIMIT 1`, [row.date, row.description, row.amount] ); if (existing.length > 0) { duplicates.push({ rowIndex: i, existingTransactionId: existing[0].id, date: row.date, description: row.description, amount: row.amount, }); } } return duplicates; } export async function getTransactionPage( filters: TransactionFilters, sort: TransactionSort, page: number, pageSize: number ): Promise { const db = await getDb(); const whereClauses: string[] = []; const params: unknown[] = []; let paramIndex = 1; if (filters.search) { whereClauses.push(`t.description LIKE $${paramIndex}`); params.push(`%${filters.search}%`); paramIndex++; } if (filters.uncategorizedOnly) { whereClauses.push(`t.category_id IS NULL`); } else if (filters.categoryId !== null) { whereClauses.push(`t.category_id = $${paramIndex}`); params.push(filters.categoryId); paramIndex++; } if (filters.sourceId !== null) { whereClauses.push(`t.source_id = $${paramIndex}`); params.push(filters.sourceId); paramIndex++; } if (filters.dateFrom) { whereClauses.push(`t.date >= $${paramIndex}`); params.push(filters.dateFrom); paramIndex++; } if (filters.dateTo) { whereClauses.push(`t.date <= $${paramIndex}`); params.push(filters.dateTo); paramIndex++; } // Always exclude split children from the transaction list whereClauses.push(`t.parent_transaction_id IS NULL`); const whereSQL = `WHERE ${whereClauses.join(" AND ")}`; // Map sort column to SQL const sortColumnMap: Record = { date: "t.date", description: "t.description", amount: "t.amount", category_name: "c.name", }; const orderSQL = `ORDER BY ${sortColumnMap[sort.column]} ${sort.direction}`; const offset = (page - 1) * pageSize; // Rows query const rowsSQL = ` SELECT t.id, t.date, t.description, t.amount, t.category_id, c.name AS category_name, c.color AS category_color, s.name AS source_name, t.notes, t.is_manually_categorized, t.is_split FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN import_sources s ON t.source_id = s.id ${whereSQL} ${orderSQL} LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; const rowsParams = [...params, pageSize, offset]; // Totals query const totalsSQL = ` SELECT COUNT(*) AS totalCount, COALESCE(SUM(t.amount), 0) AS totalAmount, COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0) AS incomeTotal, COALESCE(SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END), 0) AS expenseTotal FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN import_sources s ON t.source_id = s.id ${whereSQL} `; const [rows, totals] = await Promise.all([ db.select(rowsSQL, rowsParams), db.select< Array<{ totalCount: number; totalAmount: number; incomeTotal: number; expenseTotal: number; }> >(totalsSQL, params), ]); const t = totals[0] ?? { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0, }; return { rows, totalCount: t.totalCount, totalAmount: t.totalAmount, incomeTotal: t.incomeTotal, expenseTotal: t.expenseTotal, }; } export async function updateTransactionCategory( txId: number, categoryId: number | null, isManual: boolean ): Promise { const db = await getDb(); await db.execute( `UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, [categoryId, isManual, txId] ); } export async function updateTransactionNotes( txId: number, notes: string ): Promise { const db = await getDb(); await db.execute( `UPDATE transactions SET notes = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [notes, txId] ); } export async function getAllCategories(): Promise { const db = await getDb(); return db.select( `SELECT * FROM categories WHERE is_active = 1 AND is_inputable = 1 ORDER BY sort_order, name` ); } export async function getAllImportSources(): Promise { const db = await getDb(); return db.select( `SELECT * FROM import_sources ORDER BY name` ); } export async function autoCategorizeTransactions(): Promise { const db = await getDb(); const uncategorized = await db.select>( `SELECT id, description FROM transactions WHERE category_id IS NULL AND is_manually_categorized = 0` ); if (uncategorized.length === 0) return 0; const results = await categorizeBatch(uncategorized.map((tx) => tx.description)); let count = 0; for (let i = 0; i < uncategorized.length; i++) { const result = results[i]; if (result.category_id !== null) { await db.execute( `UPDATE transactions SET category_id = $1, supplier_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, [result.category_id, result.supplier_id, uncategorized[i].id] ); count++; } } return count; } export async function getSplitParentTransactions(): Promise { const db = await getDb(); return db.select( `SELECT t.id, t.date, t.description, t.amount, t.category_id, c.name AS category_name, c.color AS category_color, s.name AS source_name, t.notes, t.is_manually_categorized, t.is_split FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN import_sources s ON t.source_id = s.id WHERE t.is_split = 1 AND t.parent_transaction_id IS NULL ORDER BY t.date DESC` ); } export async function getSplitChildren(parentId: number): Promise { const db = await getDb(); return db.select( `SELECT t.id, t.category_id, c.name AS category_name, c.color AS category_color, t.amount, t.description FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE t.parent_transaction_id = $1 ORDER BY t.id`, [parentId] ); } export async function saveSplitAdjustment( parentId: number, entries: Array<{ category_id: number; amount: number; description: string }> ): Promise { const db = await getDb(); // Delete any existing children await db.execute( `DELETE FROM transactions WHERE parent_transaction_id = $1`, [parentId] ); // Fetch parent transaction const [parent] = await db.select( `SELECT * FROM transactions WHERE id = $1`, [parentId] ); if (!parent) throw new Error("Parent transaction not found"); // Insert each split child let offsetTotal = 0; for (const entry of entries) { await db.execute( `INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, original_description, parent_transaction_id, is_split) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1)`, [ parent.date, entry.description, entry.amount, entry.category_id, parent.source_id ?? null, parent.file_id ?? null, parent.original_description ?? "", parentId, ] ); offsetTotal += entry.amount; } // Insert offset child (cancels the redistributed portion from the original category) await db.execute( `INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, original_description, parent_transaction_id, is_split) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1)`, [ parent.date, parent.description, -offsetTotal, parent.category_id ?? null, parent.source_id ?? null, parent.file_id ?? null, parent.original_description ?? "", parentId, ] ); // Mark parent as split await db.execute( `UPDATE transactions SET is_split = 1, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [parentId] ); } export async function deleteSplitAdjustment(parentId: number): Promise { const db = await getDb(); await db.execute( `DELETE FROM transactions WHERE parent_transaction_id = $1`, [parentId] ); await db.execute( `UPDATE transactions SET is_split = 0, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [parentId] ); }