diff --git a/agent/db.py b/agent/db.py index 1b5e320..4ed9bb5 100644 --- a/agent/db.py +++ b/agent/db.py @@ -45,8 +45,9 @@ def _run(sql: str, params=None): def _row(r: dict) -> dict: """Normalize Postgres types to JSON-serializable Python types.""" d = dict(r) - if "amount" in d and d["amount"] is not None: - d["amount"] = float(d["amount"]) + for key, value in d.items(): + if value is not None and (key == "amount" or key.endswith("_amount")): + d[key] = float(value) if "date" in d and d["date"] is not None: d["date"] = str(d["date"]) return d @@ -95,6 +96,8 @@ def _row(r: dict) -> dict: ) """) +_run("ALTER TABLE income ADD COLUMN IF NOT EXISTS reimburses_expense_id INTEGER REFERENCES expenses(id)") + _run("CREATE INDEX IF NOT EXISTS income_description_trgm_idx ON income USING gin (description gin_trgm_ops)") _run(""" @@ -179,17 +182,48 @@ def save_expense(amount: float, category: str, description: str, date: str, user return {"status": "saved", "id": row["id"]} -def save_income(amount: float, category: str, description: str, date: str, user_id: int = None) -> dict: +def save_income( + amount: float, + category: str, + description: str, + date: str, + user_id: int = None, + reimburses_expense_id: int = None, +) -> dict: description = _capitalize_description(description) + if reimburses_expense_id is not None: + cur = _run("SELECT id FROM expenses WHERE id = %s AND deleted_at IS NULL", (reimburses_expense_id,)) + if cur.fetchone() is None: + return {"status": "error", "message": f"No expense with id {reimburses_expense_id}"} + cur = _run( - "INSERT INTO income (amount, category, description, date, user_id) VALUES (%s, %s, %s, %s, %s) RETURNING id", - (amount, category, description, date, user_id), + "INSERT INTO income (amount, category, description, date, user_id, reimburses_expense_id) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id", + (amount, category, description, date, user_id, reimburses_expense_id), ) row = cur.fetchone() return {"status": "saved", "id": row["id"]} +def link_income_to_expense(income_id: int, expense_id: int = None) -> dict: + """Set or clear the expense an income row is a reimbursement for. expense_id=None unlinks.""" + if expense_id is not None: + cur = _run("SELECT id FROM expenses WHERE id = %s AND deleted_at IS NULL", (expense_id,)) + if cur.fetchone() is None: + return {"status": "error", "message": f"No expense with id {expense_id}"} + + cur = _run( + "UPDATE income SET reimburses_expense_id = %s WHERE id = %s RETURNING id", + (expense_id, income_id), + ) + row = cur.fetchone() + if row is None: + return {"status": "error", "message": f"No income entry with id {income_id}"} + if expense_id is not None: + return {"status": "linked", "income_id": income_id, "expense_id": expense_id} + return {"status": "unlinked", "income_id": income_id} + + def find_similar_expenses(description: str, limit: int = 3) -> list[dict]: """Fuzzy-match past expense descriptions via trigram similarity, for vendor category recall.""" cur = _run( @@ -216,7 +250,8 @@ def get_expenses( description_contains: str = None, ) -> list[dict]: query = """ - SELECT e.id, e.amount, e.category, e.description, e.date, e.flagged, u.username AS logged_by + SELECT e.id, e.amount, e.category, e.description, e.date, e.flagged, u.username AS logged_by, + EXISTS (SELECT 1 FROM income i WHERE i.reimburses_expense_id = e.id) AS reimbursed FROM expenses e LEFT JOIN users u ON e.user_id = u.id WHERE e.deleted_at IS NULL @@ -261,9 +296,12 @@ def get_income( description_contains: str = None, ) -> list[dict]: query = """ - SELECT i.id, i.amount, i.category, i.description, i.date, u.username AS logged_by + SELECT i.id, i.amount, i.category, i.description, i.date, u.username AS logged_by, + i.reimburses_expense_id, e.description AS reimburses_expense_description, + e.amount AS reimburses_expense_amount FROM income i LEFT JOIN users u ON i.user_id = u.id + LEFT JOIN expenses e ON e.id = i.reimburses_expense_id WHERE 1=1 """ params = [] diff --git a/agent/main.py b/agent/main.py index 2e0a3ef..1c3ac0d 100644 --- a/agent/main.py +++ b/agent/main.py @@ -27,7 +27,8 @@ ## Logging income When the user describes money they received, or when parsing pasted bank/transaction text, credit/deposit lines should be classified as income (call save_income) rather than expense — never call save_expense for money coming in. Use the same date resolution rules and the same title-case "[What] at [Venue]"-style description convention as expenses. -For example: a line like "Jul 5 Payroll Deposit +2,500.00" is a credit, so call save_income with category Salary and a description like "Payroll Deposit". A line like "Jul 6 E-Transfer from Jake +40.00" — if it matches an expense you can find already logged (e.g. call get_expenses to check for a same-amount-range dinner/shared cost with Jake), use category Reimbursement and a description like "Reimbursement from Jake". If it doesn't match anything already logged (e.g. a roommate's bill share you never logged as your own expense, or you can't find a matching expense), use category Transfer instead — don't guess Reimbursement without a logged expense to point to. +For example: a line like "Jul 5 Payroll Deposit +2,500.00" is a credit, so call save_income with category Salary and a description like "Payroll Deposit". A line like "Jul 6 E-Transfer from Jake +40.00" — if it matches an expense you can find already logged (e.g. call get_expenses to check for a same-amount-range dinner/shared cost with Jake), use category Reimbursement, a description like "Reimbursement from Jake", and pass that expense's id as reimburses_expense_id on save_income so it's linked. If it doesn't match anything already logged (e.g. a roommate's bill share you never logged as your own expense, or you can't find a confident single match), use category Transfer instead and omit reimburses_expense_id — don't guess a link without a specific logged expense to point to. +If the user later says a reimbursement was for a different expense than logged, or wasn't linked but should be (e.g. "that $40 from Jake was actually for the dinner on the 4th, not the 6th"), call get_income and/or get_expenses to confirm the exact ids, then call link_income_to_expense to fix it. Pass expense_id omitted (or null) to unlink. {income_category_hints} ## Querying expenses @@ -53,7 +54,7 @@ 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 yet — 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. If the user asks to edit or delete an income entry, tell them that isn't supported yet instead of guessing. +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. ## 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. @@ -85,6 +86,7 @@ "delete_expense": "Deleting expense…", "get_expenses": "Looking up expenses…", "get_income": "Looking up income…", + "link_income_to_expense": "Linking reimbursement…", "get_category_breakdown": "Calculating breakdown…", "get_monthly_trend": "Analyzing spending trend…", "get_run_rate": "Projecting month-end total…", diff --git a/agent/tools.py b/agent/tools.py index f6f685b..f91b7ad 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -16,6 +16,7 @@ get_weekday_pattern, get_weekly_pace, get_yoy_comparison, + link_income_to_expense, save_expense, save_income, set_budget, @@ -50,10 +51,23 @@ "category": {**_income_category_enum, "description": "Income category"}, "description": {"type": "string", "description": "Short description of the income"}, "date": {"type": "string", "description": "ISO date, e.g. 2025-01-13"}, + "reimburses_expense_id": {"type": "integer", "description": "If category is Reimbursement and you found the specific expense this repays via get_expenses, pass its id here to link them. Omit if no confident match was found."}, }, "required": ["amount", "category", "description", "date"], }, }, + { + "name": "link_income_to_expense", + "description": "Link (or unlink) an existing income entry as a reimbursement for a specific expense. Use this to correct or add a link after the fact — e.g. the user says an income entry was actually a reimbursement for a specific expense, or that an existing link is wrong. First call get_income and get_expenses to confirm the exact ids — income and expense ids are separate sequences that can collide.", + "input_schema": { + "type": "object", + "properties": { + "income_id": {"type": "integer", "description": "The income entry's id"}, + "expense_id": {"type": "integer", "description": "The expense id it reimburses. Omit (or pass null) to unlink."}, + }, + "required": ["income_id"], + }, + }, { "name": "find_similar_expense", "description": "Fuzzy-search past expense descriptions for vendor/category recall (e.g. matching 'Starbucks' against a previously logged 'Coffee at Starbucks'). Call this before asking the user for a category when logging a new expense. Returns an empty list if nothing matches closely — in that case, categorize from your own knowledge of the vendor instead of asking.", @@ -301,6 +315,7 @@ "find_similar_expense": find_similar_expenses, "get_expenses": get_expenses, "get_income": get_income, + "link_income_to_expense": link_income_to_expense, "get_category_breakdown": get_category_breakdown, "get_monthly_trend": get_monthly_trend, "get_recurring_expenses": get_recurring_expenses, diff --git a/api/server.py b/api/server.py index a2ea858..8429ff2 100644 --- a/api/server.py +++ b/api/server.py @@ -215,7 +215,7 @@ def expenses_export(user_id: int = Depends(get_current_user)): rows = get_expenses() output = io.StringIO() writer = csv.DictWriter( - output, fieldnames=["id", "date", "description", "category", "amount", "logged_by", "flagged"] + output, fieldnames=["id", "date", "description", "category", "amount", "logged_by", "flagged", "reimbursed"] ) writer.writeheader() writer.writerows(rows) diff --git a/frontend/e2e/income.spec.js b/frontend/e2e/income.spec.js index 2ef5318..61d7541 100644 --- a/frontend/e2e/income.spec.js +++ b/frontend/e2e/income.spec.js @@ -20,7 +20,7 @@ test("toggles to the Income view and lists seeded income, with the expense-only await expect(visibleText(page, "Payroll Deposit")).toBeVisible(); await expect(visibleText(page, "Cashback Reward")).toBeVisible(); - await expect(page.getByText("Total: $2542.75")).toBeVisible(); + await expect(page.getByText("Total: $2572.75")).toBeVisible(); // Expense-only filter controls are hidden in the income view. await expect(page.getByPlaceholder("Search…")).toHaveCount(0); diff --git a/frontend/e2e/reimbursement.spec.js b/frontend/e2e/reimbursement.spec.js new file mode 100644 index 0000000..387c62b --- /dev/null +++ b/frontend/e2e/reimbursement.spec.js @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; +import { goToExpensesTab, login } from "./fixtures"; + +// The mobile card and desktop table both exist in the DOM at all times (CSS +// media queries just hide whichever doesn't match the viewport), so text +// matches twice — this helper scopes to the one actually visible at the +// current viewport. Same pattern as expenses.spec.js/income.spec.js. +const visibleText = (page, text) => page.getByText(text, { exact: true }).filter({ visible: true }); + +test.beforeEach(async ({ page }) => { + await login(page); + await goToExpensesTab(page); +}); + +test("shows a Reimbursed badge on the linked expense", async ({ page }) => { + await expect(visibleText(page, "Dinner at Luigi's")).toBeVisible(); + await expect(page.getByTitle("Reimbursed").filter({ visible: true })).toBeVisible(); + + // An expense with no linked reimbursement shows no badge. + await expect(visibleText(page, "Gas at Shell")).toBeVisible(); +}); + +test("shows what it repays on the linked income row", async ({ page }) => { + await page.getByRole("tab", { name: "Income" }).click(); + + await expect(visibleText(page, "Reimbursement from Jake")).toBeVisible(); + await expect(page.getByText("repays Dinner at Luigi's", { exact: false }).filter({ visible: true })).toBeVisible(); + await expect(page.getByText("$60.00", { exact: false }).filter({ visible: true })).toBeVisible(); + + // Income with no link shows no "repays" note. + await expect(visibleText(page, "Payroll Deposit")).toBeVisible(); + await expect(page.getByText("repays", { exact: false })).toHaveCount(2); // one per viewport (mobile + desktop), both for the same linked row +}); diff --git a/frontend/src/components/ExpenseTable.jsx b/frontend/src/components/ExpenseTable.jsx index 2277cc4..53cd2f1 100644 --- a/frontend/src/components/ExpenseTable.jsx +++ b/frontend/src/components/ExpenseTable.jsx @@ -876,6 +876,11 @@ export default function ExpenseTable({ expenses, income = [], className = "", to userActive={userFilter === e.logged_by} /> {e.description} + {e.reimbursed && ( + + Reimbursed + + )} ${e.amount.toFixed(2)}