diff --git a/src/actions/reports.ts b/src/actions/reports.ts index 4f71663..b520e29 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 c447b07..394109f 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 82f45d7..4ecfea1 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 b53bbc2..f55d749 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 74ec2dd..89aa1df 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 2a667ff..eb18652 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 39675fe..4d43bbc 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 24f30b9..28925f4 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 1b07466..486658a 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 cfad824..ca326f2 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 0000000..b28c53d --- /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"]); + }); +});