From 55098a7b95d7d5bb09b5e239fb51fbd36e7d7a5d Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Thu, 4 Jun 2026 11:00:36 -0300 Subject: [PATCH] feat(expenses): add edit amount and due date for pending expenses --- .../src/modules/expenses/expenses.routes.ts | 2 + .../src/modules/expenses/expenses.service.ts | 31 ++++ .../expenses/handlers/updateExpense.ts | 10 ++ apps/backend/tests/expenses.test.ts | 71 ++++++++ .../features/expenses/ExpensesProvider.tsx | 12 ++ .../expenses/components/EditExpenseDialog.tsx | 170 ++++++++++++++++++ .../components/ExpensesTabContent.tsx | 13 ++ .../expenses/components/ExpensesTable.tsx | 43 +++-- apps/frontend/src/lib/api.ts | 12 ++ apps/frontend/src/lib/queries.ts | 13 ++ 10 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/modules/expenses/handlers/updateExpense.ts create mode 100644 apps/frontend/src/features/expenses/components/EditExpenseDialog.tsx diff --git a/apps/backend/src/modules/expenses/expenses.routes.ts b/apps/backend/src/modules/expenses/expenses.routes.ts index b9a8aaf..e5de2e7 100644 --- a/apps/backend/src/modules/expenses/expenses.routes.ts +++ b/apps/backend/src/modules/expenses/expenses.routes.ts @@ -12,6 +12,7 @@ import { listExpensesHandler } from "./handlers/listExpenses"; import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses"; import { payExpenseHandler } from "./handlers/payExpense"; import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense"; +import { updateExpenseHandler } from "./handlers/updateExpense"; import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense"; const app = new Hono(); @@ -34,5 +35,6 @@ app.get("/expenses", listExpensesHandler); app.post("/expenses", createNonPeriodicExpenseHandler); app.post("/expenses/import", importExpensesHandler); app.put("/expenses/:id/pay", payExpenseHandler); +app.put("/expenses/:id", updateExpenseHandler); export default app; diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index a8fd179..5e42a38 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -23,6 +23,11 @@ export const payExpenseSchema = z.object({ paymentDate: z.string().datetime().optional(), }); +export const updateExpenseSchema = z.object({ + amount: z.number().positive("El monto debe ser mayor a 0"), + dueDate: z.string().datetime({ message: "La fecha debe ser una fecha ISO vĂ¡lida" }), +}); + function lastDayOfMonth(year: number, month: number): number { return new Date(year, month, 0).getDate(); } @@ -187,6 +192,32 @@ export async function payExpense( return result; } +export async function updateExpense( + id: number, + data: z.infer, +) { + const existing = await prisma.expense.findUniqueOrThrow({ where: { id } }); + if (existing.status !== PaymentStatus.PENDING) { + throw new Error("Solo se pueden editar gastos pendientes"); + } + + const dueDate = new Date(data.dueDate); + const year = dueDate.getUTCFullYear(); + const month = dueDate.getUTCMonth() + 1; + + const result = await prisma.expense.update({ + where: { id }, + data: { + amount: data.amount, + dueDate, + year, + month, + }, + }); + cache.invalidateByPrefix("expenses:"); + return result; +} + export async function getMonthlyPayedTotal(year: number, month: number) { const cacheKey = `expenses:monthly-total:${year}:${month}`; const cached = cache.get(cacheKey); diff --git a/apps/backend/src/modules/expenses/handlers/updateExpense.ts b/apps/backend/src/modules/expenses/handlers/updateExpense.ts new file mode 100644 index 0000000..bd37251 --- /dev/null +++ b/apps/backend/src/modules/expenses/handlers/updateExpense.ts @@ -0,0 +1,10 @@ +import type { Context } from "hono"; +import { updateExpense, updateExpenseSchema } from "../expenses.service"; + +export async function updateExpenseHandler(c: Context) { + const id = Number(c.req.param("id")); + const body = await c.req.json(); + const parsed = updateExpenseSchema.parse(body); + const result = await updateExpense(id, parsed); + return c.json(result); +} diff --git a/apps/backend/tests/expenses.test.ts b/apps/backend/tests/expenses.test.ts index e748bda..9e3fc12 100644 --- a/apps/backend/tests/expenses.test.ts +++ b/apps/backend/tests/expenses.test.ts @@ -11,6 +11,7 @@ const mockPrisma = { expense: { findMany: mock(), findFirst: mock(), + findUniqueOrThrow: mock(), create: mock(), update: mock(), count: mock(), @@ -36,6 +37,7 @@ const { createPeriodicExpenseSchema, createNonPeriodicExpenseSchema, payExpenseSchema, + updateExpenseSchema, buildDueDate, getCurrentYearMonthUTC, listPeriodicExpenses, @@ -45,6 +47,7 @@ const { listExpenses, createNonPeriodicExpense, payExpense, + updateExpense, generateExpensesForCurrentMonth, generateMonthlyExpense, } = await import("../src/modules/expenses/expenses.service"); @@ -179,6 +182,35 @@ describe("schemas", () => { expect(() => payExpenseSchema.parse({ amountPayed: 0 })).toThrow(); }); }); + + describe("updateExpenseSchema", () => { + test("accepts valid input", () => { + const result = updateExpenseSchema.parse({ + amount: 2500, + dueDate: "2026-07-15T00:00:00.000Z", + }); + expect(result.amount).toBe(2500); + expect(result.dueDate).toBe("2026-07-15T00:00:00.000Z"); + }); + + test("rejects non-positive amount", () => { + expect(() => + updateExpenseSchema.parse({ + amount: 0, + dueDate: "2026-07-15T00:00:00.000Z", + }), + ).toThrow(); + }); + + test("rejects invalid dueDate", () => { + expect(() => + updateExpenseSchema.parse({ + amount: 100, + dueDate: "not-a-date", + }), + ).toThrow(); + }); + }); }); describe("buildDueDate", () => { @@ -430,6 +462,45 @@ describe("payExpense", () => { }); }); +describe("updateExpense", () => { + test("updates amount and dueDate for a PENDING expense", async () => { + const existing = { + id: 1, + status: PaymentStatus.PENDING, + amount: 1500, + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + + const result = await updateExpense(1, { + amount: 2000, + dueDate: "2026-07-20T00:00:00.000Z", + }); + expect(result.amount).toBe(2000); + expect(result.year).toBe(2026); + expect(result.month).toBe(7); + }); + + test("rejects update for PAYED expense", async () => { + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce({ + id: 1, + status: PaymentStatus.PAYED, + }); + + expect( + updateExpense(1, { + amount: 100, + dueDate: "2026-07-15T00:00:00.000Z", + }), + ).rejects.toThrow("Solo se pueden editar gastos pendientes"); + }); +}); + describe("generateExpensesForCurrentMonth", () => { test("skips existing expenses to avoid duplicates", async () => { const { month } = getCurrentYearMonthUTC(); diff --git a/apps/frontend/src/features/expenses/ExpensesProvider.tsx b/apps/frontend/src/features/expenses/ExpensesProvider.tsx index 45687af..3cfccf9 100644 --- a/apps/frontend/src/features/expenses/ExpensesProvider.tsx +++ b/apps/frontend/src/features/expenses/ExpensesProvider.tsx @@ -18,6 +18,7 @@ import type { PaginatedResponse, PayExpenseInput, PeriodicExpense, + UpdateExpenseInput, } from "@/lib/api"; import { useCreateNonPeriodicExpense, @@ -31,6 +32,7 @@ import { usePayExpense, usePeriodicExpenses, useTotalPending, + useUpdateExpense, useUpdatePeriodicExpense, } from "@/lib/queries"; @@ -68,6 +70,7 @@ interface ExpensesContextValue { data: CreateNonPeriodicExpenseInput, ) => Promise; payExpense: (id: number, data: PayExpenseInput) => Promise; + updateExpense: (id: number, data: UpdateExpenseInput) => Promise; importPeriodicExpenses: (file: File) => Promise; importExpenses: (file: File) => Promise; } @@ -133,6 +136,7 @@ export function ExpensesProvider({ const generateMonthlyMutation = useGenerateMonthlyExpense(); const createExpenseMutation = useCreateNonPeriodicExpense(); const payExpenseMutation = usePayExpense(); + const updateExpenseMutation = useUpdateExpense(); const importPeriodicMutation = useImportPeriodicExpenses(); const importExpensesMutation = useImportExpenses(); @@ -192,6 +196,13 @@ export function ExpensesProvider({ [payExpenseMutation], ); + const updateExpenseFn = useCallback( + async (id: number, data: UpdateExpenseInput) => { + await updateExpenseMutation.mutateAsync({ id, data }); + }, + [updateExpenseMutation], + ); + return ( ; + +interface EditExpenseDialogProps { + expense: Expense | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditExpenseDialog({ + expense, + open, + onOpenChange, +}: EditExpenseDialogProps) { + const { updateExpense } = useExpensesContext(); + const amountInputRef = useRef(null); + + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + reset, + } = useForm({ + resolver: zodResolver(editSchema), + defaultValues: { + amount: 0, + dueDate: undefined, + }, + }); + const { ref: amountRef, ...amountField } = register("amount", { + setValueAs: (value) => Number(value), + }); + + useEffect(() => { + if (expense) { + reset({ + amount: Number(expense.amount), + dueDate: new Date(expense.dueDate), + }); + } + }, [expense, reset]); + + useEffect(() => { + if (!open || !expense) return; + + const frame = requestAnimationFrame(() => { + amountInputRef.current?.focus(); + amountInputRef.current?.select(); + }); + + return () => cancelAnimationFrame(frame); + }, [open, expense]); + + function handleClose(open: boolean) { + onOpenChange(open); + if (!open) reset(); + } + + async function onSubmit(data: EditFormValues) { + if (!expense) return; + await updateExpense(expense.id, { + amount: data.amount, + dueDate: data.dueDate.toISOString(), + }); + handleClose(false); + } + + if (!expense) return null; + + return ( + + + + Editar gasto + + +
+ {expense.description} +
+ +
+
+ + { + amountRef(node); + amountInputRef.current = node; + }} + className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" + /> + {errors.amount && ( + + {errors.amount.message} + + )} +
+ +
+ Fecha de vencimiento + ( + + )} + /> + {errors.dueDate && ( + + {errors.dueDate.message} + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx b/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx index 508ee48..3cf7d76 100644 --- a/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx +++ b/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx @@ -18,6 +18,7 @@ import { } from "@/components/ui/select"; import type { Expense, ImportExpensesResult } from "@/lib/api"; import { useExpensesContext } from "../ExpensesProvider"; +import { EditExpenseDialog } from "./EditExpenseDialog"; import { ExpensesTable } from "./ExpensesTable"; import { NewExpenseDialog } from "./NewExpenseDialog"; import { PayExpenseDialog } from "./PayExpenseDialog"; @@ -49,6 +50,9 @@ export function ExpensesTabContent() { const [payDialogExpense, setPayDialogExpense] = useState( null, ); + const [editDialogExpense, setEditDialogExpense] = useState( + null, + ); const [newExpenseOpen, setNewExpenseOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); @@ -153,6 +157,7 @@ export function ExpensesTabContent() { + { + if (!open) setEditDialogExpense(null); + }} + /> + void; + onEdit: (expense: Expense) => void; page: number; total: number; pageSize: number; @@ -70,6 +72,7 @@ function ExpenseStatusBadge({ status }: { status: Expense["status"] }) { export function ExpensesTable({ data, onPay, + onEdit, page, total, pageSize, @@ -137,7 +140,15 @@ export function ExpensesTable({ const expense = row.original; if (expense.status === "PAYED") return null; return ( -
+
+ +
+ + +
)}
diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index a51c21c..632b3d2 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -154,6 +154,11 @@ export type PayExpenseInput = { paymentDate?: string; }; +export type UpdateExpenseInput = { + amount: number; + dueDate: string; +}; + export function getPeriodicExpenses(): Promise { return fetcher("/api/periodic-expenses"); } @@ -263,6 +268,13 @@ export function payExpense( return mutator(`/api/expenses/${id}/pay`, "PUT", data); } +export function updateExpense( + id: number, + data: UpdateExpenseInput, +): Promise { + return mutator(`/api/expenses/${id}`, "PUT", data); +} + export type ImportResult = { imported: number; skipped: number; diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 3bfa17a..4586809 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -27,6 +27,8 @@ import { importPeriodicExpenses, type PayExpenseInput, payExpense as payExpenseApi, + type UpdateExpenseInput, + updateExpense as updateExpenseApi, updatePeriodicExpense as updatePeriodicExpenseApi, } from "./api"; @@ -279,3 +281,14 @@ export function usePayExpense() { }, }); } + +export function useUpdateExpense() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateExpenseInput }) => + updateExpenseApi(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: expenseKeys.all }); + }, + }); +}