Merge pull request 'State spending-report outflows and inflows separately' (#63) from feat/ynab-report-totals into main

This commit was merged in pull request #63.
This commit is contained in:
2026-10-12 19:48:17 -04:00
2 changed files with 68 additions and 9 deletions
+19 -4
View File
@@ -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),
}
),
)
+49 -5
View File
@@ -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