mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: idle cash alert and category hierarchy (subcategories)
Two of the four ideas picked from the Maybe/Sure feature audit: - Idle cash: a 5th proactive alert flags a depository account (checking/ savings/cash) sitting at or above a configurable balance with no transaction touching it for a configurable number of days. - Category hierarchy: categories can now have one level of subcategories (Groceries -> Alcohol). Dashboard/report aggregations roll a subcategory's spending into its parent's slice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
69
backend/tests/test_categories.py
Normal file
69
backend/tests/test_categories.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Category subcategories (parent_id): creation rules and the one-level-deep
|
||||
invariant enforced in api/routes/categories.py.
|
||||
"""
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def test_create_subcategory_under_top_level_category(client: AsyncClient, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
resp = await client.post(
|
||||
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["parent_id"] == groceries
|
||||
|
||||
|
||||
async def test_subcategory_cannot_have_a_different_kind_than_its_parent(client: AsyncClient, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
resp = await client.post(
|
||||
"/categories", json={"name": "Bad", "kind": "income", "color": "#e34948", "parent_id": groceries}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_subcategories_are_only_one_level_deep(client: AsyncClient, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
child = await client.post(
|
||||
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries}
|
||||
)
|
||||
child_id = child.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
"/categories", json={"name": "Wine", "kind": "expense", "color": "#e34948", "parent_id": child_id}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_category_cannot_be_its_own_parent(client: AsyncClient, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
resp = await client.patch(f"/categories/{groceries}", json={"parent_id": groceries})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_a_category_with_subcategories_cannot_itself_become_a_subcategory(client: AsyncClient, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
dining = categories["Dining Out"]["id"]
|
||||
await client.post(
|
||||
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries}
|
||||
)
|
||||
|
||||
resp = await client.patch(f"/categories/{groceries}", json={"parent_id": dining})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_deleting_a_parent_leaves_children_as_top_level(client: AsyncClient, categories):
|
||||
# A default category can't be deleted at all (separate rule) — use a
|
||||
# fresh, non-default parent so this test isolates the parent/child rule.
|
||||
parent = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"})
|
||||
parent_id = parent.json()["id"]
|
||||
created = await client.post(
|
||||
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": parent_id}
|
||||
)
|
||||
child_id = created.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/categories/{parent_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
refetched = await client.get("/categories")
|
||||
child = next(c for c in refetched.json() if c["id"] == child_id)
|
||||
assert child["parent_id"] is None
|
||||
@@ -81,6 +81,23 @@ async def test_spending_by_category_amounts_and_percent(client: AsyncClient, acc
|
||||
assert breakdown["Dining Out"]["percent"] == 25.0
|
||||
|
||||
|
||||
async def test_subcategory_spending_rolls_up_into_its_parent(client: AsyncClient, account_id, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
alcohol = await client.post(
|
||||
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries}
|
||||
)
|
||||
alcohol_id = alcohol.json()["id"]
|
||||
|
||||
await client.post("/transactions", json=_txn(account_id, amount="60.00", category_id=groceries, date="2026-08-01"))
|
||||
await client.post("/transactions", json=_txn(account_id, amount="15.00", category_id=alcohol_id, date="2026-08-02"))
|
||||
|
||||
resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8})
|
||||
breakdown = {row["name"]: row for row in resp.json()["spending_by_category"]}
|
||||
|
||||
assert "Alcohol" not in breakdown
|
||||
assert money(breakdown["Groceries"]["amount"]) == Decimal("75.00")
|
||||
|
||||
|
||||
async def test_more_than_eight_expense_categories_roll_up_into_other(client: AsyncClient, account_id, categories):
|
||||
expense_categories = [c for c in categories.values() if c["kind"] == "expense"]
|
||||
# The default seed only ships 8 expense categories (see app/db/seed.py) —
|
||||
|
||||
64
backend/tests/test_insights.py
Normal file
64
backend/tests/test_insights.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Proactive alerts (services/insights_service.py): only the idle-cash
|
||||
signal is covered here — the other three (negative cash flow, net worth
|
||||
decline, over-budget) predate this test file and aren't touched by this
|
||||
change.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.helpers import txn_payload as _txn
|
||||
|
||||
STALE_DATE = (date.today() - timedelta(days=400)).isoformat()
|
||||
RECENT_DATE = date.today().isoformat()
|
||||
|
||||
|
||||
async def _alert_keys(client: AsyncClient) -> set[str]:
|
||||
resp = await client.get("/insights/alerts")
|
||||
return {alert["key"] for alert in resp.json()["alerts"]}
|
||||
|
||||
|
||||
async def test_idle_cash_flags_a_stale_large_balance(client: AsyncClient, account_id, categories):
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(account_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE),
|
||||
)
|
||||
|
||||
assert "idle_cash" in await _alert_keys(client)
|
||||
|
||||
|
||||
async def test_idle_cash_ignores_a_recently_touched_account(client: AsyncClient, account_id, categories):
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(account_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE),
|
||||
)
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(account_id, type="expense", amount="1.00", category_id=categories["Groceries"]["id"], date=RECENT_DATE),
|
||||
)
|
||||
|
||||
assert "idle_cash" not in await _alert_keys(client)
|
||||
|
||||
|
||||
async def test_idle_cash_ignores_investment_accounts(client: AsyncClient, categories):
|
||||
investment = await client.post("/accounts", json={"name": "Brokerage", "type": "investment", "currency": "USD"})
|
||||
investment_id = investment.json()["id"]
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(investment_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE),
|
||||
)
|
||||
|
||||
assert "idle_cash" not in await _alert_keys(client)
|
||||
|
||||
|
||||
async def test_idle_cash_respects_custom_threshold(client: AsyncClient, account_id, categories):
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(account_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE),
|
||||
)
|
||||
assert "idle_cash" in await _alert_keys(client)
|
||||
|
||||
resp = await client.patch("/settings", json={"idle_cash_threshold_amount": "10000.00"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert "idle_cash" not in await _alert_keys(client)
|
||||
Reference in New Issue
Block a user