feat(transactions): inline transfer icon + FK error message

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>
This commit is contained in:
le king fu 2026-04-25 16:38:46 -04:00
parent a45e5c3cd0
commit 0e996a5aa1
4 changed files with 196 additions and 7 deletions

View file

@ -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<void>;
onDeleteSplit: (parentId: number) => Promise<void>;
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<number, LinkedTransferTooltipRow[]>;
}
function SortIcon({
@ -52,6 +61,7 @@ export default function TransactionTable({
onSaveSplit,
onDeleteSplit,
onRowContextMenu,
linkedTransfersByTxId,
}: TransactionTableProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null);
@ -141,8 +151,31 @@ export default function TransactionTable({
className="hover:bg-[var(--muted)] transition-colors"
>
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-3 py-2 max-w-xs truncate" title={row.description}>
{row.description}
<td className="px-3 py-2 max-w-xs">
<div className="flex items-center gap-1.5">
<span className="truncate" title={row.description}>
{row.description}
</span>
{linkedTransfersByTxId?.has(row.id) && (
<span
className="inline-flex items-center text-[var(--primary)] shrink-0"
title={
// Build a human-readable list: "TFSA (in), RRSP (out)".
(() => {
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")}
>
<Link2 size={12} />
</span>
)}
</div>
</td>
<td
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Wand2, Tag } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
@ -9,6 +9,10 @@ import TransactionTable from "../components/transactions/TransactionTable";
import TransactionPagination from "../components/transactions/TransactionPagination";
import ContextMenu from "../components/shared/ContextMenu";
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import {
listAllLinkedTransfersForTooltip,
type LinkedTransferTooltipRow,
} from "../services/balance.service";
import type { TransactionRow } from "../shared/types";
export default function TransactionsPage() {
@ -18,6 +22,18 @@ export default function TransactionsPage() {
const [resultMessage, setResultMessage] = useState<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
const [pending, setPending] = useState<TransactionRow | null>(null);
// Issue #142 — single batch lookup for the inlined transfer icon. One
// SELECT on mount gives us a Map<txId, links[]> the table consults via
// `.has()` per row. Avoids an N+1 hit on the rendered page.
const [linkedTransfersByTxId, setLinkedTransfersByTxId] = useState<
Map<number, LinkedTransferTooltipRow[]>
>(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}
/>
<TransactionPagination

View file

@ -1,4 +1,6 @@
import { getDb } from "./db";
import { isLinkedTransactionFkError } from "./balance.service";
import { TransactionLinkedToBalanceError } from "./transactionService";
import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
export async function getFilesBySourceId(
@ -94,10 +96,39 @@ export async function deleteImportWithTransactions(
);
const sourceId = files.length > 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<number> {
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;

View file

@ -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<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;