State spending-report outflows and inflows separately #63
@@ -456,19 +456,34 @@ async def ynab_spending_report(
|
|||||||
GROUP BY 1 ORDER BY {order}""",
|
GROUP BY 1 ORDER BY {order}""",
|
||||||
*params,
|
*params,
|
||||||
)
|
)
|
||||||
|
rows = [r for r in rows if r["spent"] > 0]
|
||||||
if not rows:
|
if not rows:
|
||||||
return f"No spending found from {start} to {end}."
|
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)
|
top = max(1, top)
|
||||||
shown = rows if group_by == "month" else rows[: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]
|
lines += [f"- {r['key']}: {_usd(r['spent'])} ({r['n']} txns)" for r in shown]
|
||||||
if len(rows) > len(shown):
|
if len(rows) > len(shown):
|
||||||
rest = sum((r["spent"] for r in rows[len(shown) :]), Decimal(0))
|
rest = sum((r["spent"] for r in rows[len(shown) :]), Decimal(0))
|
||||||
lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}")
|
lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}")
|
||||||
if group_by == "month" and len(rows) > 1:
|
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(
|
artifact = make_artifact(
|
||||||
"ynab.spending_report",
|
"ynab.spending_report",
|
||||||
f"Spending by {group_by.replace('_', ' ')} · "
|
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"]}
|
{"key": r["key"], "amount": _amount(r["spent"]), "count": r["n"]}
|
||||||
for r in rows
|
for r in rows
|
||||||
],
|
],
|
||||||
"total": _amount(total),
|
"total": _amount(outflows),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -247,12 +247,22 @@ SPENDING_ROWS = [
|
|||||||
|
|
||||||
|
|
||||||
async def test_spending_report(monkeypatch):
|
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})
|
msg = await call(tools.ynab_spending_report, {"top": 2})
|
||||||
|
|
||||||
assert msg.content == (
|
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"
|
"- Groceries: $400.00 (10 txns)\n"
|
||||||
"- Dining Out: $150.50 (5 txns)\n"
|
"- Dining Out: $150.50 (5 txns)\n"
|
||||||
"- 1 others: $60.00"
|
"- 1 others: $60.00"
|
||||||
@@ -269,7 +279,7 @@ async def test_spending_report(monkeypatch):
|
|||||||
{"key": "Dining Out", "amount": 150.5, "count": 5},
|
{"key": "Dining Out", "amount": 150.5, "count": 5},
|
||||||
{"key": "Gas", "amount": 60.0, "count": 2},
|
{"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-09", "spent": Decimal("100"), "n": 1},
|
||||||
{"key": "2026-10", "spent": Decimal("200"), "n": 2},
|
{"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(
|
msg = await call(
|
||||||
tools.ynab_spending_report,
|
tools.ynab_spending_report,
|
||||||
{"group_by": "month", "start_date": "2026-09-01"},
|
{"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"]
|
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():
|
def test_text_only_tools_unchanged():
|
||||||
assert tools.ynab_account_balances.response_format == "content"
|
assert tools.ynab_account_balances.response_format == "content"
|
||||||
assert tools.ynab_recent_changes.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
|
||||||
|
|||||||
Reference in New Issue
Block a user