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)}