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
19 changes: 19 additions & 0 deletions agent/categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,22 @@
- Beauty: haircut, cosmetics, personal care
- Hydro: electricity and water bills
- Subscription: recurring digital subscriptions (Netflix, Spotify, etc.)"""

INCOME_CATEGORIES = [
"Salary",
"Interest",
"Rebate",
"Reimbursement",
"Transfer",
"Gift",
"Other",
]

INCOME_CATEGORY_HINTS = """Income categories and what they cover:
- Salary: payroll deposits, wages, freelance/contract income
- Interest: bank or investment interest earned
- Rebate: cashback, rewards, refunds from a merchant or card issuer
- Reimbursement: money paid back to you that matches a specific expense already logged in this tracker (e.g. a friend repaying their share of a dinner you logged as an expense)
- Transfer: money moved to you that isn't a reimbursement or gift and doesn't match a specific logged expense (e.g. a roommate's share of a bill you never logged yourself, a general e-transfer with no expense behind it)
- Gift: money given to you with no expectation of repayment
- Other: anything that doesn't fit the above"""
74 changes: 72 additions & 2 deletions agent/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ def _row(r: dict) -> dict:

_run("CREATE INDEX IF NOT EXISTS expenses_description_trgm_idx ON expenses USING gin (description gin_trgm_ops)")

_run("""
CREATE TABLE IF NOT EXISTS income (
id SERIAL PRIMARY KEY,
amount NUMERIC(10, 2),
category TEXT,
description TEXT,
date DATE,
user_id INTEGER REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")

_run("CREATE INDEX IF NOT EXISTS income_description_trgm_idx ON income USING gin (description gin_trgm_ops)")

_run("""
CREATE TABLE IF NOT EXISTS api_calls (
id SERIAL PRIMARY KEY,
Expand Down Expand Up @@ -118,9 +132,12 @@ def create_user(username: str, password_hash: str) -> None:
_run("INSERT INTO users (username, password_hash) VALUES (%s, %s)", (username, password_hash))


def _capitalize_description(description: str) -> str:
return description[0].upper() + description[1:] if description else description


def save_expense(amount: float, category: str, description: str, date: str, user_id: int = None) -> dict:
if description:
description = description[0].upper() + description[1:]
description = _capitalize_description(description)

# Run duplicate check + insert + flag as a single transaction so a crash
# between statements can't leave the DB in a half-applied state.
Expand Down Expand Up @@ -162,6 +179,17 @@ 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:
description = _capitalize_description(description)

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),
)
row = cur.fetchone()
return {"status": "saved", "id": row["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 Down Expand Up @@ -223,6 +251,48 @@ def get_expenses(
return [_row(r) for r in cur.fetchall()]


def get_income(
start_date: str = None,
end_date: str = None,
category: str = None,
logged_by: str = None,
min_amount: float = None,
max_amount: float = None,
description_contains: str = None,
) -> list[dict]:
query = """
SELECT i.id, i.amount, i.category, i.description, i.date, u.username AS logged_by
FROM income i
LEFT JOIN users u ON i.user_id = u.id
WHERE 1=1
"""
params = []
if start_date:
query += " AND i.date >= %s"
params.append(start_date)
if end_date:
query += " AND i.date <= %s"
params.append(end_date)
if category:
query += " AND LOWER(i.category) = LOWER(%s)"
params.append(category)
if logged_by:
query += " AND LOWER(u.username) = LOWER(%s)"
params.append(logged_by)
if min_amount is not None:
query += " AND i.amount >= %s"
params.append(min_amount)
if max_amount is not None:
query += " AND i.amount <= %s"
params.append(max_amount)
if description_contains:
query += " AND i.description ILIKE %s"
params.append(f"%{description_contains}%")
query += " ORDER BY i.date DESC, i.id DESC"
cur = _run(query, params)
return [_row(r) for r in cur.fetchall()]


def get_average_transaction(
category: str = None,
start_date: str = None,
Expand Down
31 changes: 25 additions & 6 deletions agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import anthropic
from dotenv import load_dotenv

from agent.categories import CATEGORY_HINTS
from agent.categories import CATEGORY_HINTS, INCOME_CATEGORY_HINTS
from agent.tools import TOOL_DEFINITIONS, TOOL_HANDLERS

load_dotenv()
Expand All @@ -25,6 +25,11 @@
### Choosing a category
For every expense, call find_similar_expense with its description (or vendor name) before deciding on a category — do this even if you're already confident what the category should be, since the user may have categorized this vendor differently than you'd assume. If it returns a match with a high score (roughly 0.35+), reuse that match's category directly. If it returns nothing useful, fall back to your own knowledge of the vendor (e.g. you know "Tims" means Tim Hortons, a coffee shop) to pick the best category. Only ask the user if you genuinely cannot infer a category either way.

## 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.
{income_category_hints}

## Querying expenses
For category/date-range summaries (e.g. "summarize this month", "breakdown by category"), call get_category_breakdown and report its numbers exactly as returned — never tally amounts yourself from get_expenses rows, that's unreliable over more than a couple of items.
For spending trends across multiple months (e.g. "has my dining spending gone up"), call get_monthly_trend.
Expand All @@ -37,6 +42,7 @@
For anything else — finding a specific expense, listing recent transactions, lookups before an update/delete — call get_expenses with appropriate filters. Use logged_by to filter by who logged the expense (e.g. "derek" or "kelly"), min_amount/max_amount for amount-range questions (e.g. "expenses over $100"), flagged to list everything still flagged for review, and description_contains for vendor/text lookups (e.g. "what did I spend at Costco").
For "what's my average X" / "how much do I typically spend on X" questions, call get_average_transaction and report its average exactly as returned — don't average raw rows yourself.
Present results clearly with a total where useful.
For questions about income received (e.g. "how much did I get paid this month", "show my income"), call get_income with appropriate filters.

## 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.
Expand All @@ -47,13 +53,14 @@
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.

## 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, compare line items across all the blocks in this message; if two entries share the same date, amount, and description, treat them as the same transaction and save it only once.
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.
To delete, first call get_expenses to find the ID, then call delete_expense. This only applies to expenses (see above).

{category_hints}"""

Expand All @@ -72,10 +79,12 @@
# Friendly status labels shown in the UI while a tool call is in flight.
TOOL_STATUS_LABELS = {
"save_expense": "Saving expense…",
"save_income": "Saving income…",
"find_similar_expense": "Checking vendor history…",
"update_expense": "Updating expense…",
"delete_expense": "Deleting expense…",
"get_expenses": "Looking up expenses…",
"get_income": "Looking up income…",
"get_category_breakdown": "Calculating breakdown…",
"get_monthly_trend": "Analyzing spending trend…",
"get_run_rate": "Projecting month-end total…",
Expand Down Expand Up @@ -132,7 +141,7 @@ def _run_tools(response_content: list, user_id: int, on_result=None) -> list:
for block in response_content:
if block.type == "tool_use":
kwargs = dict(block.input)
if block.name == "save_expense":
if block.name in ("save_expense", "save_income"):
kwargs["user_id"] = user_id
result = TOOL_HANDLERS[block.name](**kwargs)
print(f"[tool] {block.name}({kwargs}) -> {result}")
Expand Down Expand Up @@ -161,7 +170,12 @@ def chat(user_input: str, user_id: int, username: str = "user", images: list[dic
model=MODEL_DEFAULT,
max_tokens=2048,
timeout=API_TIMEOUT,
system=SYSTEM.format(today=date.today().isoformat(), username=username, category_hints=CATEGORY_HINTS),
system=SYSTEM.format(
today=date.today().isoformat(),
username=username,
category_hints=CATEGORY_HINTS,
income_category_hints=INCOME_CATEGORY_HINTS,
),
tools=TOOL_DEFINITIONS,
messages=messages[-HISTORY_LIMIT:],
)
Expand Down Expand Up @@ -192,7 +206,12 @@ def stream_chat(user_input: str, user_id: int, username: str = "user", images: l
model=MODEL_DEFAULT,
max_tokens=2048,
timeout=API_TIMEOUT,
system=SYSTEM.format(today=date.today().isoformat(), username=username, category_hints=CATEGORY_HINTS),
system=SYSTEM.format(
today=date.today().isoformat(),
username=username,
category_hints=CATEGORY_HINTS,
income_category_hints=INCOME_CATEGORY_HINTS,
),
tools=TOOL_DEFINITIONS,
messages=messages[-HISTORY_LIMIT:],
) as stream:
Expand Down
38 changes: 37 additions & 1 deletion agent/tools.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from agent.categories import CATEGORIES
from agent.categories import CATEGORIES, INCOME_CATEGORIES
from agent.db import (
delete_budget,
delete_expense,
Expand All @@ -7,6 +7,7 @@
get_budget_status,
get_category_breakdown,
get_expenses,
get_income,
get_monthly_trend,
get_recurring_expenses,
get_run_rate,
Expand All @@ -16,11 +17,13 @@
get_weekly_pace,
get_yoy_comparison,
save_expense,
save_income,
set_budget,
update_expense,
)

_category_enum = {"type": "string", "enum": CATEGORIES}
_income_category_enum = {"type": "string", "enum": INCOME_CATEGORIES}

TOOL_DEFINITIONS = [
{
Expand All @@ -37,6 +40,20 @@
"required": ["amount", "category", "description", "date"],
},
},
{
"name": "save_income",
"description": "Save a parsed income entry to the database",
"input_schema": {
"type": "object",
"properties": {
"amount": {"type": "number", "description": "Amount in dollars"},
"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"},
},
"required": ["amount", "category", "description", "date"],
},
},
{
"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 @@ -66,6 +83,23 @@
"required": [],
},
},
{
"name": "get_income",
"description": "Query income entries from the database. Use this to answer questions about income received.",
"input_schema": {
"type": "object",
"properties": {
"start_date": {"type": "string", "description": "Filter from this ISO date (inclusive)"},
"end_date": {"type": "string", "description": "Filter to this ISO date (inclusive)"},
"category": {"type": "string", "description": "Filter by category name"},
"logged_by": {"type": "string", "description": "Filter by the username who logged the income"},
"min_amount": {"type": "number", "description": "Only include income with amount >= this value"},
"max_amount": {"type": "number", "description": "Only include income with amount <= this value"},
"description_contains": {"type": "string", "description": "Filter to income entries whose description contains this text (case-insensitive substring match)"},
},
"required": [],
},
},
{
"name": "get_category_breakdown",
"description": "Get exact spending totals per category for a date range, computed in the database (not by manual addition). Use this for any 'summarize'/'breakdown by category' request — report the numbers it returns exactly as given, don't re-tally them yourself.",
Expand Down Expand Up @@ -263,8 +297,10 @@

TOOL_HANDLERS = {
"save_expense": save_expense,
"save_income": save_income,
"find_similar_expense": find_similar_expenses,
"get_expenses": get_expenses,
"get_income": get_income,
"get_category_breakdown": get_category_breakdown,
"get_monthly_trend": get_monthly_trend,
"get_recurring_expenses": get_recurring_expenses,
Expand Down
6 changes: 6 additions & 0 deletions api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
get_budget_status,
get_budgets,
get_expenses,
get_income,
get_recurring_expenses,
get_user_by_username,
increment_api_call_count,
Expand Down Expand Up @@ -122,6 +123,11 @@ def expenses_endpoint(user_id: int = Depends(get_current_user)):
return get_expenses()


@app.get("/income")
def income_endpoint(user_id: int = Depends(get_current_user)):
return get_income()


@app.get("/categories")
def categories_endpoint():
return CATEGORIES
Expand Down
33 changes: 33 additions & 0 deletions frontend/e2e/income.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's expenseRow helper.
const visibleText = (page, text) => page.getByText(text, { exact: true }).filter({ visible: true });

test.beforeEach(async ({ page }) => {
await login(page);
await goToExpensesTab(page);
});

test("toggles to the Income view and lists seeded income, with the expense-only total", async ({ page }) => {
// Defaults to the Expenses view.
await expect(visibleText(page, "Dinner at Pasta House")).toBeVisible();

await page.getByRole("tab", { name: "Income" }).click();

await expect(visibleText(page, "Payroll Deposit")).toBeVisible();
await expect(visibleText(page, "Cashback Reward")).toBeVisible();
await expect(page.getByText("Total: $2542.75")).toBeVisible();

// Expense-only filter controls are hidden in the income view.
await expect(page.getByPlaceholder("Search…")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Flagged only" })).toHaveCount(0);

// Switching back restores the expense list and its own total.
await page.getByRole("tab", { name: "Expenses" }).click();
await expect(visibleText(page, "Dinner at Pasta House")).toBeVisible();
await expect(visibleText(page, "Payroll Deposit")).toHaveCount(0);
});
Loading