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
74 changes: 74 additions & 0 deletions agent/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,))
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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…",
}


Expand Down
54 changes: 54 additions & 0 deletions agent/tools.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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"},
]
6 changes: 6 additions & 0 deletions api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
get_income,
get_insights,
get_recurring_expenses,
get_savings_goals,
get_user_by_username,
increment_api_call_count,
record_usage,
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions frontend/e2e/savings-goals.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
8 changes: 7 additions & 1 deletion frontend/src/components/ExpenseTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -80,6 +81,7 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
const [incomeCategories, setIncomeCategories] = useState<string[]>([]);
const [budgets, setBudgets] = useState<Budget[]>([]);
const [recurring, setRecurring] = useState<RecurringCharge[]>([]);
const [savingsGoals, setSavingsGoals] = useState<SavingsGoal[]>([]);
const [overrides, setOverrides] = useState<Record<number, Partial<Expense>>>({});
const [deletedIds, setDeletedIds] = useState<Set<number>>(() => new Set());
const [incomeOverrides, setIncomeOverrides] = useState<Record<number, Partial<Income>>>({});
Expand Down Expand Up @@ -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
}, []);

Expand Down Expand Up @@ -527,6 +530,9 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
/>
)}

{/* ── Savings goals ── */}
{view === "expenses" && <SavingsGoalsSection goals={savingsGoals} />}

{/* ── Recurring charges ── */}
{view === "expenses" && (
<RecurringSection
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/components/SavingsGoalsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { SavingsGoal } from "@/types";
import { formatDate } from "@/components/expenseTableFormat";

export interface SavingsGoalsSectionProps {
goals: SavingsGoal[];
}

// Read-only for now, matching how income shipped its first phase — creating
// and contributing to a goal happens via chat (create_savings_goal /
// contribute_to_savings_goal), not a dialog here yet.
export default function SavingsGoalsSection({ goals }: SavingsGoalsSectionProps) {
if (goals.length === 0) return null;

return (
<div className="px-4 py-3 md:px-5 border-b border-border/50">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
Savings Goals
</div>
<div className="space-y-2.5">
{goals.map((g) => (
<div key={g.id}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="font-medium text-foreground truncate">{g.name}</span>
<span className="text-muted-foreground tabular-nums shrink-0">
${g.current_amount.toFixed(2)} / ${g.target_amount.toFixed(2)}
</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${g.pct_complete}%` }}
/>
</div>
{g.target_date && (
<div className="text-[10px] text-muted-foreground mt-1">
Target: {formatDate(g.target_date)}
</div>
)}
</div>
))}
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions frontend/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
Expand Down
Loading