Use gross outflows consistently in spending reports #70
@@ -110,8 +110,8 @@ WITH lines AS (
|
||||
)
|
||||
"""
|
||||
|
||||
# Spending = outflows (net of refunds) on budget accounts, excluding transfers
|
||||
# (credit card payments, moves to savings) and income.
|
||||
# Report cash flows on budget accounts, excluding transfers and internal
|
||||
# categories. Separate outflows from inflows rather than calling their net spending.
|
||||
SPENDING_FILTER = """
|
||||
a.on_budget AND l.transfer_account_id IS NULL
|
||||
AND NOT COALESCE(c.internal, false)
|
||||
@@ -415,7 +415,7 @@ async def ynab_spending_report(
|
||||
payee: str = "",
|
||||
top: int = 15,
|
||||
):
|
||||
"""Total YNAB spending grouped by category, category_group, payee, account, or month. Use group_by='month' for trends over time.
|
||||
"""Gross YNAB outflows grouped by category, category_group, payee, account, or month, with inflows reported separately. Use group_by='month' for trends over time.
|
||||
|
||||
Args:
|
||||
group_by: category, category_group, payee, account, or month
|
||||
@@ -436,6 +436,9 @@ async def ynab_spending_report(
|
||||
except ValueError:
|
||||
return "Dates must be YYYY-MM-DD."
|
||||
|
||||
if start > end:
|
||||
return "start_date must be on or before end_date."
|
||||
|
||||
conditions, params = [SPENDING_FILTER, "l.date >= $1", "l.date <= $2"], [start, end]
|
||||
if category:
|
||||
params.append(f"%{category}%")
|
||||
@@ -447,7 +450,7 @@ async def ynab_spending_report(
|
||||
order = "key" if group_by == "month" else "spent DESC"
|
||||
rows = await _query(
|
||||
f"""{LINES_CTE}
|
||||
SELECT {key} AS key, -SUM(l.amount) AS spent, count(*) AS n
|
||||
SELECT {key} AS key, -SUM(LEAST(l.amount, 0)) AS spent, count(*) AS n
|
||||
FROM lines l
|
||||
JOIN ynab_accounts a ON a.id = l.account_id
|
||||
LEFT JOIN ynab_categories c ON c.id = l.category_id
|
||||
@@ -457,8 +460,6 @@ async def ynab_spending_report(
|
||||
*params,
|
||||
)
|
||||
rows = [r for r in rows if r["spent"] > 0]
|
||||
if not rows:
|
||||
return f"No spending found from {start} to {end}."
|
||||
|
||||
totals = await _query(
|
||||
f"""{LINES_CTE}
|
||||
@@ -471,19 +472,25 @@ async def ynab_spending_report(
|
||||
*params,
|
||||
)
|
||||
outflows, inflows = totals[0]["outflows"], totals[0]["inflows"]
|
||||
if not outflows and not inflows:
|
||||
return f"No spending found from {start} to {end}."
|
||||
top = max(1, top)
|
||||
shown = rows if group_by == "month" else rows[:top]
|
||||
lines = [
|
||||
f"Spending by {group_by} from {start} to {end}:",
|
||||
f"- Total spending (outflows): {_usd(outflows)}",
|
||||
f"- Inflows (deposits into budget accounts, not spending): {_usd(inflows)}",
|
||||
"- Group amounts are gross outflows; refunds and other inflows are reported separately.",
|
||||
]
|
||||
lines += [f"- {r['key']}: {_usd(r['spent'])} ({r['n']} txns)" for r in shown]
|
||||
if len(rows) > len(shown):
|
||||
rest = sum((r["spent"] for r in rows[len(shown) :]), Decimal(0))
|
||||
lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}")
|
||||
if group_by == "month" and len(rows) > 1:
|
||||
lines.append(f"Monthly average: {_usd(outflows / len(rows))}")
|
||||
months = (end.year - start.year) * 12 + end.month - start.month + 1
|
||||
if group_by == "month":
|
||||
lines.append(
|
||||
f"Monthly average (outflows over {months} calendar months): {_usd(outflows / months)}"
|
||||
)
|
||||
artifact = make_artifact(
|
||||
"ynab.spending_report",
|
||||
f"Spending by {group_by.replace('_', ' ')} · "
|
||||
@@ -498,6 +505,10 @@ async def ynab_spending_report(
|
||||
for r in rows
|
||||
],
|
||||
"total": _amount(outflows),
|
||||
"total_basis": "outflows",
|
||||
"inflows": _amount(inflows),
|
||||
"net_outflows": _amount(outflows - inflows),
|
||||
"filters": {"category": category, "payee": payee},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -88,12 +88,15 @@ export const YnabSpendingReport = ({ data }: RendererProps<YnabSpendingReportDat
|
||||
<Subtle>Grouped by {data.group_by?.replace(/_/g, " ")}</Subtle>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">Total</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">{data.total_basis === "outflows" ? "Total outflows" : "Total"}</div>
|
||||
<div className="text-xl font-bold tabular-nums text-charcoal">{formatMoney(data.total)}</div>
|
||||
{data.inflows != null && <Subtle>Inflows: {formatMoney(data.inflows)}</Subtle>}
|
||||
{data.net_outflows != null && <Subtle>Net outflows: {formatMoney(data.net_outflows)}</Subtle>}
|
||||
</div>
|
||||
</div>
|
||||
{data.total_basis === "outflows" && <Subtle>Groups show gross outflows. Refunds and other inflows are reported separately.</Subtle>}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyNote>No spending in this period.</EmptyNote>
|
||||
<EmptyNote>No outflows in this period.</EmptyNote>
|
||||
) : data.group_by === "month" ? (
|
||||
<MonthBars rows={rows} />
|
||||
) : (
|
||||
|
||||
@@ -87,9 +87,13 @@ export type YnabSpendingReportData = {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
group_by: "category" | "category_group" | "payee" | "account" | "month" | string;
|
||||
/** amount is positive for spending (-SUM); a net refund comes out negative. */
|
||||
/** Gross outflows for new reports; legacy reports contain net spending. */
|
||||
rows: { key: string; amount: number; count?: number }[];
|
||||
total: number;
|
||||
total_basis?: "outflows";
|
||||
inflows?: number;
|
||||
net_outflows?: number;
|
||||
filters?: { category: string; payee: string };
|
||||
};
|
||||
|
||||
export type YnabCategoryBudgetData = {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const ts = require('typescript');
|
||||
const React = require('react');
|
||||
const { renderToStaticMarkup } = require('react-dom/server');
|
||||
|
||||
// Load the existing TSX renderer without adding a test bundler dependency.
|
||||
for (const ext of ['.ts', '.tsx']) {
|
||||
require.extensions[ext] = (mod, filename) => {
|
||||
const { outputText } = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
|
||||
});
|
||||
mod._compile(outputText, filename);
|
||||
};
|
||||
}
|
||||
const { YnabSpendingReport } = require('../src/components/artifacts/renderers/YnabSpendingReport.tsx');
|
||||
const base = { start_date: '2026-10-01', end_date: '2026-10-12', group_by: 'category', rows: [{ key: 'Shopping', amount: 70, count: 2 }], total: 100 };
|
||||
const render = (data) => renderToStaticMarkup(React.createElement(YnabSpendingReport, { data }));
|
||||
|
||||
test('legacy artifact keeps a neutral total label', () => {
|
||||
const html = render(base);
|
||||
assert.match(html, />Total</);
|
||||
assert.doesNotMatch(html, /Total \(net\)|Total outflows|Inflows:/);
|
||||
});
|
||||
test('new report labels gross outflows, inflows and net separately', () => {
|
||||
const html = render({ ...base, total_basis: 'outflows', inflows: 30, net_outflows: 70 });
|
||||
assert.match(html, /Total outflows/);
|
||||
assert.match(html, /Inflows:/);
|
||||
assert.match(html, /Net outflows:/);
|
||||
assert.match(html, /Groups show gross outflows/);
|
||||
});
|
||||
test('inflow-only report remains visible with empty outflow rows', () => {
|
||||
const html = render({ ...base, total_basis: 'outflows', rows: [], total: 0, inflows: 75, net_outflows: -75 });
|
||||
assert.match(html, /No outflows in this period/);
|
||||
assert.match(html, /Inflows:/);
|
||||
});
|
||||
@@ -26,7 +26,17 @@ KIND_DATA_KEYS = {
|
||||
"overspent",
|
||||
},
|
||||
"ynab.transactions": {"start_date", "end_date", "count", "total", "transactions"},
|
||||
"ynab.spending_report": {"start_date", "end_date", "group_by", "rows", "total"},
|
||||
"ynab.spending_report": {
|
||||
"start_date",
|
||||
"end_date",
|
||||
"group_by",
|
||||
"rows",
|
||||
"total",
|
||||
"total_basis",
|
||||
"inflows",
|
||||
"net_outflows",
|
||||
"filters",
|
||||
},
|
||||
"ynab.category_budget": {"month", "groups"},
|
||||
}
|
||||
|
||||
@@ -240,7 +250,7 @@ async def test_search_transactions_bad_date(monkeypatch):
|
||||
# --- ynab_spending_report ---------------------------------------------------
|
||||
|
||||
SPENDING_ROWS = [
|
||||
{"key": "Groceries", "spent": Decimal("400.00"), "n": 10},
|
||||
{"key": "Groceries", "spent": Decimal("500.00"), "n": 10},
|
||||
{"key": "Dining Out", "spent": Decimal("150.50"), "n": 5},
|
||||
{"key": "Gas", "spent": Decimal("60.00"), "n": 2},
|
||||
]
|
||||
@@ -263,7 +273,8 @@ async def test_spending_report(monkeypatch):
|
||||
"Spending by category from 2026-10-01 to 2026-10-12:\n"
|
||||
"- Total spending (outflows): $710.50\n"
|
||||
"- Inflows (deposits into budget accounts, not spending): $100.00\n"
|
||||
"- Groceries: $400.00 (10 txns)\n"
|
||||
"- Group amounts are gross outflows; refunds and other inflows are reported separately.\n"
|
||||
"- Groceries: $500.00 (10 txns)\n"
|
||||
"- Dining Out: $150.50 (5 txns)\n"
|
||||
"- 1 others: $60.00"
|
||||
)
|
||||
@@ -275,18 +286,22 @@ async def test_spending_report(monkeypatch):
|
||||
"end_date": "2026-10-12",
|
||||
"group_by": "category",
|
||||
"rows": [
|
||||
{"key": "Groceries", "amount": 400.0, "count": 10},
|
||||
{"key": "Groceries", "amount": 500.0, "count": 10},
|
||||
{"key": "Dining Out", "amount": 150.5, "count": 5},
|
||||
{"key": "Gas", "amount": 60.0, "count": 2},
|
||||
],
|
||||
"total": 710.5,
|
||||
"total_basis": "outflows",
|
||||
"inflows": 100.0,
|
||||
"net_outflows": 610.5,
|
||||
"filters": {"category": "", "payee": ""},
|
||||
}
|
||||
|
||||
|
||||
async def test_spending_report_by_month(monkeypatch):
|
||||
rows = [
|
||||
{"key": "2026-09", "spent": Decimal("100"), "n": 1},
|
||||
{"key": "2026-10", "spent": Decimal("200"), "n": 2},
|
||||
{"key": "2026-10", "spent": Decimal("300"), "n": 2},
|
||||
]
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
@@ -303,13 +318,21 @@ async def test_spending_report_by_month(monkeypatch):
|
||||
{"group_by": "month", "start_date": "2026-09-01"},
|
||||
)
|
||||
|
||||
assert msg.content.endswith("Monthly average: $200.00")
|
||||
assert msg.content.endswith(
|
||||
"Monthly average (outflows over 2 calendar months): $200.00"
|
||||
)
|
||||
assert msg.artifact["data"]["total"] == 400.0
|
||||
assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"]
|
||||
|
||||
|
||||
async def test_spending_report_empty_and_invalid(monkeypatch):
|
||||
fake_db(monkeypatch, {"GROUP BY 1": []})
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": [],
|
||||
"AS outflows": [{"outflows": Decimal(0), "inflows": Decimal(0)}],
|
||||
},
|
||||
)
|
||||
|
||||
msg = await call(tools.ynab_spending_report, {})
|
||||
assert msg.content == "No spending found from 2026-10-01 to 2026-10-12."
|
||||
@@ -469,3 +492,61 @@ async def test_spending_report_omits_nonpositive_groups(monkeypatch):
|
||||
r["key"] for r in SPENDING_ROWS
|
||||
]
|
||||
assert msg.artifact["data"]["total"] == 710.5
|
||||
|
||||
|
||||
async def test_spending_report_fully_refunded_purchase_is_not_empty(monkeypatch):
|
||||
queries = fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": [{"key": "Shopping", "spent": Decimal(100), "n": 2}],
|
||||
"AS outflows": [{"outflows": Decimal(100), "inflows": Decimal(100)}],
|
||||
},
|
||||
)
|
||||
msg = await call(tools.ynab_spending_report, {"category": "Shopping"})
|
||||
assert msg.artifact["data"]["total"] == 100
|
||||
assert msg.artifact["data"]["net_outflows"] == 0
|
||||
assert msg.artifact["data"]["rows"][0]["amount"] == 100
|
||||
assert "-SUM(LEAST(l.amount, 0)) AS spent" in queries[0][0]
|
||||
assert all("l.category_name ILIKE $3" in sql for sql, _ in queries)
|
||||
assert msg.artifact["data"]["filters"] == {"category": "Shopping", "payee": ""}
|
||||
|
||||
|
||||
async def test_spending_report_inflow_only_period_retains_totals(monkeypatch):
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": [{"key": "Refund", "spent": Decimal(0), "n": 1}],
|
||||
"AS outflows": [{"outflows": Decimal(0), "inflows": Decimal(75)}],
|
||||
},
|
||||
)
|
||||
msg = await call(tools.ynab_spending_report, {})
|
||||
assert msg.artifact["data"]["rows"] == []
|
||||
assert msg.artifact["data"]["total"] == 0
|
||||
assert msg.artifact["data"]["inflows"] == 75
|
||||
assert "not spending): $75.00" in msg.content
|
||||
|
||||
|
||||
async def test_monthly_average_includes_zero_spending_months(monkeypatch):
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": [{"key": "2026-10", "spent": Decimal(300), "n": 1}],
|
||||
"AS outflows": [{"outflows": Decimal(300), "inflows": Decimal(0)}],
|
||||
},
|
||||
)
|
||||
msg = await call(
|
||||
tools.ynab_spending_report, {"group_by": "month", "start_date": "2026-08-01"}
|
||||
)
|
||||
assert msg.content.endswith(
|
||||
"Monthly average (outflows over 3 calendar months): $100.00"
|
||||
)
|
||||
|
||||
|
||||
async def test_spending_report_reversed_dates_are_rejected(monkeypatch):
|
||||
queries = fake_db(monkeypatch, {})
|
||||
msg = await call(
|
||||
tools.ynab_spending_report,
|
||||
{"start_date": "2026-11-01", "end_date": "2026-10-01"},
|
||||
)
|
||||
assert msg.content == "start_date must be on or before end_date."
|
||||
assert queries == []
|
||||
|
||||
Reference in New Issue
Block a user