diff --git a/agent/categories.py b/agent/categories.py index dc18c66..eb7a01e 100644 --- a/agent/categories.py +++ b/agent/categories.py @@ -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""" diff --git a/agent/db.py b/agent/db.py index 0ad21a4..1b5e320 100644 --- a/agent/db.py +++ b/agent/db.py @@ -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, @@ -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. @@ -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( @@ -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, diff --git a/agent/main.py b/agent/main.py index bcd440b..2e0a3ef 100644 --- a/agent/main.py +++ b/agent/main.py @@ -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() @@ -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. @@ -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. @@ -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}""" @@ -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…", @@ -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}") @@ -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:], ) @@ -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: diff --git a/agent/tools.py b/agent/tools.py index 64f3781..f6f685b 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,4 +1,4 @@ -from agent.categories import CATEGORIES +from agent.categories import CATEGORIES, INCOME_CATEGORIES from agent.db import ( delete_budget, delete_expense, @@ -7,6 +7,7 @@ get_budget_status, get_category_breakdown, get_expenses, + get_income, get_monthly_trend, get_recurring_expenses, get_run_rate, @@ -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 = [ { @@ -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.", @@ -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.", @@ -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, diff --git a/api/server.py b/api/server.py index d55b540..a2ea858 100644 --- a/api/server.py +++ b/api/server.py @@ -21,6 +21,7 @@ get_budget_status, get_budgets, get_expenses, + get_income, get_recurring_expenses, get_user_by_username, increment_api_call_count, @@ -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 diff --git a/frontend/e2e/income.spec.js b/frontend/e2e/income.spec.js new file mode 100644 index 0000000..2ef5318 --- /dev/null +++ b/frontend/e2e/income.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'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); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ec54466..d08f579 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,6 +25,7 @@ export default function App() { const [username, setUsername] = useState(() => localStorage.getItem("username") || ""); const [expenses, setExpenses] = useState([]); const [expensesLoading, setExpensesLoading] = useState(true); + const [income, setIncome] = useState([]); const [highlightIds, setHighlightIds] = useState(() => new Set()); const highlightTimeoutRef = useRef(null); // Diff baseline for row highlighting — a ref (not the `expenses` state @@ -64,6 +65,7 @@ export default function App() { setToken(null); setUsername(""); setExpenses([]); + setIncome([]); }; const fetchExpenses = async () => { @@ -104,6 +106,17 @@ export default function App() { setExpensesLoading(false); }; + // Simpler than fetchExpenses — read-only view (Phase 1), no highlight/diff + // bookkeeping needed since there's no edit/delete flow to flash yet. + const fetchIncome = async () => { + const res = await fetch("/income", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 401) { handleLogout(); return; } + const data = await res.json(); + setIncome(data); + }; + useEffect(() => { if (!token) return; let ignore = false; @@ -122,6 +135,17 @@ export default function App() { setExpensesLoading(false); } })(); + (async () => { + const res = await fetch("/income", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 401) { + if (!ignore) handleLogout(); + return; + } + const data = await res.json(); + if (!ignore) setIncome(data); + })(); return () => { ignore = true; }; }, [token]); @@ -152,7 +176,7 @@ export default function App() { }> { fetchExpenses(); setActiveTab("chat"); }} + onExpenseChange={() => { fetchExpenses(); fetchIncome(); setActiveTab("chat"); }} token={token} username={username} onLogout={handleLogout} @@ -164,6 +188,7 @@ export default function App() { { const res = fetch(url, { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${token}` } }); res.then((r) => { if (r.status === 401) onUnauthorized(); }); @@ -342,6 +342,9 @@ export default function ExpenseTable({ expenses, className = "", token, username setLastSeenId(maxId); }; + // Read-only Phase 1 income view — toggled alongside the expense list + // rather than as a third top-level mobile tab. + const [view, setView] = useState("expenses"); const [selectedMonthOverride, setSelectedMonthOverride] = useState(null); const [flaggedOnly, setFlaggedOnly] = useState(false); const [editingExpense, setEditingExpense] = useState(null); @@ -411,6 +414,12 @@ export default function ExpenseTable({ expenses, className = "", token, username const animatedTotal = useAnimatedNumber(total); const emptyMessage = items.length === 0 ? "No expenses yet" : "No expenses match your filters"; + // Income view has no filters in Phase 1 — total is just the sum of + // whatever the API returned (already sorted date DESC server-side). + const incomeTotal = income.reduce((sum, i) => sum + i.amount, 0); + const animatedIncomeTotal = useAnimatedNumber(incomeTotal); + const displayTotal = view === "expenses" ? animatedTotal : animatedIncomeTotal; + const budgetMap = useMemo(() => { const map = {}; budgets.forEach((b) => { map[b.category] = b.monthly_limit; }); @@ -648,8 +657,31 @@ export default function ExpenseTable({ expenses, className = "", token, username
- All Expenses - {categoryFilter && ( +
+ + +
+ {view === "expenses" && categoryFilter && ( )} - {userFilter && ( + {view === "expenses" && userFilter && (
- Total: ${animatedTotal.toFixed(2)} + Total: ${displayTotal.toFixed(2)}
-
- + {view === "expenses" && ( +
+ - + -
- - setSearchQuery(e.target.value)} - placeholder="Search…" - className="h-8 text-sm pl-7 pr-7" - /> - {searchQuery && ( - - )} +
+ + setSearchQuery(e.target.value)} + placeholder="Search…" + className="h-8 text-sm pl-7 pr-7" + /> + {searchQuery && ( + + )} +
-
+ )}
{/* New activity since you left — household awareness, derived purely from data already fetched via /expenses (no new endpoint/infra). */} - {newFromOthers.length > 0 && ( + {view === "expenses" && newFromOthers.length > 0 && (
{newFromOthers.length} new expense{newFromOthers.length !== 1 ? "s" : ""} from{" "} @@ -740,7 +774,7 @@ export default function ExpenseTable({ expenses, className = "", token, username
{/* ── Category breakdown ── */} - {breakdown.length > 0 && ( + {view === "expenses" && breakdown.length > 0 && (
Breakdown
@@ -780,7 +814,7 @@ export default function ExpenseTable({ expenses, className = "", token, username )} {/* ── Recurring charges ── */} - {recurring.length > 0 && ( + {view === "expenses" && recurring.length > 0 && (
+
} {/* ── Desktop table ── */} - + {view === "expenses" &&
@@ -911,7 +945,56 @@ export default function ExpenseTable({ expenses, className = "", token, username ))} -
Date
+ } + + {/* ── Income mobile card list — read-only Phase 1, no swipe/edit/delete ── */} + {view === "income" &&
+ {income.length === 0 ? ( +

No income yet

+ ) : income.reduce((groups, i) => { + const last = groups[groups.length - 1]; + if (!last || last.date !== i.date) groups.push({ date: i.date, items: [i] }); + else last.items.push(i); + return groups; + }, []).map(({ date, items }) => ( +
+
+ {formatSectionDate(date)} +
+ {items.map((i) => ( +
+ + {i.description} + ${i.amount.toFixed(2)} +
+ ))} +
+ ))} +
} + + {/* ── Income desktop table — read-only Phase 1, no edit/delete ── */} + {view === "income" && + + + + + + + + + + {income.length === 0 ? ( + + ) : income.map((i) => ( + + + + + + + ))} + +
DateDescriptionCategoryAmount
No income yet
{formatDate(i.date)}{i.description}${i.amount.toFixed(2)}
}
{showScrollToTop && ( diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 74526c4..429683d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -15,6 +15,7 @@ export default defineConfig({ '/auth': 'http://localhost:8000', '/chat': 'http://localhost:8000', '/expenses': 'http://localhost:8000', + '/income': 'http://localhost:8000', '/categories': 'http://localhost:8000', '/budgets': 'http://localhost:8000', '/insights': 'http://localhost:8000', diff --git a/scripts/seed_e2e_data.py b/scripts/seed_e2e_data.py index 5ec5cf1..3dc003d 100644 --- a/scripts/seed_e2e_data.py +++ b/scripts/seed_e2e_data.py @@ -51,7 +51,7 @@ def _ensure_database_exists(): _ensure_database_exists() -from agent.db import _run, create_user, save_expense, set_budget # noqa: E402 +from agent.db import _run, create_user, save_expense, save_income, set_budget # noqa: E402 def _today_minus(days: int) -> str: @@ -65,7 +65,7 @@ def _today_minus(days: int) -> str: def seed(): - _run("TRUNCATE expenses, budgets, users, api_calls RESTART IDENTITY CASCADE") + _run("TRUNCATE expenses, income, budgets, users, api_calls RESTART IDENTITY CASCADE") create_user(E2E_USERNAME, bcrypt.hashpw(E2E_PASSWORD.encode(), bcrypt.gensalt()).decode()) cur = _run("SELECT id FROM users WHERE username = %s", (E2E_USERNAME,)) @@ -98,6 +98,15 @@ def seed(): for amount, category, description, day in expenses: save_expense(amount, category, description, day, user_id=user_id) + # Income: kept small and distinct from every expense description so e2e + # assertions can't accidentally match the wrong list. + income = [ + (2500.00, "Salary", "Payroll Deposit", _today_minus(15)), + (42.75, "Rebate", "Cashback Reward", _today_minus(5)), + ] + for amount, category, description, day in income: + save_income(amount, category, description, day, user_id=user_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"] diff --git a/tests/conftest.py b/tests/conftest.py index 27763c7..70f7502 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ @pytest.fixture(autouse=True) def clean_db(): - db._run("TRUNCATE expenses, budgets, users, api_calls RESTART IDENTITY CASCADE") + db._run("TRUNCATE expenses, income, budgets, users, api_calls RESTART IDENTITY CASCADE") yield diff --git a/tests/test_api.py b/tests/test_api.py index 5d3105b..8ad9a49 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,6 +18,10 @@ def add_expense(user_id, amount, category, description, day): return db.save_expense(amount, category, description, day, user_id=user_id) +def add_income(user_id, amount, category, description, day): + return db.save_income(amount, category, description, day, user_id=user_id) + + # --- /auth/login ------------------------------------------------------------ def test_login_succeeds_with_correct_password(): @@ -85,6 +89,27 @@ def test_expenses_endpoint_returns_data_when_authenticated(auth_headers, user_id assert len(response.json()) == 1 +# --- income ------------------------------------------------------------ + +def test_income_endpoint_requires_auth(): + assert client.get("/income").status_code == 401 # no Authorization header at all + + +def test_income_endpoint_rejects_invalid_token(): + response = client.get("/income", headers={"Authorization": "Bearer not-a-valid-jwt"}) + assert response.status_code == 401 + + +def test_income_endpoint_returns_data_when_authenticated(auth_headers, user_id): + add_income(user_id, 2500, "Salary", "Payroll Deposit", "2026-06-01") + + response = client.get("/income", headers=auth_headers) + + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["category"] == "Salary" + + # --- budgets --------------------------------------------------------------- def test_budgets_crud_via_api(auth_headers): diff --git a/tests/test_categories.py b/tests/test_categories.py new file mode 100644 index 0000000..80a86d2 --- /dev/null +++ b/tests/test_categories.py @@ -0,0 +1,54 @@ +from agent.categories import ( + CATEGORIES, + CATEGORY_HINTS, + INCOME_CATEGORIES, + INCOME_CATEGORY_HINTS, +) + + +def _hint_names(hints: str) -> list[str]: + """Extract category names, in order, from '- Name: description' bullet lines.""" + names = [] + for line in hints.splitlines(): + line = line.strip() + if line.startswith("- "): + names.append(line[2:].split(":", 1)[0].strip()) + return names + + +def _hint_map(hints: str) -> dict[str, str]: + result = {} + for line in hints.splitlines(): + line = line.strip() + if line.startswith("- "): + name, _, desc = line[2:].partition(":") + result[name.strip()] = desc.strip() + return result + + +# --- expense CATEGORIES / CATEGORY_HINTS pairing --------------------------- + +def test_expense_categories_each_have_exactly_one_hint_line(): + assert _hint_names(CATEGORY_HINTS) == CATEGORIES + + +# --- income INCOME_CATEGORIES / INCOME_CATEGORY_HINTS pairing -------------- + +def test_income_categories_each_have_exactly_one_hint_line(): + assert _hint_names(INCOME_CATEGORY_HINTS) == INCOME_CATEGORIES + + +def test_income_categories_is_a_closed_list_distinct_from_expense_categories(): + # Income and expense categories are separate closed lists — no accidental sharing. + assert set(INCOME_CATEGORIES).isdisjoint(CATEGORIES) + + +def test_reimbursement_and_transfer_hints_are_distinct(): + hints = _hint_map(INCOME_CATEGORY_HINTS) + assert "Reimbursement" in hints + assert "Transfer" in hints + assert hints["Reimbursement"] != hints["Transfer"] + # Reimbursement is tied to an expense you already logged; Transfer explicitly + # is not tied to one. + assert "already logged" in hints["Reimbursement"].lower() + assert "isn't a reimbursement" in hints["Transfer"].lower() diff --git a/tests/test_db.py b/tests/test_db.py index 8738bde..f3e7755 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -511,6 +511,87 @@ def test_get_expenses_description_contains_no_match_returns_empty(user_id): assert db.get_expenses(description_contains="nonexistent vendor") == [] +# --- save_income / get_income -------------------------------------------- + +def add_income(user_id, amount, category, description, day): + return db.save_income(amount, category, description, day, user_id=user_id) + + +def test_save_income_basic(user_id): + result = add_income(user_id, 2500, "Salary", "Payroll Deposit", "2026-06-10") + assert result["status"] == "saved" + + income = db.get_income() + assert len(income) == 1 + assert income[0]["amount"] == 2500.0 + assert income[0]["category"] == "Salary" + assert income[0]["description"] == "Payroll Deposit" + assert income[0]["logged_by"] == "testuser" + + +def test_save_income_capitalizes_description(user_id): + add_income(user_id, 40, "Reimbursement", "reimbursement from jake", "2026-06-10") + assert db.get_income()[0]["description"] == "Reimbursement from jake" + + +def test_get_income_filters_by_category(user_id): + add_income(user_id, 2500, "Salary", "Payroll", "2026-06-01") + add_income(user_id, 40, "Gift", "Birthday gift", "2026-06-01") + + result = db.get_income(category="salary") # case-insensitive + assert len(result) == 1 + assert result[0]["category"] == "Salary" + + +def test_get_income_filters_by_logged_by(user_id): + db.create_user("alice", "hash") + alice_id = db.get_user_by_username("alice")["id"] + add_income(user_id, 100, "Gift", "Mine", "2026-06-01") + add_income(alice_id, 200, "Gift", "Alice's", "2026-06-01") + + result = db.get_income(logged_by="ALICE") # case-insensitive + assert len(result) == 1 + assert result[0]["description"] == "Alice's" + + +def test_get_income_filters_by_amount_range(user_id): + add_income(user_id, 5, "Gift", "Small", "2026-06-01") + add_income(user_id, 50, "Gift", "Medium", "2026-06-01") + add_income(user_id, 500, "Gift", "Big", "2026-06-01") + + result = db.get_income(min_amount=10, max_amount=100) + assert [r["description"] for r in result] == ["Medium"] + + +def test_get_income_filters_by_date_range(user_id): + add_income(user_id, 10, "Gift", "In range", "2026-06-15") + add_income(user_id, 20, "Gift", "Out of range", "2026-07-01") + + result = db.get_income(start_date="2026-06-01", end_date="2026-06-30") + assert [r["description"] for r in result] == ["In range"] + + +def test_get_income_description_contains_matches_substring(user_id): + add_income(user_id, 10, "Rebate", "Cashback from Amex", "2026-06-01") + add_income(user_id, 20, "Gift", "Birthday gift", "2026-06-02") + + result = db.get_income(description_contains="amex") + assert len(result) == 1 + assert result[0]["description"] == "Cashback from Amex" + + +def test_get_income_ordered_by_date_desc(user_id): + add_income(user_id, 10, "Gift", "Earlier", "2026-06-01") + add_income(user_id, 20, "Gift", "Later", "2026-06-05") + + result = db.get_income() + assert [r["description"] for r in result] == ["Later", "Earlier"] + + +def test_get_income_empty_returns_empty_list(): + assert db.get_income() == [] + + # --- get_average_transaction -------------------------------------------- def test_average_transaction_for_category(user_id): diff --git a/tests/test_tools.py b/tests/test_tools.py index f0fb515..196df4a 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,4 +1,4 @@ -from agent.tools import COMMAND_PROMPTS, TOOL_HANDLERS +from agent.tools import COMMAND_PROMPTS, TOOL_DEFINITIONS, TOOL_HANDLERS def test_command_prompts_reference_real_tools(): @@ -13,3 +13,18 @@ def test_command_prompts_have_unique_commands(): def test_command_prompts_start_with_slash(): assert all(c["command"].startswith("/") for c in COMMAND_PROMPTS) + + +# --- TOOL_DEFINITIONS / TOOL_HANDLERS pairing (CLAUDE.md invariant #1) ----- +# Schema and handler must change together — a tool with a schema but no +# registered handler would fail at call time, not at import/lint time. + +def test_every_tool_definition_has_a_registered_handler(): + for tool in TOOL_DEFINITIONS: + assert tool["name"] in TOOL_HANDLERS, f"{tool['name']} has a schema but no TOOL_HANDLERS entry" + + +def test_save_income_and_get_income_are_registered(): + assert "save_income" in TOOL_HANDLERS + assert "get_income" in TOOL_HANDLERS + assert {t["name"] for t in TOOL_DEFINITIONS} >= {"save_income", "get_income"}