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