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: 45 additions & 7 deletions agent/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("""
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down
6 changes: 4 additions & 2 deletions agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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…",
Expand Down
15 changes: 15 additions & 0 deletions agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
get_weekday_pattern,
get_weekly_pace,
get_yoy_comparison,
link_income_to_expense,
save_expense,
save_income,
set_budget,
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/income.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions frontend/e2e/reimbursement.spec.js
Original file line number Diff line number Diff line change
@@ -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
});
34 changes: 31 additions & 3 deletions frontend/src/components/ExpenseTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,11 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
userActive={userFilter === e.logged_by}
/>
<span className="flex-1 text-sm font-medium text-foreground truncate">{e.description}</span>
{e.reimbursed && (
<span title="Reimbursed" className="text-[10px] px-1.5 py-0.5 rounded-md font-medium bg-primary/10 text-primary shrink-0">
Reimbursed
</span>
)}
<span className="text-sm font-semibold text-foreground tabular-nums shrink-0">${e.amount.toFixed(2)}</span>
<button
onClick={(ev) => toggleFlag(e, ev)}
Expand Down Expand Up @@ -914,7 +919,16 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
onClick={() => openEdit(e)}
>
<td className="px-4 py-3 text-xs text-muted-foreground tabular-nums whitespace-nowrap">{formatDate(e.date)}</td>
<td className="px-3 py-3 text-sm text-foreground">{e.description}</td>
<td className="px-3 py-3 text-sm text-foreground">
<span className="inline-flex items-center gap-1.5">
<span>{e.description}</span>
{e.reimbursed && (
<span title="Reimbursed" className="text-[10px] px-1.5 py-0.5 rounded-md font-medium bg-primary/10 text-primary shrink-0">
Reimbursed
</span>
)}
</span>
</td>
<td className="px-3 py-3">
<CategoryBadge
category={e.category}
Expand Down Expand Up @@ -964,7 +978,14 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
{items.map((i) => (
<div key={i.id} className="flex items-center gap-3 px-4 py-3 border-b border-border/50">
<CategoryBadge category={i.category} small />
<span className="flex-1 text-sm font-medium text-foreground truncate">{i.description}</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-foreground truncate">{i.description}</span>
{i.reimburses_expense_id && (
<span className="block text-xs text-muted-foreground truncate">
repays {i.reimburses_expense_description} · ${i.reimburses_expense_amount.toFixed(2)}
</span>
)}
</span>
<span className="text-sm font-semibold text-foreground tabular-nums shrink-0">${i.amount.toFixed(2)}</span>
</div>
))}
Expand All @@ -988,7 +1009,14 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
) : income.map((i) => (
<tr key={i.id} className="border-b border-border/50">
<td className="px-4 py-3 text-xs text-muted-foreground tabular-nums whitespace-nowrap">{formatDate(i.date)}</td>
<td className="px-3 py-3 text-sm text-foreground">{i.description}</td>
<td className="px-3 py-3 text-sm text-foreground">
<span>{i.description}</span>
{i.reimburses_expense_id && (
<span className="block text-xs text-muted-foreground">
repays {i.reimburses_expense_description} · ${i.reimburses_expense_amount.toFixed(2)}
</span>
)}
</td>
<td className="px-3 py-3"><CategoryBadge category={i.category} /></td>
<td className="px-3 py-3 text-right text-sm font-medium text-foreground tabular-nums">${i.amount.toFixed(2)}</td>
</tr>
Expand Down
9 changes: 9 additions & 0 deletions scripts/seed_e2e_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ def seed():
for amount, category, description, day in income:
save_income(amount, category, description, day, user_id=user_id)

# A reimbursed expense + its linked income row — a separate pair from the
# rest of the seed data so linking it never touches a description any
# other spec's exact-text assertion depends on.
reimbursed_expense = save_expense(60.00, "Dining", "Dinner at Luigi's", _today_minus(4), user_id=user_id)
save_income(
30.00, "Reimbursement", "Reimbursement from Jake", _today_minus(3),
user_id=user_id, reimburses_expense_id=reimbursed_expense["id"],
)

create_user(E2E_HOUSEMATE_USERNAME, bcrypt.hashpw(E2E_HOUSEMATE_PASSWORD.encode(), bcrypt.gensalt()).decode())
cur = _run("SELECT id FROM users WHERE username = %s", (E2E_HOUSEMATE_USERNAME,))
housemate_id = cur.fetchone()["id"]
Expand Down
Loading