From 0e996a5aa11b2f86e97436187bc61da3acc4874b Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:38:46 -0400 Subject: [PATCH] feat(transactions): inline transfer icon + FK error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. When supplied, a small `` 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) --- .../transactions/TransactionTable.tsx | 39 ++++++++- src/pages/TransactionsPage.tsx | 19 ++++- src/services/importedFileService.ts | 62 +++++++++++++- src/services/transactionService.ts | 83 +++++++++++++++++++ 4 files changed, 196 insertions(+), 7 deletions(-) diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index 43705df..74b1263 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -1,12 +1,13 @@ import { Fragment, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react"; +import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react"; import type { TransactionRow, TransactionSort, Category, SplitChild, } from "../../shared/types"; +import type { LinkedTransferTooltipRow } from "../../services/balance.service"; import CategoryCombobox from "../shared/CategoryCombobox"; import SplitAdjustmentModal from "./SplitAdjustmentModal"; @@ -22,6 +23,14 @@ interface TransactionTableProps { onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise; onDeleteSplit: (parentId: number) => Promise; onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void; + /** + * Issue #142 — when supplied, a small Link2 icon appears next to the + * description for every transaction whose id is a key in the map. The + * icon's tooltip lists the linked accounts. The lookup is intentionally + * done by the parent (one batch SELECT, in-memory `.has()` thereafter) + * to avoid an N+1 hit on the table render. + */ + linkedTransfersByTxId?: Map; } function SortIcon({ @@ -52,6 +61,7 @@ export default function TransactionTable({ onSaveSplit, onDeleteSplit, onRowContextMenu, + linkedTransfersByTxId, }: TransactionTableProps) { const { t } = useTranslation(); const [expandedId, setExpandedId] = useState(null); @@ -141,8 +151,31 @@ export default function TransactionTable({ className="hover:bg-[var(--muted)] transition-colors" > {row.date} - - {row.description} + +
+ + {row.description} + + {linkedTransfersByTxId?.has(row.id) && ( + { + const links = linkedTransfersByTxId.get(row.id) ?? []; + const parts = links.map( + (l) => + `${l.account_name} (${t(`balance.transfers.direction.${l.direction}`)})` + ); + return `${t("transactions.transferIcon.tooltip")}: ${parts.join(", ")}`; + })() + } + aria-label={t("transactions.transferIcon.ariaLabel")} + > + + + )} +
(null); const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null); const [pending, setPending] = useState(null); + // Issue #142 — single batch lookup for the inlined transfer icon. One + // SELECT on mount gives us a Map the table consults via + // `.has()` per row. Avoids an N+1 hit on the rendered page. + const [linkedTransfersByTxId, setLinkedTransfersByTxId] = useState< + Map + >(new Map()); + + useEffect(() => { + listAllLinkedTransfersForTooltip() + .then(setLinkedTransfersByTxId) + .catch(() => setLinkedTransfersByTxId(new Map())); + }, []); const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => { e.preventDefault(); @@ -95,6 +111,7 @@ export default function TransactionsPage() { onSaveSplit={saveSplit} onDeleteSplit={deleteSplit} onRowContextMenu={handleRowContextMenu} + linkedTransfersByTxId={linkedTransfersByTxId} /> 0 ? files[0].source_id : null; - const result = await db.execute( - "DELETE FROM transactions WHERE file_id = $1", + // Pre-flight: if any transaction in this file is linked to a balance + // account via `balance_account_transfers`, the FK RESTRICT will fire on + // the bulk DELETE. Surface a typed error BEFORE touching the row so the + // UI can prompt the user to unlink first (Issue #142). + const linked = await db.select< + Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }> + >( + `SELECT bat.transaction_id AS transaction_id, + bat.account_id AS account_id, + a.name AS account_name, + bat.direction AS direction + FROM balance_account_transfers bat + JOIN transactions t ON t.id = bat.transaction_id + JOIN balance_accounts a ON a.id = bat.account_id + WHERE t.file_id = $1`, [fileId] ); + if (linked.length > 0) { + throw new TransactionLinkedToBalanceError(null, linked); + } + + let result; + try { + result = await db.execute( + "DELETE FROM transactions WHERE file_id = $1", + [fileId] + ); + } catch (err) { + if (isLinkedTransactionFkError(err)) { + throw new TransactionLinkedToBalanceError(null, []); + } + throw err; + } await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]); // Clean up orphaned source if no files remain @@ -116,7 +147,32 @@ export async function deleteImportWithTransactions( export async function deleteAllImportsWithTransactions(): Promise { const db = await getDb(); - const result = await db.execute("DELETE FROM transactions"); + // Same pre-flight as the per-file path: if ANY transaction is linked to + // a balance account, the bulk wipe would explode on FK RESTRICT — surface + // a typed error so the UI can prompt the user to unlink first. + const linked = await db.select< + Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }> + >( + `SELECT bat.transaction_id AS transaction_id, + 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 + LIMIT 50` + ); + if (linked.length > 0) { + throw new TransactionLinkedToBalanceError(null, linked); + } + let result; + try { + result = await db.execute("DELETE FROM transactions"); + } catch (err) { + if (isLinkedTransactionFkError(err)) { + throw new TransactionLinkedToBalanceError(null, []); + } + throw err; + } await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM import_sources"); return result.rowsAffected; diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 518830a..34e9ca8 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -1,5 +1,9 @@ import { getDb } from "./db"; import { categorizeBatch } from "./categorizationService"; +import { + isLinkedTransactionFkError, + type LinkedTransferTooltipRow, +} from "./balance.service"; import type { Transaction, TransactionRow, @@ -11,6 +15,85 @@ import type { 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;