Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions agent/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand All @@ -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()]
Expand Down
11 changes: 9 additions & 2 deletions agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"""

Expand All @@ -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…",
Expand Down
30 changes: 30 additions & 0 deletions agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from agent.db import (
delete_budget,
delete_expense,
delete_income,
find_similar_expenses,
get_average_transaction,
get_budget_status,
Expand All @@ -21,6 +22,7 @@
save_income,
set_budget,
update_expense,
update_income,
)

_category_enum = {"type": "string", "enum": CATEGORIES}
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 32 additions & 1 deletion api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)):
Expand Down
26 changes: 26 additions & 0 deletions frontend/e2e/cashflow.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
20 changes: 18 additions & 2 deletions frontend/src/components/ExpenseTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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; });
Expand Down Expand Up @@ -700,8 +706,18 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
</button>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
Total: <span className="font-semibold text-foreground">${displayTotal.toFixed(2)}</span>
<span className="text-xs text-muted-foreground shrink-0 flex flex-col items-end gap-0.5">
<span>
Total: <span className="font-semibold text-foreground">${displayTotal.toFixed(2)}</span>
</span>
<span
title="Net cash flow, all time: total income minus total expenses"
className={animatedNetCashFlow >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}
>
Net: <span className="font-semibold">
{animatedNetCashFlow >= 0 ? "+" : "-"}${Math.abs(animatedNetCashFlow).toFixed(2)}
</span>
</span>
</span>
</div>

Expand Down
77 changes: 77 additions & 0 deletions tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down