From 8d83f6d3e0bbbd14656ab1ffbf6e16c6ba57a74d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:04:15 +0000 Subject: [PATCH] Add savings goals tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the "actually improves finances" feature the review flagged as missing beyond budgets — a savings goal (target amount, optional target date) with a manually-tracked contribution total, separate from expense/ income math on purpose: the household's net cash flow can go negative some months, which would make goal progress swing negative too and mean nothing to someone who never touched that money. Ships the same phased shape income tracking used: chat-driven creation and contribution (create_savings_goal, contribute_to_savings_goal, delete_savings_goal, get_savings_goals), a read-only progress section in the Expenses view for now, no edit dialog yet. savings_goals is household-shared like budgets (no user_id). Two real bugs caught in verification, not design gaps: - tests/conftest.py's TRUNCATE list didn't include the new table, so goals leaked across tests until the first run's failures pointed at it. - vite.config.js's dev proxy is an explicit path allowlist and /savings-goals was missing from it — the frontend fetch was silently hitting Vite itself instead of the backend, so the section never rendered in dev/e2e despite the API working correctly on its own. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Qz6JBNFwhgem5GY3i2BEMY --- agent/db.py | 74 ++++++++++++++ agent/main.py | 13 ++- agent/tools.py | 54 +++++++++++ api/server.py | 6 ++ frontend/e2e/savings-goals.spec.js | 27 ++++++ frontend/src/components/ExpenseTable.tsx | 8 +- .../src/components/SavingsGoalsSection.tsx | 44 +++++++++ frontend/src/types.ts | 10 ++ frontend/vite.config.js | 1 + scripts/seed_e2e_data.py | 16 +++- tests/conftest.py | 2 +- tests/test_api.py | 16 ++++ tests/test_db.py | 96 +++++++++++++++++++ tests/test_tools.py | 6 ++ 14 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 frontend/e2e/savings-goals.spec.js create mode 100644 frontend/src/components/SavingsGoalsSection.tsx diff --git a/agent/db.py b/agent/db.py index e41af26..2edc6a8 100644 --- a/agent/db.py +++ b/agent/db.py @@ -119,6 +119,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,)) @@ -881,6 +899,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 1c60167..fad4557 100644 --- a/agent/main.py +++ b/agent/main.py @@ -54,6 +54,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. @@ -62,7 +69,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. @@ -111,6 +118,10 @@ "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 7ab80cb..3d3e887 100644 --- a/api/server.py +++ b/api/server.py @@ -24,6 +24,7 @@ get_expenses, get_income, get_recurring_expenses, + get_savings_goals, get_user_by_username, increment_api_call_count, set_budget, @@ -175,6 +176,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 4546384..b123404 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: @@ -65,7 +73,7 @@ def _today_minus(days: int) -> str: def seed(): - _run("TRUNCATE expenses, income, budgets, users, api_calls RESTART IDENTITY CASCADE") + _run("TRUNCATE expenses, income, budgets, users, api_calls, savings_goals 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,)) @@ -121,6 +129,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 70f7502..962f8cc 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 RESTART IDENTITY CASCADE") + db._run("TRUNCATE expenses, income, budgets, users, api_calls, savings_goals RESTART IDENTITY CASCADE") yield diff --git a/tests/test_api.py b/tests/test_api.py index b57df42..34e1323 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 070709f..2372381 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}