diff --git a/agent/db.py b/agent/db.py index 669258e..4c40fe8 100644 --- a/agent/db.py +++ b/agent/db.py @@ -150,6 +150,24 @@ def _row(r: dict) -> dict: ) """) +# No user_id — a household-shared goal like budgets, not a per-user private +# one. current_amount is a manually-tracked accumulator ("add $200 to the +# vacation fund"), deliberately *not* derived from income minus expenses — +# the household's net cash flow can go negative some months, which would +# make goal progress swing negative too and mean nothing to a saver who +# never touched that money. +_run(""" + CREATE TABLE IF NOT EXISTS savings_goals ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + target_amount NUMERIC(10, 2) NOT NULL, + target_date DATE, + current_amount NUMERIC(10, 2) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ + ) +""") + def get_user_by_username(username: str) -> dict | None: cur = _run("SELECT * FROM users WHERE username = %s", (username,)) @@ -951,6 +969,62 @@ def delete_budget(category: str) -> dict: return {"status": "deleted"} +def create_savings_goal(name: str, target_amount: float, target_date: str = None) -> dict: + cur = _run( + "INSERT INTO savings_goals (name, target_amount, target_date) VALUES (%s, %s, %s) RETURNING id", + (name, target_amount, target_date), + ) + return {"status": "created", "id": cur.fetchone()["id"]} + + +def get_savings_goals() -> list[dict]: + cur = _run( + """ + SELECT id, name, target_amount, target_date, current_amount + FROM savings_goals + WHERE deleted_at IS NULL + ORDER BY created_at + """ + ) + goals = [] + for g in cur.fetchall(): + row = _row(dict(g)) + if row["target_date"] is not None: + row["target_date"] = str(row["target_date"]) + row["pct_complete"] = round(min(row["current_amount"] / row["target_amount"], 1) * 100, 1) if row["target_amount"] else 0.0 + goals.append(row) + return goals + + +def contribute_to_savings_goal(id: int, amount: float) -> dict: + """Adjust a goal's saved-so-far amount. amount can be negative (a + withdrawal); the running total is clipped at 0 either way, since a + negative progress bar wouldn't mean anything to a saver.""" + cur = _run( + """ + UPDATE savings_goals + SET current_amount = GREATEST(current_amount + %s, 0) + WHERE id = %s AND deleted_at IS NULL + RETURNING current_amount + """, + (amount, id), + ) + row = cur.fetchone() + if row is None: + return {"status": "error", "message": f"No savings goal with id {id}"} + return {"status": "updated", "current_amount": float(row["current_amount"])} + + +def delete_savings_goal(id: int) -> dict: + cur = _run( + "UPDATE savings_goals SET deleted_at = NOW() WHERE id = %s AND deleted_at IS NULL RETURNING id", + (id,), + ) + if cur.fetchone() is None: + return {"status": "not_found"} + return {"status": "deleted"} + + def get_api_call_count(user_id: int, date: str) -> int: cur = _run( "SELECT count FROM api_calls WHERE user_id = %s AND date = %s", diff --git a/agent/main.py b/agent/main.py index 8415923..c35bfe6 100644 --- a/agent/main.py +++ b/agent/main.py @@ -56,6 +56,13 @@ To set or change a monthly limit (e.g. "set my dining budget to $400"), call set_budget. To remove a budget entirely, call delete_budget. get_budget_status's spent figure is net of any linked reimbursements (see Logging income) — a $60 expense with a $30 linked reimbursement counts as $30 spent, reflecting the expense's own month regardless of when the reimbursement was recorded or linked. This differs from get_category_breakdown, get_monthly_trend, and the other historical-spend tools below, which always report the gross amount actually paid. If a user notices the numbers don't match for the same category/month, explain the difference rather than guessing one of them is wrong. +## Savings goals +Savings goals are separate from budgets — a budget is a monthly spending limit; a goal is money manually set aside toward a target (e.g. "save $3000 for a trip by December"), tracked as its own running total, not derived from income or expenses. +To create one, call create_savings_goal. To check progress ("how's my vacation fund doing"), call get_savings_goals and report the amount saved, target, and percent complete exactly as returned. +To add money toward a goal (e.g. "put $200 toward my vacation fund"), call contribute_to_savings_goal with a positive amount; use a negative amount if the user says they're taking money back out. First call get_savings_goals to find the right id if it isn't already known from context (e.g. just created in this conversation). +To remove a goal entirely, first call get_savings_goals to find the id, then call delete_savings_goal. +Logging or editing an expense/income entry never affects a savings goal, and contributing to a goal never creates an expense or income entry — don't call save_expense/save_income for a goal contribution, and don't call contribute_to_savings_goal just because an expense or income entry was logged. + ## Editing and flagging save_expense returns the new expense's id. If you need to immediately update the just-saved expense (e.g. flag it), use that id directly with update_expense — never call get_expenses to find it. For all other edits and flags, call get_expenses to find the right record first, then call update_expense. @@ -64,7 +71,7 @@ Income entries can be edited too. save_income returns the new income entry's id — use that id directly with update_income if editing immediately after saving, otherwise call get_income first to find the right id. update_income never changes an entry's reimbursement link (reimburses_expense_id); if the user wants to change what an income entry reimburses, use link_income_to_expense instead (see Logging income). -There are five tools split cleanly by table: update_expense and delete_expense only ever operate on expenses; update_income, delete_income, and link_income_to_expense only ever operate on income. Expense ids and income ids are separate sequences that collide (e.g. expense id 7 and income id 7 can both exist and refer to unrelated rows) — never pass an id you got from get_expenses or save_expense as the income_id/id argument to update_income, delete_income, or link_income_to_expense, and never pass an id you got from get_income or save_income as the id argument to update_expense or delete_expense. +There are five tools split cleanly by table: update_expense and delete_expense only ever operate on expenses; update_income, delete_income, and link_income_to_expense only ever operate on income. Expense ids and income ids are separate sequences that collide (e.g. expense id 7 and income id 7 can both exist and refer to unrelated rows) — never pass an id you got from get_expenses or save_expense as the income_id/id argument to update_income, delete_income, or link_income_to_expense, and never pass an id you got from get_income or save_income as the id argument to update_expense or delete_expense. Savings goal ids (from create_savings_goal/get_savings_goals) are yet another separate sequence — never pass one as an expense or income id, or vice versa. ## 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. @@ -131,6 +138,10 @@ def _get_session_lock(user_id: int) -> threading.Lock: "delete_budget": "Removing budget…", "get_average_transaction": "Calculating average…", "get_recurring_expenses": "Checking recurring charges…", + "create_savings_goal": "Creating savings goal…", + "get_savings_goals": "Checking savings goals…", + "contribute_to_savings_goal": "Updating savings goal…", + "delete_savings_goal": "Removing savings goal…", } diff --git a/agent/tools.py b/agent/tools.py index 67cdedf..5ab95e9 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,8 +1,11 @@ from agent.categories import CATEGORIES, INCOME_CATEGORIES from agent.db import ( + contribute_to_savings_goal, + create_savings_goal, delete_budget, delete_expense, delete_income, + delete_savings_goal, find_similar_expenses, get_average_transaction, get_budget_status, @@ -12,6 +15,7 @@ get_monthly_trend, get_recurring_expenses, get_run_rate, + get_savings_goals, get_top_expenses, get_user_breakdown, get_weekday_pattern, @@ -335,6 +339,51 @@ "required": ["category"], }, }, + { + "name": "create_savings_goal", + "description": "Create a new savings goal (e.g. 'save $3000 for a trip by December'). This is a household-shared goal, separate from budgets — it tracks money manually set aside toward a target, not spending.", + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Short name for the goal, e.g. 'Vacation Fund'"}, + "target_amount": {"type": "number", "description": "Target amount in dollars"}, + "target_date": {"type": "string", "description": "ISO date the goal is aimed for, if the user gave one — omit if open-ended"}, + }, + "required": ["name", "target_amount"], + }, + }, + { + "name": "get_savings_goals", + "description": "List all active savings goals with their target, amount saved so far, and percent complete. Use for 'how's my vacation fund doing' / 'what are my savings goals' questions.", + "input_schema": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + { + "name": "contribute_to_savings_goal", + "description": "Add (or, with a negative amount, withdraw) money from a goal's saved-so-far total — e.g. 'put $200 toward my vacation fund'. First call get_savings_goals to find the right id if it's not already known from context. This does not create an expense or income entry; it's a separate running total.", + "input_schema": { + "type": "object", + "properties": { + "id": {"type": "integer", "description": "The savings goal's id"}, + "amount": {"type": "number", "description": "Amount in dollars to add; negative to withdraw"}, + }, + "required": ["id", "amount"], + }, + }, + { + "name": "delete_savings_goal", + "description": "Delete a savings goal — e.g. the user finished it or no longer wants to track it. First call get_savings_goals to find the id.", + "input_schema": { + "type": "object", + "properties": { + "id": {"type": "integer", "description": "The savings goal's id to delete"}, + }, + "required": ["id"], + }, + }, ] TOOL_HANDLERS = { @@ -361,6 +410,10 @@ "get_budget_status": get_budget_status, "set_budget": set_budget, "delete_budget": delete_budget, + "create_savings_goal": create_savings_goal, + "get_savings_goals": get_savings_goals, + "contribute_to_savings_goal": contribute_to_savings_goal, + "delete_savings_goal": delete_savings_goal, } # Example prompts shown as chips in a fresh chat — one per analytics tool above, @@ -393,4 +446,5 @@ {"command": "/weekday", "label": "Weekday pattern", "prompt": "What days of the week do I spend the most on?", "tool": "get_weekday_pattern"}, {"command": "/average", "label": "Average transaction", "prompt": "What's my average transaction amount?", "tool": "get_average_transaction"}, {"command": "/budget", "label": "Budget status", "prompt": "Am I over budget on anything this month?", "tool": "get_budget_status"}, + {"command": "/goals", "label": "Savings goals", "prompt": "How are my savings goals doing?", "tool": "get_savings_goals"}, ] diff --git a/api/server.py b/api/server.py index 23188aa..ee34d01 100644 --- a/api/server.py +++ b/api/server.py @@ -24,6 +24,7 @@ get_income, get_insights, get_recurring_expenses, + get_savings_goals, get_user_by_username, increment_api_call_count, record_usage, @@ -178,6 +179,11 @@ def delete_budget_endpoint(category: str, user_id: int = Depends(get_current_use return delete_budget(category) +@app.get("/savings-goals") +def savings_goals_endpoint(user_id: int = Depends(get_current_user)): + return get_savings_goals() + + @app.get("/chat/suggestions") def chat_suggestions_endpoint(): return SUGGESTED_PROMPTS diff --git a/frontend/e2e/savings-goals.spec.js b/frontend/e2e/savings-goals.spec.js new file mode 100644 index 0000000..62c58be --- /dev/null +++ b/frontend/e2e/savings-goals.spec.js @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; +import { goToExpensesTab, login } from "./fixtures"; + +// Seed data (scripts/seed_e2e_data.py) plants one goal: Vacation Fund, +// target $3000, $750 contributed so far -> 25% complete. + +test.beforeEach(async ({ page }) => { + await login(page); + await goToExpensesTab(page); +}); + +test("shows a savings goal's name, progress, and target date", async ({ page }) => { + await expect(page.getByText("Savings Goals")).toBeVisible(); + await expect(page.getByText("Vacation Fund")).toBeVisible(); + await expect(page.getByText("$750.00 / $3000.00")).toBeVisible(); + await expect(page.getByText("Target:", { exact: false })).toBeVisible(); +}); + +test("the savings goals section persists across an Expenses/Income tab switch", async ({ page }) => { + await expect(page.getByText("Vacation Fund")).toBeVisible(); + + await page.getByRole("tab", { name: "Income" }).click(); + await expect(page.getByText("Vacation Fund")).toHaveCount(0); + + await page.getByRole("tab", { name: "Expenses" }).click(); + await expect(page.getByText("Vacation Fund")).toBeVisible(); +}); diff --git a/frontend/src/components/ExpenseTable.tsx b/frontend/src/components/ExpenseTable.tsx index 9a20364..d4ccf1f 100644 --- a/frontend/src/components/ExpenseTable.tsx +++ b/frontend/src/components/ExpenseTable.tsx @@ -11,7 +11,8 @@ import ExpenseList from "@/components/ExpenseList"; import IncomeList from "@/components/IncomeList"; import CategoryBreakdown from "@/components/CategoryBreakdown"; import RecurringSection from "@/components/RecurringSection"; -import type { AuthFetch, Budget, EditValues, Expense, Income, IncomeEditValues, RecurringCharge } from "@/types"; +import SavingsGoalsSection from "@/components/SavingsGoalsSection"; +import type { AuthFetch, Budget, EditValues, Expense, Income, IncomeEditValues, RecurringCharge, SavingsGoal } from "@/types"; export interface ExpenseTableProps { expenses: Expense[]; @@ -80,6 +81,7 @@ export default function ExpenseTable({ expenses, income = [], className = "", to const [incomeCategories, setIncomeCategories] = useState([]); const [budgets, setBudgets] = useState([]); const [recurring, setRecurring] = useState([]); + const [savingsGoals, setSavingsGoals] = useState([]); const [overrides, setOverrides] = useState>>({}); const [deletedIds, setDeletedIds] = useState>(() => new Set()); const [incomeOverrides, setIncomeOverrides] = useState>>({}); @@ -120,6 +122,7 @@ export default function ExpenseTable({ expenses, income = [], className = "", to useEffect(() => { fetchBudgets(); authFetch("/expenses/recurring").then((r) => r.json()).then(setRecurring); + authFetch("/savings-goals").then((r) => r.json()).then(setSavingsGoals); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -527,6 +530,9 @@ export default function ExpenseTable({ expenses, income = [], className = "", to /> )} + {/* ── Savings goals ── */} + {view === "expenses" && } + {/* ── Recurring charges ── */} {view === "expenses" && ( +
+ Savings Goals +
+
+ {goals.map((g) => ( +
+
+ {g.name} + + ${g.current_amount.toFixed(2)} / ${g.target_amount.toFixed(2)} + +
+
+
+
+ {g.target_date && ( +
+ Target: {formatDate(g.target_date)} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5dbddde..83bc22c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -42,6 +42,16 @@ export interface Budget { monthly_limit: number; } +export interface SavingsGoal { + id: number; + name: string; + target_amount: number; + // Nullable — a goal can be open-ended, with no date to hit the target by. + target_date: string | null; + current_amount: number; + pct_complete: number; +} + export interface BreakdownEntry { category: string; amount: number; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 429683d..dbf734f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -19,6 +19,7 @@ export default defineConfig({ '/categories': 'http://localhost:8000', '/budgets': 'http://localhost:8000', '/insights': 'http://localhost:8000', + '/savings-goals': 'http://localhost:8000', '/health': 'http://localhost:8000', }, }, diff --git a/scripts/seed_e2e_data.py b/scripts/seed_e2e_data.py index 6c90815..2d26a13 100644 --- a/scripts/seed_e2e_data.py +++ b/scripts/seed_e2e_data.py @@ -51,7 +51,15 @@ def _ensure_database_exists(): _ensure_database_exists() -from agent.db import _run, create_user, save_expense, save_income, set_budget # noqa: E402 +from agent.db import ( # noqa: E402 + _run, + contribute_to_savings_goal, + create_savings_goal, + create_user, + save_expense, + save_income, + set_budget, +) def _today_minus(days: int) -> str: @@ -75,7 +83,7 @@ def _days_ago(days: int) -> str: def seed(): - _run("TRUNCATE expenses, income, budgets, users, api_calls, usage_events RESTART IDENTITY CASCADE") + _run("TRUNCATE expenses, income, budgets, users, api_calls, savings_goals, usage_events 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,)) @@ -140,6 +148,10 @@ def seed(): housemate_id = cur.fetchone()["id"] save_expense(9.99, "Subscription", "Cloud Storage", _today_minus(1), user_id=housemate_id) + # A savings goal partway to its target, for the Savings Goals section. + goal = create_savings_goal("Vacation Fund", 3000, target_date="2026-12-25") + contribute_to_savings_goal(goal["id"], 750) + if __name__ == "__main__": seed() diff --git a/tests/conftest.py b/tests/conftest.py index b95db4f..5f5c50d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ @pytest.fixture(autouse=True) def clean_db(): - db._run("TRUNCATE expenses, income, budgets, users, api_calls, usage_events, chat_sessions RESTART IDENTITY CASCADE") + db._run("TRUNCATE expenses, income, budgets, users, api_calls, savings_goals, usage_events, chat_sessions RESTART IDENTITY CASCADE") yield diff --git a/tests/test_api.py b/tests/test_api.py index d273695..83a2c53 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -131,6 +131,22 @@ def test_budgets_crud_via_api(auth_headers): assert client.get("/budgets", headers=auth_headers).json() == [] +# --- savings goals ----------------------------------------------------------- + +def test_savings_goals_endpoint_requires_auth(): + assert client.get("/savings-goals").status_code == 401 + + +def test_savings_goals_endpoint_returns_created_goals(auth_headers): + db.create_savings_goal("Vacation Fund", 1000) + + result = client.get("/savings-goals", headers=auth_headers).json() + + assert len(result) == 1 + assert result[0]["name"] == "Vacation Fund" + assert result[0]["pct_complete"] == 0.0 + + # --- insights ---------------------------------------------------------------- def test_insights_endpoint_omits_categories_with_no_budget(auth_headers, user_id): diff --git a/tests/test_db.py b/tests/test_db.py index ea32912..b94e6f2 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -31,6 +31,102 @@ def test_get_budgets_ordered_alphabetically(): assert [b["category"] for b in db.get_budgets()] == ["Dining", "Travel"] +# --- savings goals ----------------------------------------------------- + +def test_create_and_get_savings_goal(): + result = db.create_savings_goal("Vacation Fund", 3000) + assert result["status"] == "created" + + goals = db.get_savings_goals() + assert len(goals) == 1 + assert goals[0]["id"] == result["id"] + assert goals[0]["name"] == "Vacation Fund" + assert goals[0]["target_amount"] == 3000.0 + assert goals[0]["current_amount"] == 0.0 + assert goals[0]["target_date"] is None + assert goals[0]["pct_complete"] == 0.0 + + +def test_create_savings_goal_with_target_date(): + db.create_savings_goal("Vacation Fund", 3000, target_date="2026-12-25") + assert db.get_savings_goals()[0]["target_date"] == "2026-12-25" + + +def test_contribute_to_savings_goal_increases_current_amount(): + goal = db.create_savings_goal("Vacation Fund", 1000) + + result = db.contribute_to_savings_goal(goal["id"], 200) + + assert result["status"] == "updated" + assert result["current_amount"] == 200.0 + goals = db.get_savings_goals() + assert goals[0]["current_amount"] == 200.0 + assert goals[0]["pct_complete"] == 20.0 + + +def test_contribute_negative_amount_withdraws(): + goal = db.create_savings_goal("Vacation Fund", 1000) + db.contribute_to_savings_goal(goal["id"], 200) + + result = db.contribute_to_savings_goal(goal["id"], -50) + + assert result["current_amount"] == 150.0 + + +def test_contribute_clips_at_zero_instead_of_going_negative(): + goal = db.create_savings_goal("Vacation Fund", 1000) + db.contribute_to_savings_goal(goal["id"], 50) + + result = db.contribute_to_savings_goal(goal["id"], -200) + + assert result["current_amount"] == 0.0 + + +def test_contribute_to_nonexistent_goal_returns_error(): + result = db.contribute_to_savings_goal(999999, 50) + assert result["status"] == "error" + + +def test_pct_complete_caps_at_100_when_overfunded(): + goal = db.create_savings_goal("Vacation Fund", 1000) + db.contribute_to_savings_goal(goal["id"], 1500) + + assert db.get_savings_goals()[0]["pct_complete"] == 100.0 + + +def test_delete_savings_goal_is_soft_delete(): + goal = db.create_savings_goal("Vacation Fund", 1000) + + result = db.delete_savings_goal(goal["id"]) + + assert result["status"] == "deleted" + assert db.get_savings_goals() == [] + # Row survives with deleted_at set, not hard-deleted. + cur = db._run("SELECT deleted_at FROM savings_goals WHERE id = %s", (goal["id"],)) + assert cur.fetchone()["deleted_at"] is not None + + +def test_delete_savings_goal_twice_returns_not_found_second_time(): + goal = db.create_savings_goal("Vacation Fund", 1000) + db.delete_savings_goal(goal["id"]) + + assert db.delete_savings_goal(goal["id"])["status"] == "not_found" + + +def test_contribute_to_deleted_goal_returns_error(): + goal = db.create_savings_goal("Vacation Fund", 1000) + db.delete_savings_goal(goal["id"]) + + assert db.contribute_to_savings_goal(goal["id"], 50)["status"] == "error" + + +def test_get_savings_goals_ordered_by_creation(): + db.create_savings_goal("First Goal", 100) + db.create_savings_goal("Second Goal", 200) + + assert [g["name"] for g in db.get_savings_goals()] == ["First Goal", "Second Goal"] + + # --- save_expense / duplicate detection --------------------------------- def test_save_expense_basic(user_id): diff --git a/tests/test_tools.py b/tests/test_tools.py index a46e434..562af89 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -33,3 +33,9 @@ def test_save_income_and_get_income_are_registered(): def test_link_income_to_expense_is_registered(): assert "link_income_to_expense" in TOOL_HANDLERS assert "link_income_to_expense" in {t["name"] for t in TOOL_DEFINITIONS} + + +def test_savings_goal_tools_are_registered(): + names = {"create_savings_goal", "get_savings_goals", "contribute_to_savings_goal", "delete_savings_goal"} + assert names <= set(TOOL_HANDLERS) + assert names <= {t["name"] for t in TOOL_DEFINITIONS}