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
13 changes: 7 additions & 6 deletions agent/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
43 changes: 43 additions & 0 deletions frontend/e2e/income.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
1 change: 1 addition & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export default function App() {
token={token}
username={username}
onExpenseChange={fetchExpenses}
onIncomeChange={fetchIncome}
onUnauthorized={handleLogout}
loading={expensesLoading}
highlightIds={highlightIds}
Expand Down
106 changes: 100 additions & 6 deletions frontend/src/components/ExpenseTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -19,12 +20,13 @@ export interface ExpenseTableProps {
token: string;
username: string;
onExpenseChange: () => void;
onIncomeChange?: () => void;
onUnauthorized: () => void;
loading?: boolean;
highlightIds?: Set<number>;
}

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(); });
Expand Down Expand Up @@ -73,11 +75,15 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
const [selectedMonthOverride, setSelectedMonthOverride] = useState<string | null>(null);
const [flaggedOnly, setFlaggedOnly] = useState(false);
const [editingExpense, setEditingExpense] = useState<Expense | null>(null);
const [editingIncome, setEditingIncome] = useState<Income | null>(null);
const [categories, setCategories] = useState<string[]>([]);
const [incomeCategories, setIncomeCategories] = useState<string[]>([]);
const [budgets, setBudgets] = useState<Budget[]>([]);
const [recurring, setRecurring] = useState<RecurringCharge[]>([]);
const [overrides, setOverrides] = useState<Record<number, Partial<Expense>>>({});
const [deletedIds, setDeletedIds] = useState<Set<number>>(() => new Set());
const [incomeOverrides, setIncomeOverrides] = useState<Record<number, Partial<Income>>>({});
const [deletedIncomeIds, setDeletedIncomeIds] = useState<Set<number>>(() => new Set());
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [userFilter, setUserFilter] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
Expand All @@ -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<Record<number, ReturnType<typeof setTimeout>>>({});
const pendingIncomeDeletes = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
const listRef = useRef<HTMLDivElement>(null);
const [showScrollToTop, setShowScrollToTop] = useState(false);

Expand All @@ -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 = () => {
Expand All @@ -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<string>();
items.forEach((e) => seen.add(e.date.slice(0, 7)));
Expand All @@ -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;

Expand Down Expand Up @@ -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<IncomeEditValues> = {};
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 (
<div className={`${className} flex-col flex-1 overflow-hidden bg-background`}>

Expand All @@ -284,6 +371,13 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
onDelete={deleteExpense}
onClose={() => setEditingExpense(null)}
/>
<IncomeEditDialog
income={editingIncome}
categories={incomeCategories}
onSave={saveEditIncome}
onDelete={deleteIncome}
onClose={() => setEditingIncome(null)}
/>

{/* Header */}
<div className="flex flex-col gap-2.5 px-4 py-3 border-b border-border shrink-0 md:px-5">
Expand Down Expand Up @@ -459,7 +553,7 @@ export default function ExpenseTable({ expenses, income = [], className = "", to
)}

{/* ── Income list (mobile cards + desktop table) ── */}
{view === "income" && <IncomeList income={income} />}
{view === "income" && <IncomeList income={incomeItems} onEdit={openEditIncome} />}

</div>
{showScrollToTop && (
Expand Down
Loading