diff --git a/blueprints/ynab/tools.py b/blueprints/ynab/tools.py index 469b5d4..2719360 100644 --- a/blueprints/ynab/tools.py +++ b/blueprints/ynab/tools.py @@ -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}, } ), ) diff --git a/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx b/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx index 39db77c..31aef59 100644 --- a/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx +++ b/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx @@ -88,12 +88,15 @@ export const YnabSpendingReport = ({ data }: RendererPropsGrouped by {data.group_by?.replace(/_/g, " ")}
-
Total
+
{data.total_basis === "outflows" ? "Total outflows" : "Total"}
{formatMoney(data.total)}
+ {data.inflows != null && Inflows: {formatMoney(data.inflows)}} + {data.net_outflows != null && Net outflows: {formatMoney(data.net_outflows)}}
+ {data.total_basis === "outflows" && Groups show gross outflows. Refunds and other inflows are reported separately.} {rows.length === 0 ? ( - No spending in this period. + No outflows in this period. ) : data.group_by === "month" ? ( ) : ( diff --git a/raggr-frontend/src/components/artifacts/renderers/types.ts b/raggr-frontend/src/components/artifacts/renderers/types.ts index fd7413e..816cdaf 100644 --- a/raggr-frontend/src/components/artifacts/renderers/types.ts +++ b/raggr-frontend/src/components/artifacts/renderers/types.ts @@ -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 = { diff --git a/raggr-frontend/tests/spending-report.test.cjs b/raggr-frontend/tests/spending-report.test.cjs new file mode 100644 index 0000000..b279c8f --- /dev/null +++ b/raggr-frontend/tests/spending-report.test.cjs @@ -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 { + 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:/); +}); diff --git a/tests/unit/test_ynab_tool_artifacts.py b/tests/unit/test_ynab_tool_artifacts.py index 16ff211..a81763b 100644 --- a/tests/unit/test_ynab_tool_artifacts.py +++ b/tests/unit/test_ynab_tool_artifacts.py @@ -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 == []