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(); + } + }); +});