Issue #142 / Bilan #4 — non-regressive transfer awareness in the transactions table + clean error mapping on bulk delete. - `TransactionTable.tsx`: optional new prop `linkedTransfersByTxId?: Map<txId, links[]>`. When supplied, a small `<Link2>` icon appears next to the description for every linked transaction; tooltip lists the account name(s) and direction(s). Without the prop, the table renders byte-for-byte identical to before — preserves the spec's non-regression invariant. - `TransactionsPage.tsx`: loads the linked-transfers map once on mount via `listAllLinkedTransfersForTooltip()` (one batch SELECT) and threads it through to the table. Failure to load the map degrades gracefully to an empty map (icon simply doesn't appear). - `transactionService.ts`: new `deleteTransaction(id)` helper + `TransactionLinkedToBalanceError` (typed FK guard). Pre-checks `balance_account_transfers` before attempting the DELETE so the error carries the offending account names; falls back to the FK pattern matcher if a race linked the transaction between the SELECT and the DELETE. - `importedFileService.ts`: both bulk delete paths (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`) now pre-check for any linked transfer and surface the same typed error before they would explode on FK RESTRICT. The pre-check has a `LIMIT 50` safety cap on the global path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
458 lines
13 KiB
TypeScript
458 lines
13 KiB
TypeScript
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<void> {
|
|
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<number> {
|
|
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<Transaction[]>(
|
|
`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<TransactionPageResult> {
|
|
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<string, string> = {
|
|
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<TransactionRow[]>(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<void> {
|
|
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<void> {
|
|
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<Category[]> {
|
|
const db = await getDb();
|
|
return db.select<Category[]>(
|
|
`SELECT * FROM categories WHERE is_active = 1 AND is_inputable = 1 ORDER BY sort_order, name`
|
|
);
|
|
}
|
|
|
|
export async function getAllImportSources(): Promise<ImportSource[]> {
|
|
const db = await getDb();
|
|
return db.select<ImportSource[]>(
|
|
`SELECT * FROM import_sources ORDER BY name`
|
|
);
|
|
}
|
|
|
|
export async function autoCategorizeTransactions(): Promise<number> {
|
|
const db = await getDb();
|
|
const uncategorized = await db.select<Array<{ id: number; description: string }>>(
|
|
`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<TransactionRow[]> {
|
|
const db = await getDb();
|
|
return db.select<TransactionRow[]>(
|
|
`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<SplitChild[]> {
|
|
const db = await getDb();
|
|
return db.select<SplitChild[]>(
|
|
`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<void> {
|
|
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<Transaction[]>(
|
|
`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<void> {
|
|
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]
|
|
);
|
|
}
|