From b88a63cc491dab5a285509c87f354213f0a33c3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 05:46:31 +0000 Subject: [PATCH] Add income tracking Phase 3: net budget math + income edit/delete Closes the last two deferred pieces from the income tracking plan: - get_budget_status now nets a category's spent total against any income linked to its expenses via reimburses_expense_id, clipped at 0 per-expense so an over-reimbursed expense can't drag a category negative. This differs deliberately from get_category_breakdown and the other historical-spend tools, which still report gross amounts actually paid - the SYSTEM prompt now explains the distinction so the model doesn't get caught flat-footed if a user notices the two don't match for the same transactions. - update_income/delete_income (hard delete - income has no FK dependents, unlike expenses) with matching tools and PATCH/DELETE /income/{id} endpoints. update_income deliberately never touches reimburses_expense_id, keeping that link_income_to_expense's exclusive job. - A "Net" cash-flow stat (all-time income minus expenses) next to the existing Total in ExpenseTable's header. Deliberately did NOT add a cash-flow chat tool/slash command - the roadmap already flags the analytics command surface as possibly over-built, so this stays UI-only. Ran the same three-angle adversarial review as prior phases. Found and fixed a "four tools" miscount in the SYSTEM prompt (five tools are actually split by table now) and added the gross-vs-net budget clarification above. Everything else checked out: parameterized queries, no dangling references from the hard delete (nothing else FKs to income.id), netCashFlow correctly recomputes after chat-driven saves, and the unscoped-by-income-date netting in get_budget_status is intentional - it ties the reimbursement to the expense's own economics, not to whenever the income happened to get recorded, consistent with how update_expense can already retroactively change a "closed" month's numbers. Verified: 150 backend tests, 68 e2e tests, lint, and build all green. --- agent/db.py | 52 +++++++++++++++- agent/main.py | 11 +++- agent/tools.py | 30 +++++++++ api/server.py | 33 +++++++++- frontend/e2e/cashflow.spec.js | 26 ++++++++ frontend/src/components/ExpenseTable.jsx | 20 +++++- tests/test_db.py | 77 ++++++++++++++++++++++++ 7 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 frontend/e2e/cashflow.spec.js diff --git a/agent/db.py b/agent/db.py index 4ed9bb5..6e8247f 100644 --- a/agent/db.py +++ b/agent/db.py @@ -773,6 +773,46 @@ def delete_expense(id: int) -> dict: return {"status": "deleted"} +def update_income( + id: int, + amount: float = None, + category: str = None, + description: str = None, + date: str = None, +) -> dict: + # Deliberately does not touch reimburses_expense_id — that stays the exclusive + # job of link_income_to_expense so the two tools' responsibilities don't overlap. + fields, params = [], [] + if amount is not None: + fields.append("amount = %s") + params.append(amount) + if category is not None: + fields.append("category = %s") + params.append(category) + if description is not None: + fields.append("description = %s") + params.append(description) + if date is not None: + fields.append("date = %s") + params.append(date) + + if not fields: + return {"status": "nothing to update"} + + params.append(id) + cur = _run(f"UPDATE income SET {', '.join(fields)} WHERE id = %s", params) + if cur.rowcount == 0: + return {"status": "not_found"} + return {"status": "updated"} + + +def delete_income(id: int) -> dict: + cur = _run("DELETE FROM income WHERE id = %s RETURNING id", (id,)) + if cur.fetchone() is None: + return {"status": "not_found"} + return {"status": "deleted"} + + def get_budgets() -> list[dict]: cur = _run("SELECT category, monthly_limit FROM budgets ORDER BY category") return [{"category": r["category"], "monthly_limit": float(r["monthly_limit"])} for r in cur.fetchall()] @@ -784,15 +824,23 @@ def get_budget_status(category: str = None, month: str = None) -> list[dict]: month_start = ref.replace(day=1) month_end = _shift_month(month_start, 1) - # Single JOIN instead of N+1 per-category queries. + # Single JOIN instead of N+1 per-category queries. Each expense's contribution + # to spent is netted against any income linked to it via reimburses_expense_id, + # clipped at 0 so a fully-or-over-reimbursed expense can't drag spent negative. query = """ SELECT b.category, b.monthly_limit, - COALESCE(SUM(e.amount), 0) AS spent + COALESCE(SUM(GREATEST(e.amount - COALESCE(r.reimbursed, 0), 0)), 0) AS spent FROM budgets b LEFT JOIN expenses e ON LOWER(e.category) = LOWER(b.category) AND e.date >= %s AND e.date < %s AND e.deleted_at IS NULL + LEFT JOIN ( + SELECT reimburses_expense_id, SUM(amount) AS reimbursed + FROM income + WHERE reimburses_expense_id IS NOT NULL + GROUP BY reimburses_expense_id + ) r ON r.reimburses_expense_id = e.id WHERE 1=1 """ params: list = [month_start.isoformat(), month_end.isoformat()] diff --git a/agent/main.py b/agent/main.py index 1c3ac0d..4cada71 100644 --- a/agent/main.py +++ b/agent/main.py @@ -48,20 +48,25 @@ ## Budgets For budget questions (e.g. "am I over budget", "how much do I have left for groceries"), call get_budget_status. Only categories with a budget configured are returned — if a category isn't in the result, tell the user it has no budget set rather than guessing a limit. To set or change a monthly limit (e.g. "set my dining budget to $400"), call set_budget. To remove a budget entirely, call delete_budget. +get_budget_status's spent figure is net of any linked reimbursements (see Logging income) — a $60 expense with a $30 linked reimbursement counts as $30 spent, reflecting the expense's own month regardless of when the reimbursement was recorded or linked. This differs from get_category_breakdown, get_monthly_trend, and the other historical-spend tools below, which always report the gross amount actually paid. If a user notices the numbers don't match for the same category/month, explain the difference rather than guessing one of them is wrong. ## Editing and flagging save_expense returns the new expense's id. If you need to immediately update the just-saved expense (e.g. flag it), use that id directly with update_expense — never call get_expenses to find it. For all other edits and flags, call get_expenses to find the right record first, then call update_expense. If the user refers to "the last one", "that expense", or similar, call get_expenses (no filters, most recent first) to identify it by context. Flagging marks an expense for follow-up (flagged=true). Unflagging clears it (flagged=false). -update_expense and delete_expense only ever operate on expenses. Income entries can't be edited or deleted, only relinked via link_income_to_expense (see Logging income) — expense ids and income ids are separate sequences that can collide, so never call update_expense/delete_expense with an id you got from get_income or save_income, and never call link_income_to_expense with an id you got from get_expenses or save_expense. If the user asks to edit or delete an income entry's amount/date/description, tell them that isn't supported yet instead of guessing. + +Income entries can be edited too. save_income returns the new income entry's id — use that id directly with update_income if editing immediately after saving, otherwise call get_income first to find the right id. update_income never changes an entry's reimbursement link (reimburses_expense_id); if the user wants to change what an income entry reimburses, use link_income_to_expense instead (see Logging income). + +There are five tools split cleanly by table: update_expense and delete_expense only ever operate on expenses; update_income, delete_income, and link_income_to_expense only ever operate on income. Expense ids and income ids are separate sequences that collide (e.g. expense id 7 and income id 7 can both exist and refer to unrelated rows) — never pass an id you got from get_expenses or save_expense as the income_id/id argument to update_income, delete_income, or link_income_to_expense, and never pass an id you got from get_income or save_income as the id argument to update_expense or delete_expense. ## Receipt / screenshot scanning When the user's message contains extracted text from one or more images (prefixed with "[Extracted text from image...]"), parse each block for expense line items. Read dates and amounts exactly as shown — do not approximate. For category, use your best judgement. When multiple images are attached, the same transaction can appear in more than one block — this happens when someone screenshots overlapping date ranges of the same account. Before calling save_expense or save_income, compare line items across all the blocks in this message; if two entries (expense or income) share the same date, amount, and description, treat them as the same transaction and save it only once. This applies just as much to pasted bank/transaction text as to screenshots — check for overlap within a single pasted statement too. ## Deleting -To delete, first call get_expenses to find the ID, then call delete_expense. This only applies to expenses (see above). +To delete an expense, first call get_expenses to find the ID, then call delete_expense. +To delete an income entry, first call get_income to find the ID, then call delete_income. Remember expense ids and income ids are separate sequences — see above. {category_hints}""" @@ -84,6 +89,8 @@ "find_similar_expense": "Checking vendor history…", "update_expense": "Updating expense…", "delete_expense": "Deleting expense…", + "update_income": "Updating income…", + "delete_income": "Deleting income…", "get_expenses": "Looking up expenses…", "get_income": "Looking up income…", "link_income_to_expense": "Linking reimbursement…", diff --git a/agent/tools.py b/agent/tools.py index f91b7ad..67cdedf 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -2,6 +2,7 @@ from agent.db import ( delete_budget, delete_expense, + delete_income, find_similar_expenses, get_average_transaction, get_budget_status, @@ -21,6 +22,7 @@ save_income, set_budget, update_expense, + update_income, ) _category_enum = {"type": "string", "enum": CATEGORIES} @@ -258,6 +260,32 @@ "required": ["id"], }, }, + { + "name": "update_income", + "description": "Update fields on an existing income entry by its ID. First call get_income to find the ID, or use the ID returned by save_income directly if editing immediately after saving. This never changes the entry's reimbursement link — use link_income_to_expense for that.", + "input_schema": { + "type": "object", + "properties": { + "id": {"type": "integer", "description": "The income entry's ID to update"}, + "amount": {"type": "number", "description": "New amount in dollars"}, + "category": {**_income_category_enum, "description": "New category"}, + "description": {"type": "string", "description": "New description"}, + "date": {"type": "string", "description": "New ISO date"}, + }, + "required": ["id"], + }, + }, + { + "name": "delete_income", + "description": "Delete an income entry by its ID. First call get_income to find the ID.", + "input_schema": { + "type": "object", + "properties": { + "id": {"type": "integer", "description": "The income entry's ID to delete"}, + }, + "required": ["id"], + }, + }, { "name": "get_average_transaction", "description": "Get the average transaction amount and transaction count for a category and/or date range, computed in the database. Use for 'what's my average coffee purchase' / 'how much do I typically spend on X' questions — report the average exactly as returned, don't compute it yourself from raw rows.", @@ -327,6 +355,8 @@ "get_yoy_comparison": get_yoy_comparison, "update_expense": update_expense, "delete_expense": delete_expense, + "update_income": update_income, + "delete_income": delete_income, "get_average_transaction": get_average_transaction, "get_budget_status": get_budget_status, "set_budget": set_budget, diff --git a/api/server.py b/api/server.py index 8429ff2..6369fe2 100644 --- a/api/server.py +++ b/api/server.py @@ -13,10 +13,11 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, field_validator -from agent.categories import CATEGORIES +from agent.categories import CATEGORIES, INCOME_CATEGORIES from agent.db import ( delete_budget, delete_expense, + delete_income, get_api_call_count, get_budget_status, get_budgets, @@ -27,6 +28,7 @@ increment_api_call_count, set_budget, update_expense, + update_income, ) from agent.main import clear_session, stream_chat from agent.tools import COMMAND_PROMPTS, SUGGESTED_PROMPTS @@ -209,6 +211,35 @@ def delete_expense_endpoint(id: int, user_id: int = Depends(get_current_user)): return result +class IncomeUpdateRequest(BaseModel): + amount: float | None = None + category: str | None = None + description: str | None = None + date: str | None = None + + @field_validator("category") + @classmethod + def validate_category(cls, v): + if v is not None and v not in INCOME_CATEGORIES: + raise ValueError(f"Invalid category: {v}") + return v + + +@app.patch("/income/{id}") +def update_income_endpoint(id: int, req: IncomeUpdateRequest, user_id: int = Depends(get_current_user)): + result = update_income(id, req.amount, req.category, req.description, req.date) + if result.get("status") == "not_found": + raise HTTPException(status_code=404, detail="Income entry not found") + return result + + +@app.delete("/income/{id}") +def delete_income_endpoint(id: int, user_id: int = Depends(get_current_user)): + result = delete_income(id) + if result.get("status") == "not_found": + raise HTTPException(status_code=404, detail="Income entry not found") + return result + @app.get("/expenses/export") def expenses_export(user_id: int = Depends(get_current_user)): diff --git a/frontend/e2e/cashflow.spec.js b/frontend/e2e/cashflow.spec.js new file mode 100644 index 0000000..c22c38d --- /dev/null +++ b/frontend/e2e/cashflow.spec.js @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test"; +import { goToExpensesTab, login } from "./fixtures"; + +// Expected net = all-time income minus all-time expenses (both users, since +// expenses/income are shared household data) from scripts/seed_e2e_data.py: +// income = 2500.00 + 42.75 + 30.00 = 2572.75 +// expense = 1850.00 + 142.37 + 38.50 + 9.50 + 54.20 + 210.00 + 120.00 +// + 310.00 + 89.99 + 12.50 + 22.00 + 15.99 + 60.00 (e2e_test) +// + 9.99 (e2e_housemate's Cloud Storage) = 2945.04 +// net = 2572.75 - 2945.04 = -372.29 +const EXPECTED_NET = "-$372.29"; + +test.beforeEach(async ({ page }) => { + await login(page); + await goToExpensesTab(page); +}); + +test("shows the net cash flow figure, unaffected by the Expenses/Income toggle", async ({ page }) => { + await expect(page.getByText(`Net: ${EXPECTED_NET}`, { exact: false }).filter({ visible: true })).toBeVisible(); + + await page.getByRole("tab", { name: "Income" }).click(); + await expect(page.getByText(`Net: ${EXPECTED_NET}`, { exact: false }).filter({ visible: true })).toBeVisible(); + + await page.getByRole("tab", { name: "Expenses" }).click(); + await expect(page.getByText(`Net: ${EXPECTED_NET}`, { exact: false }).filter({ visible: true })).toBeVisible(); +}); diff --git a/frontend/src/components/ExpenseTable.jsx b/frontend/src/components/ExpenseTable.jsx index 53cd2f1..cb9cbc9 100644 --- a/frontend/src/components/ExpenseTable.jsx +++ b/frontend/src/components/ExpenseTable.jsx @@ -420,6 +420,12 @@ export default function ExpenseTable({ expenses, income = [], className = "", to const animatedIncomeTotal = useAnimatedNumber(incomeTotal); const displayTotal = view === "expenses" ? animatedTotal : animatedIncomeTotal; + // Net cash flow, all-time and unfiltered by the month/search controls (those + // only apply to the Expenses view's own total) — uses `items` rather than the + // raw `expenses` prop so an optimistic delete-with-undo is reflected instantly. + const netCashFlow = incomeTotal - items.reduce((sum, e) => sum + e.amount, 0); + const animatedNetCashFlow = useAnimatedNumber(netCashFlow); + const budgetMap = useMemo(() => { const map = {}; budgets.forEach((b) => { map[b.category] = b.monthly_limit; }); @@ -700,8 +706,18 @@ export default function ExpenseTable({ expenses, income = [], className = "", to )} - - Total: ${displayTotal.toFixed(2)} + + + Total: ${displayTotal.toFixed(2)} + + = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"} + > + Net: + {animatedNetCashFlow >= 0 ? "+" : "-"}${Math.abs(animatedNetCashFlow).toFixed(2)} + + diff --git a/tests/test_db.py b/tests/test_db.py index 10f256e..9ec47a7 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -685,6 +685,62 @@ def test_save_income_nonexistent_reimburses_expense_id_returns_error(user_id): assert db.get_income() == [] +# --- update_income / delete_income ---------------------------------------- + +def test_update_income_partial_fields(user_id): + saved = add_income(user_id, 2500, "Salary", "Payroll", "2026-06-01") + db.update_income(saved["id"], amount=2600) + + row = db.get_income()[0] + assert row["amount"] == 2600.0 + assert row["category"] == "Salary" # untouched fields preserved + assert row["description"] == "Payroll" + + +def test_update_income_category_description_date(user_id): + saved = add_income(user_id, 40, "Gift", "Birthday gift", "2026-06-01") + db.update_income(saved["id"], category="Rebate", description="Cashback", date="2026-06-05") + + row = db.get_income()[0] + assert row["category"] == "Rebate" + assert row["description"] == "Cashback" + assert row["date"] == "2026-06-05" + assert row["amount"] == 40.0 # untouched field preserved + + +def test_update_income_with_no_fields_is_noop(user_id): + saved = add_income(user_id, 40, "Gift", "Birthday gift", "2026-06-01") + result = db.update_income(saved["id"]) + assert result == {"status": "nothing to update"} + + +def test_update_income_does_not_touch_reimburses_expense_id(user_id): + expense = add_expense(user_id, 42, "Dining", "Dinner", "2026-06-01") + income_id = add_income(user_id, 21, "Reimbursement", "From Jake", "2026-06-02")["id"] + db.link_income_to_expense(income_id, expense["id"]) + + db.update_income(income_id, amount=25) + + assert db.get_income()[0]["reimburses_expense_id"] == expense["id"] + + +def test_update_income_not_found(): + result = db.update_income(999999, amount=10) + assert result == {"status": "not_found"} + + +def test_delete_income(user_id): + saved = add_income(user_id, 40, "Gift", "Birthday gift", "2026-06-01") + result = db.delete_income(saved["id"]) + assert result == {"status": "deleted"} + assert db.get_income() == [] + + +def test_delete_income_not_found(): + result = db.delete_income(999999) + assert result == {"status": "not_found"} + + # --- get_average_transaction -------------------------------------------- def test_average_transaction_for_category(user_id): @@ -755,6 +811,27 @@ def test_budget_status_category_with_no_budget_returns_empty(): assert db.get_budget_status(category="Dining") == [] +def test_budget_status_nets_out_linked_reimbursement(user_id): + db.set_budget("Dining", 200) + expense = add_expense(user_id, 60, "Dining", "Dinner", "2026-06-05") + income_id = add_income(user_id, 30, "Reimbursement", "From Jake", "2026-06-06")["id"] + db.link_income_to_expense(income_id, expense["id"]) + + result = db.get_budget_status(month="2026-06") + assert result[0]["spent"] == 30.0 + + +def test_budget_status_over_reimbursement_clips_at_zero(user_id): + db.set_budget("Dining", 200) + expense = add_expense(user_id, 60, "Dining", "Dinner", "2026-06-05") + income_id = add_income(user_id, 90, "Reimbursement", "From Jake", "2026-06-06")["id"] + db.link_income_to_expense(income_id, expense["id"]) + + result = db.get_budget_status(month="2026-06") + assert result[0]["spent"] == 0.0 + assert result[0]["remaining"] == 200.0 + + # --- api_calls / rate limiting -------------------------------------------- def test_api_call_count_starts_at_zero(user_id):