Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/app/(dashboard)/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
Expand All @@ -141,7 +138,6 @@ export default async function ReportsPage({
sankeyLinks={sankeyData?.links}
cashFlowBarData={cashFlowBarData}
safeToSpendData={safeToSpendData}
isCurrentMonth={isCurrentMonth}
comparisonLabel={compLabel}
dateFrom={dateFrom}
dateTo={dateTo}
Expand Down
21 changes: 17 additions & 4 deletions src/components/organisms/report-cash-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ 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 {
sankeyNodes: SankeyNode[];
sankeyLinks: SankeyLink[];
barData: IncomeExpenseRow[];
safeToSpend: SafeToSpendResult;
isCurrentMonth: boolean;
dateFrom: string;
dateTo: string;
accountIds?: string[];
Expand All @@ -25,7 +25,6 @@ export function ReportCashFlow({
sankeyLinks,
barData,
safeToSpend,
isCurrentMonth,
dateFrom,
dateTo,
accountIds,
Expand All @@ -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,
},
];
Expand All @@ -74,7 +73,21 @@ export function ReportCashFlow({

return (
<div className="space-y-4">
<ReportSummaryBar items={summaryItems} />
{/* 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. */}
<section aria-labelledby="safe-to-spend-heading" className="space-y-2">
<div className="flex flex-wrap items-baseline gap-x-2">
<h3 id="safe-to-spend-heading" className="text-lg font-medium">
{formatMonthLong(safeToSpend.month)}
</h3>
<p className="text-xs text-muted-foreground">
this month only — not affected by the date filter
</p>
</div>
<ReportSummaryBar items={summaryItems} />
</section>

<h3 className="text-lg font-medium">Money Flow</h3>
<div className="h-[400px]">
Expand Down
3 changes: 0 additions & 3 deletions src/components/organisms/report-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -69,7 +68,6 @@ export function ReportTabs({
sankeyLinks,
cashFlowBarData,
safeToSpendData,
isCurrentMonth,
comparisonLabel,
dateFrom,
dateTo,
Expand Down Expand Up @@ -129,7 +127,6 @@ export function ReportTabs({
sankeyLinks={sankeyLinks}
barData={cashFlowBarData}
safeToSpend={safeToSpendData}
isCurrentMonth={isCurrentMonth ?? false}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
Expand Down
25 changes: 21 additions & 4 deletions src/queries/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SafeToSpendResult> {
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
Expand All @@ -598,7 +610,7 @@ export async function getSafeToSpend(
isNull(transactions.transferPairId),
incomeCatIds.size > 0
? inArray(transactions.categoryId, [...incomeCatIds])
: sql`0`,
: sql`false`,
),
);

Expand Down Expand Up @@ -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,
};
}

Expand Down
140 changes: 140 additions & 0 deletions tests/integration/safe-to-spend.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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();
}
});
});
Loading