diff --git a/blueprints/ynab/tools.py b/blueprints/ynab/tools.py index 8418c33..469b5d4 100644 --- a/blueprints/ynab/tools.py +++ b/blueprints/ynab/tools.py @@ -456,19 +456,34 @@ async def ynab_spending_report( GROUP BY 1 ORDER BY {order}""", *params, ) + rows = [r for r in rows if r["spent"] > 0] if not rows: return f"No spending found from {start} to {end}." - total = sum((r["spent"] for r in rows), Decimal(0)) + totals = await _query( + f"""{LINES_CTE} + SELECT COALESCE(-SUM(LEAST(l.amount, 0)), 0) AS outflows, + COALESCE(SUM(GREATEST(l.amount, 0)), 0) AS inflows + FROM lines l + JOIN ynab_accounts a ON a.id = l.account_id + LEFT JOIN ynab_categories c ON c.id = l.category_id + WHERE {" AND ".join(conditions)}""", + *params, + ) + outflows, inflows = totals[0]["outflows"], totals[0]["inflows"] top = max(1, top) shown = rows if group_by == "month" else rows[:top] - lines = [f"Spending by {group_by} from {start} to {end}: total {_usd(total)}"] + 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)}", + ] 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(total / len(rows))}") + lines.append(f"Monthly average: {_usd(outflows / len(rows))}") artifact = make_artifact( "ynab.spending_report", f"Spending by {group_by.replace('_', ' ')} ยท " @@ -482,7 +497,7 @@ async def ynab_spending_report( {"key": r["key"], "amount": _amount(r["spent"]), "count": r["n"]} for r in rows ], - "total": _amount(total), + "total": _amount(outflows), } ), ) diff --git a/tests/unit/test_ynab_tool_artifacts.py b/tests/unit/test_ynab_tool_artifacts.py index ece9dfe..16ff211 100644 --- a/tests/unit/test_ynab_tool_artifacts.py +++ b/tests/unit/test_ynab_tool_artifacts.py @@ -247,12 +247,22 @@ SPENDING_ROWS = [ async def test_spending_report(monkeypatch): - fake_db(monkeypatch, {"GROUP BY 1": SPENDING_ROWS}) + fake_db( + monkeypatch, + { + "GROUP BY 1": SPENDING_ROWS, + "AS outflows": [ + {"outflows": Decimal("710.50"), "inflows": Decimal("100.00")} + ], + }, + ) msg = await call(tools.ynab_spending_report, {"top": 2}) assert msg.content == ( - "Spending by category from 2026-10-01 to 2026-10-12: total $610.50\n" + "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" "- Dining Out: $150.50 (5 txns)\n" "- 1 others: $60.00" @@ -269,7 +279,7 @@ async def test_spending_report(monkeypatch): {"key": "Dining Out", "amount": 150.5, "count": 5}, {"key": "Gas", "amount": 60.0, "count": 2}, ], - "total": 610.5, + "total": 710.5, } @@ -278,14 +288,23 @@ async def test_spending_report_by_month(monkeypatch): {"key": "2026-09", "spent": Decimal("100"), "n": 1}, {"key": "2026-10", "spent": Decimal("200"), "n": 2}, ] - fake_db(monkeypatch, {"GROUP BY 1": rows}) + fake_db( + monkeypatch, + { + "GROUP BY 1": rows, + "AS outflows": [ + {"outflows": Decimal("400.00"), "inflows": Decimal("100.00")} + ], + }, + ) msg = await call( tools.ynab_spending_report, {"group_by": "month", "start_date": "2026-09-01"}, ) - assert msg.content.endswith("Monthly average: $150.00") + assert msg.content.endswith("Monthly average: $200.00") + assert msg.artifact["data"]["total"] == 400.0 assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"] @@ -425,3 +444,28 @@ async def test_db_error(monkeypatch, tool): def test_text_only_tools_unchanged(): assert tools.ynab_account_balances.response_format == "content" assert tools.ynab_recent_changes.response_format == "content" + + +async def test_spending_report_omits_nonpositive_groups(monkeypatch): + rows = SPENDING_ROWS + [ + {"key": "Uncategorized", "spent": Decimal("-5000"), "n": 1}, + {"key": "Fully refunded", "spent": Decimal("0"), "n": 2}, + ] + fake_db( + monkeypatch, + { + "GROUP BY 1": rows, + "AS outflows": [ + {"outflows": Decimal("710.50"), "inflows": Decimal("5100.00")} + ], + }, + ) + msg = await call(tools.ynab_spending_report, {}) + assert "Total spending (outflows): $710.50" in msg.content + assert "not spending): $5,100.00" in msg.content + assert "Uncategorized" not in msg.content + assert "Fully refunded" not in msg.content + assert [r["key"] for r in msg.artifact["data"]["rows"]] == [ + r["key"] for r in SPENDING_ROWS + ] + assert msg.artifact["data"]["total"] == 710.5