From c7c33df6404b9da0b8e23ba176cbbd84d5fb9469 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Wed, 2 Sep 2026 21:00:39 +0900 Subject: [PATCH] fix(reports): say which month the Cash Flow tiles measure, and count spending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four tiles above Money Flow are a whole-calendar-month view, but they sat under a date chip reading "Last 3 months" with nothing to say otherwise. On Sep 2 that meant Total Income $0.00 and Spent So Far $0.00 next to an Income vs Expense tab reporting $33,185.24 for the same chip. There was a guard for this, and it fired backwards. `isCurrentMonth` was true whenever the selected range merely *overlapped* the current month, so Jun 2 – Sep 2 counted as "the current month" and the "(current month)" note was hidden — in exactly the case a reader needed it. It also only ever marked the Safe to Spend tile, though all four are month-scoped. Rather than push the filter into the query, the panel now names its own window: a "September 2026 — this month only, not affected by the date filter" heading over the group. Safe to Spend is income received this month less bills still due and what has already gone out; over a three-month range it would be asking how much is left to spend in the past, which is not a number. Scoping it to the month is right — hiding that fact was the bug. Safe to Spend also gains a line saying what it means, since an unexplained negative in red is alarming on first read. Two query bugs surfaced while pinning this down: Spent So Far selected `normalizedAmount > 0`. Negative is a charge everywhere else in Reports, so this collected the refunds and left out every real charge: the tile read $0.00 in every month regardless of spending. On August's demo data it counted 0 rows where 42 charges totalling $4,383.06 were sitting. A household with no income category hit `sql`0`` as a WHERE term and took a Postgres 42804 (`argument of AND must be type boolean, not type integer`) — the whole tab failed, not just the tile. It is `sql`false`` now. `getSafeToSpend` takes an optional month (defaulting to the current one) and reports it back, so the panel labels the window the query actually measured instead of deriving it separately. Verified against demo data seeded into September: Total Income $7,700.00, Spent So Far $120.00 — the charge counted, the refund not — and Safe to Spend $4,828.02 = 7,700.00 − 2,751.98 − 120.00. Closes #145 --- src/app/(dashboard)/reports/page.tsx | 6 +- src/components/organisms/report-cash-flow.tsx | 21 ++- src/components/organisms/report-tabs.tsx | 3 - src/queries/reports.ts | 25 +++- tests/integration/safe-to-spend.test.ts | 140 ++++++++++++++++++ 5 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 tests/integration/safe-to-spend.test.ts diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx index 394109f..536518e 100644 --- a/src/app/(dashboard)/reports/page.tsx +++ b/src/app/(dashboard)/reports/page.tsx @@ -12,7 +12,7 @@ import { getSafeToSpend, type ReportFilters, } from "@/queries/reports"; -import { rangeToDateBounds, shiftDateRange, comparisonLabel, getCurrentMonth } from "@/lib/date-utils"; +import { rangeToDateBounds, shiftDateRange, comparisonLabel } from "@/lib/date-utils"; import { resolveReportDateSelection, DEFAULT_REPORT_PRESET } from "@/lib/report-date-selection"; import { ReportFilterBar } from "@/components/organisms/report-filter-bar"; import { ReportTabs } from "@/components/organisms/report-tabs"; @@ -116,9 +116,6 @@ export default async function ReportsPage({ break; } - const currentMonth = getCurrentMonth(); - const isCurrentMonth = dateFrom <= `${currentMonth}-01` && dateTo >= `${currentMonth}-01`; - const [allCategories, filterAccounts, savedReports] = await sharedPromise; return ( @@ -141,7 +138,6 @@ export default async function ReportsPage({ sankeyLinks={sankeyData?.links} cashFlowBarData={cashFlowBarData} safeToSpendData={safeToSpendData} - isCurrentMonth={isCurrentMonth} comparisonLabel={compLabel} dateFrom={dateFrom} dateTo={dateTo} diff --git a/src/components/organisms/report-cash-flow.tsx b/src/components/organisms/report-cash-flow.tsx index f55d749..69973aa 100644 --- a/src/components/organisms/report-cash-flow.tsx +++ b/src/components/organisms/report-cash-flow.tsx @@ -7,6 +7,7 @@ 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 { resolvedCategoryLabel } from "@/lib/labels"; +import { formatMonthLong } from "@/lib/date-utils"; import type { IncomeExpenseRow, SafeToSpendResult } from "@/queries/reports"; interface ReportCashFlowProps { @@ -14,7 +15,6 @@ interface ReportCashFlowProps { sankeyLinks: SankeyLink[]; barData: IncomeExpenseRow[]; safeToSpend: SafeToSpendResult; - isCurrentMonth: boolean; dateFrom: string; dateTo: string; accountIds?: string[]; @@ -25,7 +25,6 @@ export function ReportCashFlow({ sankeyLinks, barData, safeToSpend, - isCurrentMonth, dateFrom, dateTo, accountIds, @@ -48,7 +47,7 @@ export function ReportCashFlow({ label: "Safe to Spend", value: safeToSpend.safeToSpend, color: safeColor, - secondaryLabel: isCurrentMonth ? undefined : "(current month)", + secondaryLabel: "income, less bills still due and what you have spent", icon: PiggyBank, }, ]; @@ -74,7 +73,21 @@ export function ReportCashFlow({ return (
- + {/* These four are a whole-calendar-month view and do not follow the date + filter above — "how much is left to spend" is only a question about + the month you are in. Say so, rather than letting a reader assume the + chip applies and read $0.00 as a data problem. */} +
+
+

+ {formatMonthLong(safeToSpend.month)} +

+

+ this month only — not affected by the date filter +

+
+ +

Money Flow

diff --git a/src/components/organisms/report-tabs.tsx b/src/components/organisms/report-tabs.tsx index 4d43bbc..3216773 100644 --- a/src/components/organisms/report-tabs.tsx +++ b/src/components/organisms/report-tabs.tsx @@ -45,7 +45,6 @@ interface ReportTabsProps { sankeyLinks?: SankeyLink[]; cashFlowBarData?: IncomeExpenseRow[]; safeToSpendData?: SafeToSpendResult; - isCurrentMonth?: boolean; comparisonLabel: string | null; /** * The range the figures on screen were actually computed over. Passed down @@ -69,7 +68,6 @@ export function ReportTabs({ sankeyLinks, cashFlowBarData, safeToSpendData, - isCurrentMonth, comparisonLabel, dateFrom, dateTo, @@ -129,7 +127,6 @@ export function ReportTabs({ sankeyLinks={sankeyLinks} barData={cashFlowBarData} safeToSpend={safeToSpendData} - isCurrentMonth={isCurrentMonth ?? false} dateFrom={dateFrom} dateTo={dateTo} accountIds={accountIds} diff --git a/src/queries/reports.ts b/src/queries/reports.ts index 486658a..8f46e29 100644 --- a/src/queries/reports.ts +++ b/src/queries/reports.ts @@ -574,15 +574,27 @@ export interface SafeToSpendResult { recurringExpenses: number; discretionarySpent: number; safeToSpend: number; + /** The calendar month these figures cover, `YYYY-MM`. */ + month: string; } +/** + * How much of this month is still the household's to spend. + * + * Deliberately scoped to a whole calendar month and not to the report's date + * filter: every term is a this-month quantity — income received, bills still + * due, spending already made. Over a three-month range it would be asking how + * much is left to spend in the past. The Cash Flow tab labels the panel with + * `month` so a reader can see it stands apart from the filter above it. + */ export async function getSafeToSpend( householdId: string, db: LedgrDb = defaultDb, + month: string = getCurrentMonth(), ): Promise { const scoped = scopedQuery(householdId, db); const incomeCatIds = await getIncomeCategoryIds(householdId, db); - const { from: dateFrom, to: dateTo } = monthBounds(getCurrentMonth()); + const { from: dateFrom, to: dateTo } = monthBounds(month); // Monthly income (including pending — so paycheck shows immediately) const incomeTxns = await db @@ -598,7 +610,7 @@ export async function getSafeToSpend( isNull(transactions.transferPairId), incomeCatIds.size > 0 ? inArray(transactions.categoryId, [...incomeCatIds]) - : sql`0`, + : sql`false`, ), ); @@ -670,18 +682,23 @@ export async function getSafeToSpend( eq(transactions.isTransfer, false), isNull(transactions.transferPairId), isNull(transactions.recurringTransactionId), - sql`${transactions.normalizedAmount} > 0`, + // A charge is a NEGATIVE normalized amount, as everywhere else in + // Reports. Selecting positives instead collected the refunds and left + // every real charge out, so this tile read $0.00 in every month no + // matter what the household had spent. + lt(transactions.normalizedAmount, 0), notIncomeCondition, ), ); - const discretionarySpent = discretionaryTxns.reduce((s, t) => s + t.normalizedAmount, 0); + const discretionarySpent = discretionaryTxns.reduce((s, t) => s + Math.abs(t.normalizedAmount), 0); return { monthlyIncome, recurringExpenses, discretionarySpent, safeToSpend: monthlyIncome - recurringExpenses - discretionarySpent, + month, }; } diff --git a/tests/integration/safe-to-spend.test.ts b/tests/integration/safe-to-spend.test.ts new file mode 100644 index 0000000..3b20478 --- /dev/null +++ b/tests/integration/safe-to-spend.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { createTestDb } from "./setup"; +import { + insertHousehold, + insertAccount, + insertCategoryGroup, + insertCategory, + insertTransaction, + insertRecurringTransaction, +} from "./helpers"; +import { getCurrentMonth, monthBounds, shiftMonth } from "../../src/lib/date-utils"; +import type { LedgrDb } from "../../src/db"; + +/** + * Safe to Spend answers "how much of this month is still mine to spend?" — so + * every part of it is scoped to the current calendar month, deliberately and + * independently of the report's date filter. + * + * Dates here are derived from `new Date()`: the query resolves its own window + * from the calendar, so fixtures pinned to literal dates would rot. + */ + +let db: LedgrDb; +let close: () => Promise; +let householdId: string; +let accountId: string; +let foodCatId: string; +let salaryCatId: string; + +const THIS_MONTH = getCurrentMonth(); +const LAST_MONTH = shiftMonth(THIS_MONTH, -1); +/** The 10th: safely inside every month, whatever today happens to be. */ +const IN_MONTH = `${THIS_MONTH}-10`; +const IN_LAST_MONTH = `${LAST_MONTH}-10`; + +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(); +}); + +describe("Spent So Far counts spending", () => { + test("a charge this month is spending", async () => { + // Negative normalizedAmount is a charge — the same sign convention the + // Spending tab uses. Summing positives instead made this tile read $0.00 + // in every month, whatever the household had spent. + await insertTransaction(db, householdId, accountId, { date: IN_MONTH, normalizedAmount: -5000, amount: 5000, categoryId: foodCatId, name: "Grocery" }); + await insertTransaction(db, householdId, accountId, { date: IN_MONTH, normalizedAmount: -3000, amount: 3000, categoryId: foodCatId, name: "Restaurant" }); + const { getSafeToSpend } = await import("../../src/queries/reports"); + + const result = await getSafeToSpend(householdId, db); + + expect(result.discretionarySpent).toBe(8000); + }); + + test("a refund is not spending", async () => { + await insertTransaction(db, householdId, accountId, { date: IN_MONTH, normalizedAmount: -5000, amount: 5000, categoryId: foodCatId, name: "Grocery" }); + await insertTransaction(db, householdId, accountId, { date: IN_MONTH, normalizedAmount: 2000, amount: -2000, categoryId: foodCatId, name: "Refund" }); + const { getSafeToSpend } = await import("../../src/queries/reports"); + + expect((await getSafeToSpend(householdId, db)).discretionarySpent).toBe(5000); + }); + + test("a recurring charge belongs to Recurring Bills, not Spent So Far", async () => { + const { recurringId } = await insertRecurringTransaction(db, householdId, { averageAmount: 4000 }); + await insertTransaction(db, householdId, accountId, { date: IN_MONTH, normalizedAmount: -4000, amount: 4000, categoryId: foodCatId, name: "Netflix", recurringTransactionId: recurringId }); + await insertTransaction(db, householdId, accountId, { date: IN_MONTH, normalizedAmount: -1500, amount: 1500, categoryId: foodCatId, name: "Coffee" }); + const { getSafeToSpend } = await import("../../src/queries/reports"); + + const result = await getSafeToSpend(householdId, db); + + expect(result.discretionarySpent).toBe(1500); + expect(result.recurringExpenses).toBe(4000); + }); + + test("last month's charges are not this month's spending", async () => { + await insertTransaction(db, householdId, accountId, { date: IN_LAST_MONTH, normalizedAmount: -9900, amount: 9900, categoryId: foodCatId, name: "Old charge" }); + await insertTransaction(db, householdId, accountId, { date: IN_MONTH, normalizedAmount: -1000, amount: 1000, categoryId: foodCatId, name: "New charge" }); + const { getSafeToSpend } = await import("../../src/queries/reports"); + + expect((await getSafeToSpend(householdId, db)).discretionarySpent).toBe(1000); + }); +}); + +describe("the window is the current calendar month, whatever the report filter says", () => { + test("it reads the current month by default", async () => { + await insertTransaction(db, householdId, accountId, { date: IN_MONTH, normalizedAmount: 500000, amount: -500000, categoryId: salaryCatId, name: "Salary" }); + await insertTransaction(db, householdId, accountId, { date: IN_LAST_MONTH, normalizedAmount: 500000, amount: -500000, categoryId: salaryCatId, name: "Last salary" }); + const { getSafeToSpend } = await import("../../src/queries/reports"); + + expect((await getSafeToSpend(householdId, db)).monthlyIncome).toBe(500000); + }); + + test("an explicit month overrides the default", async () => { + await insertTransaction(db, householdId, accountId, { date: IN_LAST_MONTH, normalizedAmount: 500000, amount: -500000, categoryId: salaryCatId, name: "Last salary" }); + const { getSafeToSpend } = await import("../../src/queries/reports"); + + expect((await getSafeToSpend(householdId, db, LAST_MONTH)).monthlyIncome).toBe(500000); + expect((await getSafeToSpend(householdId, db, THIS_MONTH)).monthlyIncome).toBe(0); + }); + + test("the month it reports is the month it measured", async () => { + const { getSafeToSpend } = await import("../../src/queries/reports"); + + const result = await getSafeToSpend(householdId, db); + + expect(result.month).toBe(THIS_MONTH); + expect(monthBounds(result.month).from).toBe(`${THIS_MONTH}-01`); + }); +}); + +describe("a household with no income categories", () => { + test("reports zero income rather than failing", async () => { + const { db: db2, close: close2 } = await createTestDb(); + const { householdId: h2 } = await insertHousehold(db2); + const { accountId: a2 } = await insertAccount(db2, h2); + await insertTransaction(db2, h2, a2, { date: IN_MONTH, normalizedAmount: -2500, amount: 2500, name: "Charge" }); + const { getSafeToSpend } = await import("../../src/queries/reports"); + + try { + const result = await getSafeToSpend(h2, db2); + expect(result.monthlyIncome).toBe(0); + expect(result.discretionarySpent).toBe(2500); + } finally { + await close2(); + } + }); +});