From 78beb54ae2b26d3a2139abc80192165003c309b1 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Wed, 2 Sep 2026 20:53:31 +0900 Subject: [PATCH] fix(reports): make a drill-down explain the figure that opened it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a spending row opened a sheet whose total disagreed with the row, for three separate reasons. The sheet added up the page of rows it had been handed. That page is capped at 50, so any category with more transactions than that displayed a total short of the row above it, with no indication anything was missing. The total and the match count now come from the server, computed over the whole matching population; the page is still 50 rows, and now says so honestly ("Showing the most recent 50 of 120"). The query behind the sheet filtered on category and dates alone, so a list meant to explain a spending figure also contained the transfers, pending rows and refunds that figure had deliberately excluded. It now builds on the same `spendingBaseConditions` every Reports tab agrees on, or on its income counterpart when an income node was clicked — both exported from `spending-helpers` rather than restated here. Third, the sheet took its date range from the URL, where the page took its own from the resolved preset. On a bare `/reports` the URL carries no dates at all, so the tab showed three months while the drill-down queried all-time: clicking a $1,891.26 Groceries row opened a sheet reading $3,746.58. The resolved range and account filter are now passed down from the page, so the two can no longer diverge. `getTransactions` keeps its filter-shaped API; the page fetch underneath it is extracted as `fetchTransactionPage` so the report query can supply its own conditions without duplicating the row enrichment. Verified against demo data: Groceries reads $1,891.26 / 24 transactions on both the row and the sheet, and the Cash Flow Salary node reads $23,100.00 / 6 on both. Closes #144 --- src/actions/reports.ts | 30 ++- src/app/(dashboard)/reports/page.tsx | 3 + src/components/organisms/drill-down-sheet.tsx | 27 ++- src/components/organisms/report-cash-flow.tsx | 13 +- .../organisms/report-income-expense.tsx | 18 +- src/components/organisms/report-spending.tsx | 18 +- src/components/organisms/report-tabs.tsx | 33 +++- src/lib/spending-helpers.ts | 38 +++- src/queries/reports.ts | 70 ++++++- src/queries/transactions.ts | 19 +- tests/integration/report-drill-down.test.ts | 183 ++++++++++++++++++ 11 files changed, 407 insertions(+), 45 deletions(-) create mode 100644 tests/integration/report-drill-down.test.ts diff --git a/src/actions/reports.ts b/src/actions/reports.ts index 4f716639..b520e29b 100644 --- a/src/actions/reports.ts +++ b/src/actions/reports.ts @@ -10,7 +10,11 @@ import { scopedQuery } from "@/lib/scoped-query"; import { authorizeAction } from "@/lib/auth/authorize-action"; import { getHouseholdId } from "@/lib/auth/session"; import { withHousehold } from "@/lib/household-context"; -import { getTransactions, type TransactionRow } from "@/queries/transactions"; +import { + getDrillDownTransactions as queryDrillDownTransactions, + type DrillDownFilters, + type DrillDownResult, +} from "@/queries/reports"; const saveReportSchema = z.object({ name: z.string().min(1).max(100), @@ -83,21 +87,15 @@ export async function deleteReport( const DRILL_DOWN_LIMIT = 50; -export async function getDrillDownTransactions(filters: { - /** A category id, `null` for uncategorized, `undefined` for no category filter. */ - categoryId?: string | null; - dateFrom: string; - dateTo: string; - type?: "income" | "expense"; -}): Promise<{ rows: TransactionRow[]; hasMore: boolean }> { +export async function getDrillDownTransactions( + filters: DrillDownFilters, + db: LedgrDb = defaultDb, +): Promise { const householdId = await getHouseholdId(); - const page = await withHousehold(householdId, (tx) => - getTransactions(householdId, { - categoryId: filters.categoryId, - dateFrom: filters.dateFrom, - dateTo: filters.dateTo, - }, DRILL_DOWN_LIMIT, undefined, tx)); - - return { rows: page.rows, hasMore: page.nextCursor !== null }; + return withHousehold( + householdId, + (tx) => queryDrillDownTransactions(householdId, filters, DRILL_DOWN_LIMIT, tx), + db, + ); } diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx index c447b07b..394109f6 100644 --- a/src/app/(dashboard)/reports/page.tsx +++ b/src/app/(dashboard)/reports/page.tsx @@ -143,6 +143,9 @@ export default async function ReportsPage({ safeToSpendData={safeToSpendData} isCurrentMonth={isCurrentMonth} comparisonLabel={compLabel} + dateFrom={dateFrom} + dateTo={dateTo} + accountIds={accountIds} /> ); diff --git a/src/components/organisms/drill-down-sheet.tsx b/src/components/organisms/drill-down-sheet.tsx index 82f45d75..4ecfea1d 100644 --- a/src/components/organisms/drill-down-sheet.tsx +++ b/src/components/organisms/drill-down-sheet.tsx @@ -30,13 +30,19 @@ interface DrillDownSheetProps { filter: DrillDownFilter | null; dateFrom: string; dateTo: string; + accountIds?: string[]; onClose: () => void; } -export function DrillDownSheet({ filter, dateFrom, dateTo, onClose }: DrillDownSheetProps) { +export function DrillDownSheet({ filter, dateFrom, dateTo, accountIds, onClose }: DrillDownSheetProps) { + // Depend on the contents, not the array identity: the parent re-renders on + // every drill-down open and would otherwise hand us a fresh array each time. + const accountKey = accountIds?.join(",") ?? ""; const [isPending, startTransition] = useTransition(); const [rows, setRows] = useState([]); const [hasMore, setHasMore] = useState(false); + const [total, setTotal] = useState(0); + const [matchCount, setMatchCount] = useState(0); const effectiveDateFrom = filter?.month ? `${filter.month}-01` : dateFrom; const effectiveDateTo = filter?.month @@ -51,18 +57,20 @@ export function DrillDownSheet({ filter, dateFrom, dateTo, onClose }: DrillDownS categoryId: filter.categoryId, dateFrom: effectiveDateFrom, dateTo: effectiveDateTo, - type: filter.type, + accountIds: accountKey ? accountKey.split(",") : undefined, + // The Spending tab has one side only, so an absent type means expense. + type: filter.type ?? "expense", }); setRows(result.rows); setHasMore(result.hasMore); + setTotal(result.total); + setMatchCount(result.matchCount); }); // Depend on the filter object itself, not its fields: an uncategorized // drill-down carries `categoryId: null` alongside undefined month/type, which // is field-for-field identical to the closed (null filter) state, so a field // dependency list would never fire and the sheet would open empty. - }, [filter, effectiveDateFrom, effectiveDateTo]); - - const totalAmount = rows.reduce((s, r) => s + r.normalizedAmount, 0); + }, [filter, effectiveDateFrom, effectiveDateTo, accountKey]); const txnPageUrl = filter ? drillDownTransactionsUrl({ @@ -81,9 +89,12 @@ export function DrillDownSheet({ filter, dateFrom, dateTo, onClose }: DrillDownS {filter?.categoryName} - {!isPending && rows.length > 0 && ( + {!isPending && matchCount > 0 && (
- {centsToDisplay(Math.abs(totalAmount))} + {centsToDisplay(total)} + + {matchCount} {matchCount === 1 ? "transaction" : "transactions"} +
)} @@ -99,7 +110,7 @@ export function DrillDownSheet({ filter, dateFrom, dateTo, onClose }: DrillDownS <> {hasMore && (
- Showing first {rows.length} transactions + Showing the most recent {rows.length} of {matchCount}
)} diff --git a/src/components/organisms/report-cash-flow.tsx b/src/components/organisms/report-cash-flow.tsx index b53bbc2e..f55d7490 100644 --- a/src/components/organisms/report-cash-flow.tsx +++ b/src/components/organisms/report-cash-flow.tsx @@ -6,7 +6,6 @@ import { SankeyChart, type SankeyNode, type SankeyLink } from "@/components/orga import { CashFlowBarChart } from "@/components/atoms/cash-flow-bar-chart"; import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar"; import { DrillDownSheet, type DrillDownFilter } from "@/components/organisms/drill-down-sheet"; -import { useSearchParamFilters } from "@/hooks/use-search-param-filters"; import { resolvedCategoryLabel } from "@/lib/labels"; import type { IncomeExpenseRow, SafeToSpendResult } from "@/queries/reports"; @@ -16,6 +15,9 @@ interface ReportCashFlowProps { barData: IncomeExpenseRow[]; safeToSpend: SafeToSpendResult; isCurrentMonth: boolean; + dateFrom: string; + dateTo: string; + accountIds?: string[]; } export function ReportCashFlow({ @@ -24,9 +26,11 @@ export function ReportCashFlow({ barData, safeToSpend, isCurrentMonth, + dateFrom, + dateTo, + accountIds, }: ReportCashFlowProps) { const [drillDown, setDrillDown] = useState(null); - const { dateRange } = useSearchParamFilters(); const safeColor: SummaryItem["color"] = (() => { if (safeToSpend.monthlyIncome === 0) return "default"; @@ -89,8 +93,9 @@ export function ReportCashFlow({ setDrillDown(null)} /> diff --git a/src/components/organisms/report-income-expense.tsx b/src/components/organisms/report-income-expense.tsx index 74ec2dd1..89aa1dfb 100644 --- a/src/components/organisms/report-income-expense.tsx +++ b/src/components/organisms/report-income-expense.tsx @@ -6,18 +6,25 @@ import { CashFlowBarChart } from "@/components/atoms/cash-flow-bar-chart"; import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar"; import { IncomeExpenseCategoryTable } from "@/components/molecules/income-expense-category-table"; import { DrillDownSheet, type DrillDownFilter } from "@/components/organisms/drill-down-sheet"; -import { useSearchParamFilters } from "@/hooks/use-search-param-filters"; import { resolvedCategoryLabel } from "@/lib/labels"; import type { IncomeExpenseRow, IncomeExpenseCategoryRow } from "@/queries/reports"; interface ReportIncomeExpenseProps { data: IncomeExpenseRow[]; categoryData?: IncomeExpenseCategoryRow[]; + dateFrom: string; + dateTo: string; + accountIds?: string[]; } -export function ReportIncomeExpense({ data, categoryData }: ReportIncomeExpenseProps) { +export function ReportIncomeExpense({ + data, + categoryData, + dateFrom, + dateTo, + accountIds, +}: ReportIncomeExpenseProps) { const [drillDown, setDrillDown] = useState(null); - const { dateRange } = useSearchParamFilters(); const chartData = data.map((r) => ({ month: r.period, @@ -61,8 +68,9 @@ export function ReportIncomeExpense({ data, categoryData }: ReportIncomeExpenseP )} setDrillDown(null)} /> diff --git a/src/components/organisms/report-spending.tsx b/src/components/organisms/report-spending.tsx index 2a667ff3..eb18652d 100644 --- a/src/components/organisms/report-spending.tsx +++ b/src/components/organisms/report-spending.tsx @@ -18,18 +18,25 @@ import { } from "@/components/ui/table"; import { centsToDisplay } from "@/lib/money"; import { CHART_COLORS } from "@/lib/chart-colors"; -import { useSearchParamFilters } from "@/hooks/use-search-param-filters"; import type { SpendingRow } from "@/queries/reports"; interface ReportSpendingProps { data: SpendingRow[]; comparisonLabel: string | null; + dateFrom: string; + dateTo: string; + accountIds?: string[]; } -export function ReportSpending({ data, comparisonLabel: compLabel }: ReportSpendingProps) { +export function ReportSpending({ + data, + comparisonLabel: compLabel, + dateFrom, + dateTo, + accountIds, +}: ReportSpendingProps) { const [view, setView] = useState<"donut" | "bar">("donut"); const [drillDown, setDrillDown] = useState(null); - const { dateRange } = useSearchParamFilters(); const chartData = data.map((r) => ({ id: r.categoryId, @@ -126,8 +133,9 @@ export function ReportSpending({ data, comparisonLabel: compLabel }: ReportSpend setDrillDown(null)} /> diff --git a/src/components/organisms/report-tabs.tsx b/src/components/organisms/report-tabs.tsx index 39675fe4..4d43bbc2 100644 --- a/src/components/organisms/report-tabs.tsx +++ b/src/components/organisms/report-tabs.tsx @@ -47,6 +47,15 @@ interface ReportTabsProps { safeToSpendData?: SafeToSpendResult; isCurrentMonth?: boolean; comparisonLabel: string | null; + /** + * The range the figures on screen were actually computed over. Passed down + * rather than re-read from the URL, so a drill-down can never query a + * different period than the row that opened it — on a bare `/reports` the + * URL carries no dates at all, and the client-side fallback was all-time. + */ + dateFrom: string; + dateTo: string; + accountIds?: string[]; } export function ReportTabs({ @@ -62,6 +71,9 @@ export function ReportTabs({ safeToSpendData, isCurrentMonth, comparisonLabel, + dateFrom, + dateTo, + accountIds, }: ReportTabsProps) { const { updateFilter } = useSearchParamFilters(); @@ -89,11 +101,25 @@ export function ReportTabs({ - {spendingData && } + {spendingData && ( + + )} {incomeExpenseData && ( - + )} @@ -104,6 +130,9 @@ export function ReportTabs({ barData={cashFlowBarData} safeToSpend={safeToSpendData} isCurrentMonth={isCurrentMonth ?? false} + dateFrom={dateFrom} + dateTo={dateTo} + accountIds={accountIds} /> )} diff --git a/src/lib/spending-helpers.ts b/src/lib/spending-helpers.ts index 24f30b95..28925f42 100644 --- a/src/lib/spending-helpers.ts +++ b/src/lib/spending-helpers.ts @@ -1,4 +1,4 @@ -import { eq, lt, gte, lte, inArray, notInArray, isNull } from "drizzle-orm"; +import { eq, lt, gte, lte, inArray, notInArray, isNull, sql } from "drizzle-orm"; import { db as defaultDb, type LedgrDb } from "@/db"; import { transactions, @@ -9,7 +9,7 @@ import { import { scopedQuery } from "@/lib/scoped-query"; import { notDeleted, sumAbs, sumCol } from "@/lib/query-helpers"; import { UNCATEGORIZED, resolvedCategoryLabel } from "@/lib/labels"; -import { notIncome } from "@/queries/shared-conditions"; +import { notIncome, getIncomeCategoryIds } from "@/queries/shared-conditions"; import type { ReportFilters } from "@/queries/reports"; export interface SpendingChartItem { @@ -22,7 +22,13 @@ export interface SpendingChartItem { } -async function spendingBaseConditions(householdId: string, filters: ReportFilters, db: LedgrDb) { +/** + * The one definition of spending. Every Reports tab that answers "how much did + * I spend?" builds on this, and so does the drill-down that explains any of + * those figures: a settled, non-transfer, negative charge outside an income + * category. + */ +export async function spendingBaseConditions(householdId: string, filters: ReportFilters, db: LedgrDb) { const conditions = [ notDeleted(transactions), lt(transactions.normalizedAmount, 0), @@ -39,6 +45,32 @@ async function spendingBaseConditions(householdId: string, filters: ReportFilter return conditions; } +/** + * The income counterpart, matching the Sankey's income nodes and the Income vs + * Expense category table: settled, non-transfer rows in an income category, + * summed by magnitude. Note this is *not* the Total Income tile's definition, + * which also counts uncategorized credits — that gap is the open gross-vs-net + * product question, deliberately left alone here. + */ +export async function incomeBaseConditions(householdId: string, filters: ReportFilters, db: LedgrDb) { + const incomeCatIds = await getIncomeCategoryIds(householdId, db); + const conditions = [ + notDeleted(transactions), + eq(transactions.pending, false), + eq(transactions.isTransfer, false), + isNull(transactions.transferPairId), + gte(transactions.date, filters.dateFrom), + lte(transactions.date, filters.dateTo), + incomeCatIds.size > 0 + ? inArray(transactions.categoryId, [...incomeCatIds]) + : sql`false`, + ]; + if (filters.accountIds?.length) { + conditions.push(inArray(transactions.accountId, filters.accountIds)); + } + return conditions; +} + async function findSplitParentIds( scoped: ReturnType, diff --git a/src/queries/reports.ts b/src/queries/reports.ts index 1b07466b..486658a6 100644 --- a/src/queries/reports.ts +++ b/src/queries/reports.ts @@ -9,14 +9,17 @@ import { recurringTransactions, } from "@/db/schema"; import { scopedQuery } from "@/lib/scoped-query"; -import { notDeleted, sumAbs } from "@/lib/query-helpers"; +import { notDeleted, sumAbs, countRows } from "@/lib/query-helpers"; import { getIncomeCategoryIds, notIncome } from "@/queries/shared-conditions"; import { classifyAccountType } from "@/lib/account-utils"; import { resolvedCategoryLabel, UNCATEGORIZED } from "@/lib/labels"; import { aggregateSpending, enrichSpendingMap, + spendingBaseConditions, + incomeBaseConditions, } from "@/lib/spending-helpers"; +import { fetchTransactionPage, type TransactionRow } from "@/queries/transactions"; import { getCurrentMonth, monthBounds } from "@/lib/date-utils"; import type { SankeyNode, SankeyLink } from "@/components/organisms/sankey-chart"; @@ -681,3 +684,68 @@ export async function getSafeToSpend( safeToSpend: monthlyIncome - recurringExpenses - discretionarySpent, }; } + + +export interface DrillDownFilters extends ReportFilters { + /** A category id, `null` for uncategorized, `undefined` for every category. */ + categoryId?: string | null; + /** Which side of the report the clicked figure came from. */ + type?: "income" | "expense"; +} + +export interface DrillDownResult { + rows: TransactionRow[]; + hasMore: boolean; + /** Magnitude summed over every match, not just the page in `rows`. */ + total: number; + /** How many transactions the figure counted, page size notwithstanding. */ + matchCount: number; +} + +/** + * The transactions behind a report figure. + * + * The population is the report's own — `spendingBaseConditions` or + * `incomeBaseConditions`, plus the report's date range and account filter — not + * a bare category+date lookup, which swept in the transfers, pending rows and + * refunds that the figure had deliberately excluded. + * + * `total` and `matchCount` are computed over that whole population. The sheet + * used to add up the rows it had been handed, so any category with more + * transactions than the page limit displayed a total short of the row that + * opened it. + */ +export async function getDrillDownTransactions( + householdId: string, + filters: DrillDownFilters, + limit = 50, + db: LedgrDb = defaultDb, +): Promise { + const scoped = scopedQuery(householdId, db); + + const conditions = + filters.type === "income" + ? await incomeBaseConditions(householdId, filters, db) + : await spendingBaseConditions(householdId, filters, db); + + if (filters.categoryId === null) { + conditions.push(isNull(transactions.categoryId)); + } else if (filters.categoryId !== undefined) { + conditions.push(eq(transactions.categoryId, filters.categoryId)); + } + + const [page, [totals]] = await Promise.all([ + fetchTransactionPage(householdId, conditions, limit, null, db), + db + .select({ total: sumAbs(transactions.normalizedAmount), matchCount: countRows() }) + .from(transactions) + .where(scoped.where(transactions, ...conditions)), + ]); + + return { + rows: page.rows, + hasMore: page.nextCursor !== null, + total: totals?.total ?? 0, + matchCount: totals?.matchCount ?? 0, + }; +} diff --git a/src/queries/transactions.ts b/src/queries/transactions.ts index cfad8243..ca326f2b 100644 --- a/src/queries/transactions.ts +++ b/src/queries/transactions.ts @@ -142,7 +142,24 @@ export async function getTransactions( cursor: string | null = null, db: LedgrDb = defaultDb, ): Promise { - const conditions = buildTransactionConditions(filters); + return fetchTransactionPage(householdId, buildTransactionConditions(filters), limit, cursor, db); +} + +/** + * Fetch one page of transactions for an arbitrary set of conditions. + * + * `getTransactions` builds those conditions from the transactions-page filter + * shape; the Reports drill-down builds them from the spending/income definition + * instead, so that a drill-down lists exactly the rows its figure counted. + */ +export async function fetchTransactionPage( + householdId: string, + baseConditions: (SQL | undefined)[], + limit = 50, + cursor: string | null = null, + db: LedgrDb = defaultDb, +): Promise { + const conditions = [...baseConditions]; // Cursor conditions stay here — not in the shared builder const decoded = cursor ? decodeCursor(cursor) : null; diff --git a/tests/integration/report-drill-down.test.ts b/tests/integration/report-drill-down.test.ts new file mode 100644 index 00000000..b28c53de --- /dev/null +++ b/tests/integration/report-drill-down.test.ts @@ -0,0 +1,183 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { createTestDb } from "./setup"; +import { + insertHousehold, + insertAccount, + insertCategoryGroup, + insertCategory, + insertTransaction, +} from "./helpers"; +import type { LedgrDb } from "../../src/db"; + +/** + * Clicking a figure opens a sheet that must answer for that same figure. Two + * things used to break that promise: the sheet summed only the page of rows it + * had fetched (50 at most), and the query behind it filtered on category and + * dates alone — so transfers, pending rows and credits joined a list that was + * supposed to explain a spending number. + * + * These tests pin the drill-down against the tab figure that opened it, which + * is the only invariant that matters here. + */ + +let db: LedgrDb; +let close: () => Promise; +let householdId: string; +let accountId: string; +let foodCatId: string; +let salaryCatId: string; + +const RANGE = { dateFrom: "2026-03-01", dateTo: "2026-03-31" }; + +beforeEach(async () => { + ({ db, close } = await createTestDb()); + ({ householdId } = await insertHousehold(db)); + ({ accountId } = await insertAccount(db, householdId)); + + const { groupId } = await insertCategoryGroup(db, householdId, { name: "Living" }); + ({ categoryId: foodCatId } = await insertCategory(db, householdId, groupId, { name: "Food" })); + const incGroup = await insertCategoryGroup(db, householdId, { name: "Income" }); + ({ categoryId: salaryCatId } = await insertCategory(db, householdId, incGroup.groupId, { + name: "Salary", + isIncome: true, + })); +}); + +afterEach(async () => { + await close(); +}); + +/** Every row shape the Spending figure excludes but a category+date query keeps. */ +async function seedFoodWithNoise() { + await insertTransaction(db, householdId, accountId, { date: "2026-03-05", normalizedAmount: -5000, amount: 5000, categoryId: foodCatId, name: "Grocery" }); + await insertTransaction(db, householdId, accountId, { date: "2026-03-15", normalizedAmount: -3000, amount: 3000, categoryId: foodCatId, name: "Restaurant" }); + // A refund: positive, so not spending. + await insertTransaction(db, householdId, accountId, { date: "2026-03-20", normalizedAmount: 2000, amount: -2000, categoryId: foodCatId, name: "Grocery refund" }); + // Pending: not settled, so not spending. + await insertTransaction(db, householdId, accountId, { date: "2026-03-21", normalizedAmount: -9900, amount: 9900, categoryId: foodCatId, name: "Pending charge", pending: true }); + // A transfer, and its paired leg. + await insertTransaction(db, householdId, accountId, { date: "2026-03-22", normalizedAmount: -4400, amount: 4400, categoryId: foodCatId, name: "Transfer out", isTransfer: true }); + await insertTransaction(db, householdId, accountId, { date: "2026-03-23", normalizedAmount: -7700, amount: 7700, categoryId: foodCatId, name: "Paired leg", transferPairId: "pair-1" }); + // Outside the range. + await insertTransaction(db, householdId, accountId, { date: "2026-02-25", normalizedAmount: -6600, amount: 6600, categoryId: foodCatId, name: "Last month" }); +} + +describe("a spending drill-down explains the figure that opened it", () => { + test("its total equals the Spending tab row", async () => { + await seedFoodWithNoise(); + const { getSpendingByCategory, getDrillDownTransactions } = await import("../../src/queries/reports"); + + const row = (await getSpendingByCategory(householdId, RANGE, db)).find((r) => r.categoryName === "Food"); + const drill = await getDrillDownTransactions( + householdId, + { categoryId: foodCatId, ...RANGE, type: "expense" }, + 50, + db, + ); + + expect(row?.total).toBe(8000); + expect(drill.total).toBe(row?.total); + }); + + test("it lists only the transactions the figure counted", async () => { + await seedFoodWithNoise(); + const { getDrillDownTransactions } = await import("../../src/queries/reports"); + + const drill = await getDrillDownTransactions( + householdId, + { categoryId: foodCatId, ...RANGE, type: "expense" }, + 50, + db, + ); + + expect(drill.rows.map((r) => r.name).sort()).toEqual(["Grocery", "Restaurant"]); + }); + + test("the total covers every match, not just the page that was fetched", async () => { + // 120 charges of 100 each: two and a half pages at the sheet's limit. + for (let i = 0; i < 120; i++) { + await insertTransaction(db, householdId, accountId, { + date: "2026-03-10", + normalizedAmount: -100, + amount: 100, + categoryId: foodCatId, + name: `Charge ${i}`, + }); + } + const { getSpendingByCategory, getDrillDownTransactions } = await import("../../src/queries/reports"); + + const row = (await getSpendingByCategory(householdId, RANGE, db)).find((r) => r.categoryName === "Food"); + const drill = await getDrillDownTransactions( + householdId, + { categoryId: foodCatId, ...RANGE, type: "expense" }, + 50, + db, + ); + + expect(drill.rows).toHaveLength(50); + expect(drill.hasMore).toBe(true); + expect(drill.matchCount).toBe(120); + expect(drill.total).toBe(12000); + expect(drill.total).toBe(row?.total); + }); + + test("an uncategorized drill-down matches its Spending row", async () => { + await insertTransaction(db, householdId, accountId, { date: "2026-03-08", normalizedAmount: -7000, amount: 7000, categoryId: null, name: "Unknown merchant" }); + await insertTransaction(db, householdId, accountId, { date: "2026-03-09", normalizedAmount: 1500, amount: -1500, categoryId: null, name: "Unknown credit" }); + const { getSpendingByCategory, getDrillDownTransactions } = await import("../../src/queries/reports"); + + const row = (await getSpendingByCategory(householdId, RANGE, db)).find((r) => r.categoryId === null); + const drill = await getDrillDownTransactions( + householdId, + { categoryId: null, ...RANGE, type: "expense" }, + 50, + db, + ); + + expect(drill.total).toBe(7000); + expect(drill.total).toBe(row?.total); + expect(drill.rows.map((r) => r.name)).toEqual(["Unknown merchant"]); + }); + + test("it honours the account filter the report was run with", async () => { + const other = await insertAccount(db, householdId, { name: "Other" }); + await insertTransaction(db, householdId, accountId, { date: "2026-03-05", normalizedAmount: -5000, amount: 5000, categoryId: foodCatId, name: "On account" }); + await insertTransaction(db, householdId, other.accountId, { date: "2026-03-06", normalizedAmount: -2500, amount: 2500, categoryId: foodCatId, name: "Off account" }); + const { getDrillDownTransactions } = await import("../../src/queries/reports"); + + const drill = await getDrillDownTransactions( + householdId, + { categoryId: foodCatId, ...RANGE, type: "expense", accountIds: [accountId] }, + 50, + db, + ); + + expect(drill.total).toBe(5000); + expect(drill.rows.map((r) => r.name)).toEqual(["On account"]); + }); +}); + +describe("an income drill-down explains its own figure", () => { + test("its total equals the Cash Flow income node", async () => { + await insertTransaction(db, householdId, accountId, { date: "2026-03-01", normalizedAmount: 500000, amount: -500000, categoryId: salaryCatId, name: "Salary" }); + await insertTransaction(db, householdId, accountId, { date: "2026-03-15", normalizedAmount: 250000, amount: -250000, categoryId: salaryCatId, name: "Bonus" }); + await insertTransaction(db, householdId, accountId, { date: "2026-03-16", normalizedAmount: 90000, amount: -90000, categoryId: salaryCatId, name: "Pending pay", pending: true }); + const { getCashFlowSankey, getDrillDownTransactions } = await import("../../src/queries/reports"); + + const { links } = await getCashFlowSankey(householdId, RANGE, db); + const nodeTotal = links + .filter((l) => l.source === `income-${salaryCatId}`) + .reduce((s, l) => s + l.value, 0); + + const drill = await getDrillDownTransactions( + householdId, + { categoryId: salaryCatId, ...RANGE, type: "income" }, + 50, + db, + ); + + expect(drill.total).toBe(750000); + expect(drill.total).toBe(nodeTotal); + expect(drill.rows.map((r) => r.name).sort()).toEqual(["Bonus", "Salary"]); + }); +});