Hide transfers in the transactions view by default
ci / lint-test (push) Successful in 51s

Transfers move money between your own accounts; at 433 of 3,996 rows
(~11%) they crowd out the rows that represent actual spending.

getTransactions gains `exclude_categories`, opt-in per caller and
deliberately not defaulted in queries.ts: the rules preview and the bulk
rule-apply path both read candidate rows through getTransactions, and a
default exclusion there would silently shrink what a rule can see and
reach — invisibly, since a rule that matches nothing looks the same as a
rule with nothing to do.

Two behaviours the filter needs, both tested:

- An explicit category pick beats the exclusion. Selecting "Transfers"
  while the default is on subtracts it from the hidden list instead of
  returning zero rows and reading as "you have no transfers".
- COALESCE the effective category to '' before `<> ALL`. NULL <> ALL(...)
  is NULL, not true, so an uncategorised row would disappear from a
  filter that never named its category — the trap EXCLUDE_NON_SPEND
  already documents.

The default is 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 the row you went there to check.
This commit is contained in:
2026-07-30 23:34:31 +10:00
parent 549abd8cca
commit f6c500b27a
6 changed files with 141 additions and 1 deletions
+23
View File
@@ -95,6 +95,29 @@ COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merc
COALESCE(o.category_override, t.category) -- category 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 ## Database
```bash ```bash
+57
View File
@@ -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", () => { describe("getTransactions — search filter", () => {
it("searches description case-insensitively", async () => { it("searches description case-insensitively", async () => {
const { ownerId } = await seedParticipants(pool); const { ownerId } = await seedParticipants(pool);
+1
View File
@@ -14,6 +14,7 @@ export async function GET(req: NextRequest) {
from: sp.get("from") || undefined, from: sp.get("from") || undefined,
to: sp.get("to") || undefined, to: sp.get("to") || undefined,
categories: parseArr("categories"), categories: parseArr("categories"),
exclude_categories: parseArr("exclude_categories"),
bank_names: parseArr("bank_names"), bank_names: parseArr("bank_names"),
tag_ids: parseArr("tag_ids"), tag_ids: parseArr("tag_ids"),
transaction_types: parseArr("transaction_types"), transaction_types: parseArr("transaction_types"),
+39 -1
View File
@@ -518,6 +518,14 @@ function TransactionsContent() {
from: "", from: "",
to: "", to: "",
categories: [] as string[], 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[], bank_names: [] as string[],
tag_ids: [] as string[], tag_ids: [] as string[],
transaction_types: [] as string[], transaction_types: [] as string[],
@@ -716,9 +724,39 @@ function TransactionsContent() {
<MultiSelect <MultiSelect
options={CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }))} options={CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }))}
value={filters.categories} 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" placeholder="All Categories"
/> />
<label className="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-900 border border-zinc-700 rounded text-sm text-zinc-300 cursor-pointer select-none">
<input
type="checkbox"
checked={filters.exclude_categories.includes("transfers")}
onChange={(e) =>
setFilters((f) => ({
...f,
exclude_categories: e.target.checked
? [...f.exclude_categories, "transfers"]
: f.exclude_categories.filter((c) => c !== "transfers"),
categories: e.target.checked
? f.categories.filter((c) => c !== "transfers")
: f.categories,
offset: 0,
}))
}
className="accent-indigo-500"
/>
Hide transfers
</label>
<MultiSelect <MultiSelect
options={(banks ?? []).map((b) => ({ value: b, label: b }))} options={(banks ?? []).map((b) => ({ value: b, label: b }))}
value={filters.bank_names} value={filters.bank_names}
+1
View File
@@ -16,6 +16,7 @@ interface TransactionFilters {
from?: string; from?: string;
to?: string; to?: string;
categories?: string[]; categories?: string[];
exclude_categories?: string[];
bank_names?: string[]; bank_names?: string[];
tag_ids?: string[]; tag_ids?: string[];
transaction_types?: string[]; transaction_types?: string[];
+20
View File
@@ -117,6 +117,13 @@ interface TransactionFilters {
from?: string; from?: string;
to?: string; to?: string;
categories?: 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[]; bank_names?: string[];
tag_ids?: string[]; tag_ids?: string[];
transaction_types?: 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[])`); conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`);
params.push(filters.categories); 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) { if (filters.bank_names?.length) {
// "Manual" and "Gift Card" are not banks — they are the two shapes a // "Manual" and "Gift Card" are not banks — they are the two shapes a
// statement-less row can take, and bankLabel() decides which. The filter // statement-less row can take, and bankLabel() decides which. The filter