diff --git a/CLAUDE.md b/CLAUDE.md index 43411db..6292f9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,29 @@ COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merc COALESCE(o.category_override, t.category) -- category ``` +### Hiding categories in the transactions view + +`getTransactions` takes `exclude_categories`. It is **opt-in per caller and +never defaulted in `queries.ts`** — `GET /api/rules/[id]/matches` and +`POST /api/rules/apply` both read their candidate rows through `getTransactions`, +so a default exclusion there would silently shrink what a rule can preview and +reach. Only the transactions page sets it. + +The transactions view defaults it to `["transfers"]` (433 of 3,996 rows, ~11%), +with a visible "Hide transfers" checkbox. Two rules the implementation depends +on, both tested: + +- **An explicit category pick beats the exclusion.** Selecting "Transfers" while + the default is on subtracts it from the hidden list rather than returning zero + rows — otherwise the view reads "you have no transfers". +- **`COALESCE(..., '')` before `<> ALL`.** `NULL <> ALL(...)` is NULL, not true, + so an uncategorised row would vanish from a filter that never named its + category. Same trap `EXCLUDE_NON_SPEND` documents. + +It defaults **off** when the view is scoped to a statement (`?statement_id=`). +That is a reconciliation view — the row count has to match the statement, and a +credit-card payment is exactly the row you went there to check. + ## Database ```bash diff --git a/src/__tests__/integration/queries.test.ts b/src/__tests__/integration/queries.test.ts index ef8ea0e..6c99a6a 100644 --- a/src/__tests__/integration/queries.test.ts +++ b/src/__tests__/integration/queries.test.ts @@ -105,6 +105,63 @@ describe("getTransactions — category filter", () => { }); }); +describe("getTransactions — exclude_categories", () => { + it("hides the excluded category", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" }); + await insertTransaction(pool, ownerId, { description: "Card payment", category: "transfers" }); + + const { data, total } = await getTransactions(ownerId, { + exclude_categories: ["transfers"], limit: 50, offset: 0, + }); + expect(data).toHaveLength(1); + expect(total).toBe(1); + expect(data[0].description).toBe("Grocery run"); + }); + + it("keeps uncategorised rows visible", async () => { + // NULL <> ALL(...) is NULL, not true. Without the COALESCE an uncategorised + // row would vanish from a filter that never named its category. + const { ownerId } = await seedParticipants(pool); + // insertTransaction defaults category to 'other', so insert directly. + await pool.query( + `INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index) + VALUES ($1, NULL, '2026-06-15', 'Unknown thing', 100, 'debit', NULL, 0)`, + [ownerId] + ); + + const { data } = await getTransactions(ownerId, { + exclude_categories: ["transfers"], limit: 50, offset: 0, + }); + expect(data).toHaveLength(1); + expect(data[0].description).toBe("Unknown thing"); + }); + + it("an explicit category pick beats the exclusion", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { description: "Card payment", category: "transfers" }); + + const { data } = await getTransactions(ownerId, { + categories: ["transfers"], exclude_categories: ["transfers"], limit: 50, offset: 0, + }); + expect(data).toHaveLength(1); + }); + + it("respects the category override, not the raw category", async () => { + const { ownerId } = await seedParticipants(pool); + const txId = await insertTransaction(pool, ownerId, { description: "Was a transfer", category: "transfers" }); + await pool.query( + `INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'investment')`, + [txId] + ); + + const { data } = await getTransactions(ownerId, { + exclude_categories: ["transfers"], limit: 50, offset: 0, + }); + expect(data).toHaveLength(1); + }); +}); + describe("getTransactions — search filter", () => { it("searches description case-insensitively", async () => { const { ownerId } = await seedParticipants(pool); diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 2e7e7b6..3220c06 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -14,6 +14,7 @@ export async function GET(req: NextRequest) { from: sp.get("from") || undefined, to: sp.get("to") || undefined, categories: parseArr("categories"), + exclude_categories: parseArr("exclude_categories"), bank_names: parseArr("bank_names"), tag_ids: parseArr("tag_ids"), transaction_types: parseArr("transaction_types"), diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index cf1c2f1..227f0ea 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -518,6 +518,14 @@ function TransactionsContent() { from: "", to: "", categories: [] as string[], + // Transfers move money between your own accounts — they are not spending, + // and at ~380 rows they crowd out everything that is. Hidden by default, + // with a visible toggle: a filter you cannot see is one you forget is on. + // + // Off when the view is scoped to a statement. That is a reconciliation + // view — the row count has to match the statement, and a credit-card + // payment is exactly the row you are there to check. + exclude_categories: (initialStatementId ? [] : ["transfers"]) as string[], bank_names: [] as string[], tag_ids: [] as string[], transaction_types: [] as string[], @@ -716,9 +724,39 @@ function TransactionsContent() { ({ value: c, label: formatCategory(c) }))} value={filters.categories} - onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))} + onChange={(v) => + setFilters((f) => ({ + ...f, + categories: v, + // Asking for a category you are also hiding is a contradiction the + // server resolves in favour of the explicit pick; drop it here too + // so the toggle does not claim to be hiding what is on screen. + exclude_categories: f.exclude_categories.filter((c) => !v.includes(c)), + offset: 0, + })) + } placeholder="All Categories" /> + ({ value: b, label: b }))} value={filters.bank_names} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 78992cc..78c0471 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -16,6 +16,7 @@ interface TransactionFilters { from?: string; to?: string; categories?: string[]; + exclude_categories?: string[]; bank_names?: string[]; tag_ids?: string[]; transaction_types?: string[]; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 4bac104..f4b91ce 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -117,6 +117,13 @@ interface TransactionFilters { from?: string; to?: string; categories?: string[]; + /** + * Categories to hide. Opt-in per caller and never defaulted here — the rules + * preview and the bulk rule-apply path both go through getTransactions, and a + * default exclusion would silently shrink what a rule can see and reach. The + * transactions view sets this; nothing else does. + */ + exclude_categories?: string[]; bank_names?: string[]; tag_ids?: string[]; transaction_types?: string[]; @@ -152,6 +159,19 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`); params.push(filters.categories); } + if (filters.exclude_categories?.length) { + // Picking a category explicitly beats hiding it. Without this, selecting + // "Transfers" while the hide-transfers default is on returns zero rows and + // reads as "you have no transfers". + const hidden = filters.exclude_categories.filter((c) => !filters.categories?.includes(c)); + if (hidden.length) { + // COALESCE to '' rather than leaving it NULL: `NULL <> ALL(...)` is NULL, + // not true, so an uncategorised row would be filtered out by a hide rule + // that never named it. Same trap EXCLUDE_NON_SPEND documents. + conditions.push(`COALESCE(o.category_override, t.category, '') <> ALL($${paramIdx++}::text[])`); + params.push(hidden); + } + } if (filters.bank_names?.length) { // "Manual" and "Gift Card" are not banks — they are the two shapes a // statement-less row can take, and bankLabel() decides which. The filter