diff --git a/agent/db.py b/agent/db.py index 6e8247f..e41af26 100644 --- a/agent/db.py +++ b/agent/db.py @@ -97,6 +97,7 @@ def _row(r: dict) -> dict: """) _run("ALTER TABLE income ADD COLUMN IF NOT EXISTS reimburses_expense_id INTEGER REFERENCES expenses(id)") +_run("ALTER TABLE income ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ") _run("CREATE INDEX IF NOT EXISTS income_description_trgm_idx ON income USING gin (description gin_trgm_ops)") @@ -213,7 +214,7 @@ def link_income_to_expense(income_id: int, expense_id: int = None) -> dict: return {"status": "error", "message": f"No expense with id {expense_id}"} cur = _run( - "UPDATE income SET reimburses_expense_id = %s WHERE id = %s RETURNING id", + "UPDATE income SET reimburses_expense_id = %s WHERE id = %s AND deleted_at IS NULL RETURNING id", (expense_id, income_id), ) row = cur.fetchone() @@ -251,7 +252,7 @@ def get_expenses( ) -> list[dict]: query = """ SELECT e.id, e.amount, e.category, e.description, e.date, e.flagged, u.username AS logged_by, - EXISTS (SELECT 1 FROM income i WHERE i.reimburses_expense_id = e.id) AS reimbursed + EXISTS (SELECT 1 FROM income i WHERE i.reimburses_expense_id = e.id AND i.deleted_at IS NULL) AS reimbursed FROM expenses e LEFT JOIN users u ON e.user_id = u.id WHERE e.deleted_at IS NULL @@ -302,7 +303,7 @@ def get_income( FROM income i LEFT JOIN users u ON i.user_id = u.id LEFT JOIN expenses e ON e.id = i.reimburses_expense_id - WHERE 1=1 + WHERE i.deleted_at IS NULL """ params = [] if start_date: @@ -800,14 +801,14 @@ def update_income( return {"status": "nothing to update"} params.append(id) - cur = _run(f"UPDATE income SET {', '.join(fields)} WHERE id = %s", params) + cur = _run(f"UPDATE income SET {', '.join(fields)} WHERE id = %s AND deleted_at IS NULL", params) if cur.rowcount == 0: return {"status": "not_found"} return {"status": "updated"} def delete_income(id: int) -> dict: - cur = _run("DELETE FROM income WHERE id = %s RETURNING id", (id,)) + cur = _run("UPDATE income 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"} @@ -838,7 +839,7 @@ def get_budget_status(category: str = None, month: str = None) -> list[dict]: LEFT JOIN ( SELECT reimburses_expense_id, SUM(amount) AS reimbursed FROM income - WHERE reimburses_expense_id IS NOT NULL + WHERE reimburses_expense_id IS NOT NULL AND deleted_at IS NULL GROUP BY reimburses_expense_id ) r ON r.reimburses_expense_id = e.id WHERE 1=1 diff --git a/api/server.py b/api/server.py index 6369fe2..7ab80cb 100644 --- a/api/server.py +++ b/api/server.py @@ -135,6 +135,11 @@ def categories_endpoint(): return CATEGORIES +@app.get("/income/categories") +def income_categories_endpoint(): + return INCOME_CATEGORIES + + @app.get("/expenses/recurring") def recurring_expenses_endpoint(user_id: int = Depends(get_current_user)): return get_recurring_expenses() diff --git a/frontend/e2e/income.spec.js b/frontend/e2e/income.spec.js index 61d7541..b9e1734 100644 --- a/frontend/e2e/income.spec.js +++ b/frontend/e2e/income.spec.js @@ -31,3 +31,46 @@ test("toggles to the Income view and lists seeded income, with the expense-only await expect(visibleText(page, "Dinner at Pasta House")).toBeVisible(); await expect(visibleText(page, "Payroll Deposit")).toHaveCount(0); }); + +test("opens the edit dialog pre-filled with the income entry's existing details", async ({ page }) => { + await page.getByRole("tab", { name: "Income" }).click(); + await visibleText(page, "Cashback Reward").click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("Edit Income")).toBeVisible(); + await expect(dialog.locator("input").nth(0)).toHaveValue("Cashback Reward"); + await expect(dialog.locator('input[type="number"]')).toHaveValue("42.75"); + await expect(dialog.getByRole("combobox")).toContainText("Rebate"); +}); + +test("editing an income entry persists after reload", async ({ page }) => { + await page.getByRole("tab", { name: "Income" }).click(); + await visibleText(page, "Cashback Reward").click(); + const dialog = page.getByRole("dialog"); + await dialog.locator("input").nth(0).fill("Cashback Reward (edited)"); + await dialog.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Income updated")).toBeVisible(); + + await page.reload(); + await goToExpensesTab(page); + await page.getByRole("tab", { name: "Income" }).click(); + await expect(visibleText(page, "Cashback Reward (edited)")).toBeVisible(); + + // Restore original state so this test doesn't leak into other tests/projects + // sharing the same backend (mobile/desktop both hit the same seeded DB). + await visibleText(page, "Cashback Reward (edited)").click(); + await dialog.locator("input").nth(0).fill("Cashback Reward"); + await dialog.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Income updated")).toBeVisible(); +}); + +test("deleting an income entry shows an undo toast that restores it", async ({ page }) => { + await page.getByRole("tab", { name: "Income" }).click(); + await visibleText(page, "Cashback Reward").click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Delete" }).click(); + await expect(visibleText(page, "Cashback Reward")).toHaveCount(0); + + await page.getByRole("button", { name: "Undo" }).click(); + await expect(visibleText(page, "Cashback Reward")).toBeVisible(); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d08f579..0d6291a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -192,6 +192,7 @@ export default function App() { token={token} username={username} onExpenseChange={fetchExpenses} + onIncomeChange={fetchIncome} onUnauthorized={handleLogout} loading={expensesLoading} highlightIds={highlightIds} diff --git a/frontend/src/components/ExpenseTable.tsx b/frontend/src/components/ExpenseTable.tsx index 983c5cf..9a20364 100644 --- a/frontend/src/components/ExpenseTable.tsx +++ b/frontend/src/components/ExpenseTable.tsx @@ -6,11 +6,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { useAnimatedNumber } from "@/lib/categoryVisuals"; import { formatMonth } from "@/components/expenseTableFormat"; import ExpenseEditDialog from "@/components/ExpenseEditDialog"; +import IncomeEditDialog from "@/components/IncomeEditDialog"; 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, RecurringCharge } from "@/types"; +import type { AuthFetch, Budget, EditValues, Expense, Income, IncomeEditValues, RecurringCharge } from "@/types"; export interface ExpenseTableProps { expenses: Expense[]; @@ -19,12 +20,13 @@ export interface ExpenseTableProps { token: string; username: string; onExpenseChange: () => void; + onIncomeChange?: () => void; onUnauthorized: () => void; loading?: boolean; highlightIds?: Set; } -export default function ExpenseTable({ expenses, income = [], className = "", token, username, onExpenseChange, onUnauthorized, loading = false, highlightIds }: ExpenseTableProps) { +export default function ExpenseTable({ expenses, income = [], className = "", token, username, onExpenseChange, onIncomeChange, onUnauthorized, loading = false, highlightIds }: ExpenseTableProps) { const authFetch: AuthFetch = (url, opts = {}) => { const res = fetch(url, { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${token}` } }); res.then((r) => { if (r.status === 401) onUnauthorized(); }); @@ -73,11 +75,15 @@ export default function ExpenseTable({ expenses, income = [], className = "", to const [selectedMonthOverride, setSelectedMonthOverride] = useState(null); const [flaggedOnly, setFlaggedOnly] = useState(false); const [editingExpense, setEditingExpense] = useState(null); + const [editingIncome, setEditingIncome] = useState(null); const [categories, setCategories] = useState([]); + const [incomeCategories, setIncomeCategories] = useState([]); const [budgets, setBudgets] = useState([]); const [recurring, setRecurring] = useState([]); const [overrides, setOverrides] = useState>>({}); const [deletedIds, setDeletedIds] = useState>(() => new Set()); + const [incomeOverrides, setIncomeOverrides] = useState>>({}); + const [deletedIncomeIds, setDeletedIncomeIds] = useState>(() => new Set()); const [categoryFilter, setCategoryFilter] = useState(null); const [userFilter, setUserFilter] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -88,6 +94,7 @@ export default function ExpenseTable({ expenses, income = [], className = "", to const [showAllCategories, setShowAllCategories] = useState(false); const [showRecurring, setShowRecurring] = useState(false); const pendingDeletes = useRef>>({}); + const pendingIncomeDeletes = useRef>>({}); const listRef = useRef(null); const [showScrollToTop, setShowScrollToTop] = useState(false); @@ -103,6 +110,7 @@ export default function ExpenseTable({ expenses, income = [], className = "", to useEffect(() => { fetch("/categories").then((r) => r.json()).then(setCategories); + fetch("/income/categories").then((r) => r.json()).then(setIncomeCategories); }, []); const fetchBudgets = () => { @@ -121,6 +129,12 @@ export default function ExpenseTable({ expenses, income = [], className = "", to .map((e) => (overrides[e.id] ? { ...e, ...overrides[e.id] } : e)); }, [expenses, overrides, deletedIds]); + const incomeItems = useMemo(() => { + return income + .filter((i) => !deletedIncomeIds.has(i.id)) + .map((i) => (incomeOverrides[i.id] ? { ...i, ...incomeOverrides[i.id] } : i)); + }, [income, incomeOverrides, deletedIncomeIds]); + const months = useMemo(() => { const seen = new Set(); items.forEach((e) => seen.add(e.date.slice(0, 7))); @@ -141,9 +155,10 @@ export default function ExpenseTable({ expenses, income = [], className = "", to 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); + // Income view has no filters — total is just the sum of incomeItems + // (already sorted date DESC server-side, adjusted for optimistic + // edits/deletes the same way `items` adjusts the expense list). + const incomeTotal = incomeItems.reduce((sum, i) => sum + i.amount, 0); const animatedIncomeTotal = useAnimatedNumber(incomeTotal); const displayTotal = view === "expenses" ? animatedTotal : animatedIncomeTotal; @@ -273,6 +288,78 @@ export default function ExpenseTable({ expenses, income = [], className = "", to setEditingExpense(null); }; + const openEditIncome = (i: Income) => { + setEditingIncome(i); + }; + + const saveEditIncome = async (values: IncomeEditValues) => { + if (!editingIncome) return; + const id = editingIncome.id; + const original = incomeItems.find((x) => x.id === id); + const changes: Partial = {}; + if (values.amount !== editingIncome.amount) changes.amount = values.amount; + if (values.category !== editingIncome.category) changes.category = values.category; + if (values.description !== editingIncome.description) changes.description = values.description; + if (values.date !== editingIncome.date) changes.date = values.date; + setIncomeOverrides((prev) => ({ ...prev, [id]: { ...prev[id], ...values } })); + setEditingIncome(null); + try { + const res = await authFetch(`/income/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(changes), + }); + if (!res.ok) throw new Error(); + toast.success("Income updated"); + onIncomeChange?.(); + } catch { + setIncomeOverrides((prev) => ({ ...prev, [id]: { ...prev[id], ...original } })); + toast.error("Failed to update income"); + } + }; + + const restoreDeletedIncome = (id: number) => { + clearTimeout(pendingIncomeDeletes.current[id]); + delete pendingIncomeDeletes.current[id]; + setDeletedIncomeIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }; + + const deleteIncomeById = (id: number) => { + setDeletedIncomeIds((prev) => new Set(prev).add(id)); + + const toastId = toast("Income deleted", { + action: { label: "Undo", onClick: () => restoreDeletedIncome(id) }, + duration: 5000, + }); + + pendingIncomeDeletes.current[id] = setTimeout(async () => { + delete pendingIncomeDeletes.current[id]; + try { + const res = await authFetch(`/income/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + onIncomeChange?.(); + } catch { + setDeletedIncomeIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + toast.dismiss(toastId); + toast.error("Failed to delete income"); + } + }, 5000); + }; + + const deleteIncome = () => { + if (!editingIncome) return; + deleteIncomeById(editingIncome.id); + setEditingIncome(null); + }; + return (
@@ -284,6 +371,13 @@ export default function ExpenseTable({ expenses, income = [], className = "", to onDelete={deleteExpense} onClose={() => setEditingExpense(null)} /> + setEditingIncome(null)} + /> {/* Header */}
@@ -459,7 +553,7 @@ export default function ExpenseTable({ expenses, income = [], className = "", to )} {/* ── Income list (mobile cards + desktop table) ── */} - {view === "income" && } + {view === "income" && }
{showScrollToTop && ( diff --git a/frontend/src/components/IncomeEditDialog.tsx b/frontend/src/components/IncomeEditDialog.tsx new file mode 100644 index 0000000..cb2873c --- /dev/null +++ b/frontend/src/components/IncomeEditDialog.tsx @@ -0,0 +1,105 @@ +import { useState, type ChangeEvent } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import type { Income, IncomeEditValues } from "@/types"; + +const valuesFromIncome = (income: Income | null): IncomeEditValues => ({ + amount: income?.amount ?? 0, + category: income?.category ?? "", + description: income?.description ?? "", + date: income?.date ?? "", +}); + +export interface IncomeEditDialogProps { + // The income entry currently being edited, or null/undefined when the + // dialog is closed. Same open-derived-from-prop pattern as + // ExpenseEditDialog, so Radix can still play its close animation. + income: Income | null | undefined; + categories: string[]; + onSave: (values: IncomeEditValues) => void; + onDelete: () => void; + onClose: () => void; +} + +export default function IncomeEditDialog({ income, categories, onSave, onDelete, onClose }: IncomeEditDialogProps) { + const normalizedIncome = income ?? null; + const isOpen = !!normalizedIncome; + const [values, setValues] = useState(() => valuesFromIncome(normalizedIncome)); + // Same resync-on-open-transition pattern as ExpenseEditDialog — see that + // file's comment for the full reasoning (reopening the same row after a + // cancelled edit must pick up current values, not the stale unsaved edit; + // staying open or closing must not blow away in-progress typing or flash + // fields empty during the close animation). + const [wasOpen, setWasOpen] = useState(isOpen); + + if (isOpen && !wasOpen) { + setValues(valuesFromIncome(normalizedIncome)); + } + if (isOpen !== wasOpen) { + setWasOpen(isOpen); + } + + return ( + { if (!open) onClose(); }}> + { + // Same rationale as ExpenseEditDialog: skip autofocusing the + // Description input (avoids an immediate mobile keyboard pop), + // but still move focus into the dialog itself. + e.preventDefault(); + (e.currentTarget as HTMLElement).focus(); + }} + > + + Edit Income + Edit the details of this income entry + +
+
+ + ) => setValues({ ...values, description: e.target.value })} /> +
+
+ + ) => setValues({ ...values, date: e.target.value })} /> +
+
+ + ) => setValues({ ...values, amount: parseFloat(e.target.value) })} /> +
+
+ + +
+ {normalizedIncome?.reimburses_expense_id && ( +

+ Repays {normalizedIncome.reimburses_expense_description} · ${normalizedIncome.reimburses_expense_amount!.toFixed(2)}. + To change what this repays, ask in chat instead — this dialog doesn't edit that link. +

+ )} +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/IncomeList.tsx b/frontend/src/components/IncomeList.tsx index 5c92bd1..89e181b 100644 --- a/frontend/src/components/IncomeList.tsx +++ b/frontend/src/components/IncomeList.tsx @@ -4,10 +4,14 @@ import type { Income } from "@/types"; export interface IncomeListProps { income: Income[]; + onEdit: (income: Income) => void; } -// Read-only Phase 1 income view — no edit/delete UI yet. -export default function IncomeList({ income }: IncomeListProps) { +// Click a row to edit or delete (delete lives inside the edit dialog's +// footer, mirroring ExpenseEditDialog — no separate swipe-to-delete gesture +// here, unlike ExpenseList, to keep income's UI complexity proportional to +// what's actually built for it so far). +export default function IncomeList({ income, onEdit }: IncomeListProps) { const groupedByDate = income.reduce<{ date: string; items: Income[] }[]>((groups, i) => { const last = groups[groups.length - 1]; if (!last || last.date !== i.date) groups.push({ date: i.date, items: [i] }); @@ -17,7 +21,7 @@ export default function IncomeList({ income }: IncomeListProps) { return ( <> - {/* ── Income mobile card list — read-only Phase 1, no swipe/edit/delete ── */} + {/* ── Income mobile card list — click a row to edit or delete ── */}
{income.length === 0 ? (

No income yet

@@ -27,7 +31,11 @@ export default function IncomeList({ income }: IncomeListProps) { {formatSectionDate(date)}
{items.map((i) => ( -
+
onEdit(i)} + > {i.description} @@ -44,7 +52,7 @@ export default function IncomeList({ income }: IncomeListProps) { ))}
- {/* ── Income desktop table — read-only Phase 1, no edit/delete ── */} + {/* ── Income desktop table — click a row to edit or delete ── */} @@ -58,7 +66,7 @@ export default function IncomeList({ income }: IncomeListProps) { {income.length === 0 ? ( ) : income.map((i) => ( - + onEdit(i)}>
No income yet
{formatDate(i.date)} {i.description} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8031ef4..5dbddde 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -61,5 +61,15 @@ export interface EditValues { flagged: boolean; } +// Editable fields on an income entry — no flagged (income has no such +// concept) and no reimburses_expense_id (that stays link_income_to_expense's +// exclusive job on the backend; this dialog never touches it). +export interface IncomeEditValues { + amount: number; + category: string; + description: string; + date: string; +} + // authFetch: injects the bearer token and triggers onUnauthorized on 401. export type AuthFetch = (url: string, opts?: RequestInit) => Promise; diff --git a/tests/test_api.py b/tests/test_api.py index 8ad9a49..b57df42 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -59,6 +59,11 @@ def test_categories_endpoint_returns_category_list(): assert client.get("/categories").json() == CATEGORIES +def test_income_categories_endpoint_returns_income_category_list(): + from agent.categories import INCOME_CATEGORIES + assert client.get("/income/categories").json() == INCOME_CATEGORIES + + def test_chat_suggestions_endpoint(): from agent.tools import SUGGESTED_PROMPTS assert client.get("/chat/suggestions").json() == SUGGESTED_PROMPTS diff --git a/tests/test_db.py b/tests/test_db.py index 9ec47a7..070709f 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -659,6 +659,17 @@ def test_get_expenses_reimbursed_flag(user_id): assert result["Lunch"] is False +def test_get_expenses_reimbursed_flag_clears_when_reimbursing_income_deleted(user_id): + expense = add_expense(user_id, 42, "Dining", "Dinner", "2026-06-01") + income_id = add_income(user_id, 21, "Reimbursement", "From Jake", "2026-06-02")["id"] + db.link_income_to_expense(income_id, expense["id"]) + + db.delete_income(income_id) + + result = {r["description"]: r["reimbursed"] for r in db.get_expenses()} + assert result["Dinner"] is False + + def test_link_income_to_expense_nonexistent_expense_returns_error(user_id): income_id = add_income(user_id, 21, "Reimbursement", "From Jake", "2026-06-02")["id"] @@ -741,6 +752,43 @@ def test_delete_income_not_found(): assert result == {"status": "not_found"} +def test_delete_income_is_soft_delete_not_permanent(user_id): + saved = add_income(user_id, 40, "Gift", "Birthday gift", "2026-06-01") + db.delete_income(saved["id"]) + + row = db._run("SELECT deleted_at FROM income WHERE id = %s", (saved["id"],)).fetchone() + assert row is not None # row still physically exists + assert row["deleted_at"] is not None + + +def test_delete_income_twice_returns_not_found_second_time(user_id): + saved = add_income(user_id, 40, "Gift", "Birthday gift", "2026-06-01") + db.delete_income(saved["id"]) + + result = db.delete_income(saved["id"]) + + assert result == {"status": "not_found"} + + +def test_update_income_rejects_soft_deleted_entry(user_id): + saved = add_income(user_id, 40, "Gift", "Birthday gift", "2026-06-01") + db.delete_income(saved["id"]) + + result = db.update_income(saved["id"], amount=99) + + assert result == {"status": "not_found"} + + +def test_link_income_to_expense_rejects_soft_deleted_income(user_id): + expense = add_expense(user_id, 42, "Dining", "Dinner", "2026-06-01") + income_id = add_income(user_id, 21, "Reimbursement", "From Jake", "2026-06-02")["id"] + db.delete_income(income_id) + + result = db.link_income_to_expense(income_id, expense["id"]) + + assert result["status"] == "error" + + # --- get_average_transaction -------------------------------------------- def test_average_transaction_for_category(user_id): @@ -832,6 +880,19 @@ def test_budget_status_over_reimbursement_clips_at_zero(user_id): assert result[0]["remaining"] == 200.0 +def test_budget_status_ignores_deleted_reimbursement(user_id): + db.set_budget("Dining", 200) + expense = add_expense(user_id, 60, "Dining", "Dinner", "2026-06-05") + income_id = add_income(user_id, 30, "Reimbursement", "From Jake", "2026-06-06")["id"] + db.link_income_to_expense(income_id, expense["id"]) + assert db.get_budget_status(month="2026-06")[0]["spent"] == 30.0 # net, sanity check + + db.delete_income(income_id) + + result = db.get_budget_status(month="2026-06") + assert result[0]["spent"] == 60.0 # back to gross once the reimbursement is deleted + + # --- api_calls / rate limiting -------------------------------------------- def test_api_call_count_starts_at_zero(user_id):