From b08399908b5af494084ddc02ec26fb4a11e502d5 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Mon, 8 Jun 2026 08:12:46 -0300 Subject: [PATCH] feat(expenses): allow editing paid expenses (status, amountPayed, paymentDate) --- .../src/modules/expenses/expenses.service.ts | 39 +++-- apps/backend/tests/expenses.test.ts | 82 +++++++++- .../expenses/components/EditExpenseDialog.tsx | 149 +++++++++++++++++- .../expenses/components/ExpensesTable.tsx | 43 ++--- apps/frontend/src/lib/api.ts | 3 + 5 files changed, 273 insertions(+), 43 deletions(-) diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index a529429..6eb9572 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -28,6 +28,15 @@ export const updateExpenseSchema = z.object({ dueDate: z .string() .datetime({ message: "La fecha debe ser una fecha ISO válida" }), + status: z.enum(["PENDING"]).optional(), + amountPayed: z + .number() + .positive("El monto pagado debe ser mayor a 0") + .optional(), + paymentDate: z + .string() + .datetime({ message: "La fecha debe ser una fecha ISO válida" }) + .optional(), }); function lastDayOfMonth(year: number, month: number): number { @@ -199,22 +208,34 @@ export async function updateExpense( 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 updateData: Record = { + amount: data.amount, + dueDate, + year, + month, + }; + + if (data.status === "PENDING") { + updateData.status = PaymentStatus.PENDING; + updateData.amountPayed = null; + updateData.paymentDate = null; + } else if (existing.status === PaymentStatus.PAYED) { + if (data.amountPayed !== undefined) { + updateData.amountPayed = data.amountPayed; + } + if (data.paymentDate !== undefined) { + updateData.paymentDate = new Date(data.paymentDate); + } + } + const result = await prisma.expense.update({ where: { id }, - data: { - amount: data.amount, - dueDate, - year, - month, - }, + data: updateData, }); cache.invalidateByPrefix("expenses:"); return result; diff --git a/apps/backend/tests/expenses.test.ts b/apps/backend/tests/expenses.test.ts index 9e3fc12..43667ae 100644 --- a/apps/backend/tests/expenses.test.ts +++ b/apps/backend/tests/expenses.test.ts @@ -471,6 +471,8 @@ describe("updateExpense", () => { dueDate: new Date("2026-06-15T00:00:00.000Z"), year: 2026, month: 6, + amountPayed: null, + paymentDate: null, }; mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); mockPrisma.expense.update.mockImplementationOnce( @@ -486,18 +488,82 @@ describe("updateExpense", () => { expect(result.month).toBe(7); }); - test("rejects update for PAYED expense", async () => { - mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce({ + test("allows updating a PAYED expense", async () => { + const existing = { id: 1, status: PaymentStatus.PAYED, + amount: 1500, + amountPayed: 1500, + dueDate: new Date("2026-06-15T00:00:00.000Z"), + paymentDate: new Date("2026-06-10T00: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", + amountPayed: 1800, + }); + expect(result.amount).toBe(2000); + expect(result.year).toBe(2026); + expect(result.month).toBe(7); + expect(result.amountPayed).toBe(1800); + }); + + test("reverting a PAYED expense to PENDING clears payment info", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 1500, + amountPayed: 1500, + dueDate: new Date("2026-06-15T00:00:00.000Z"), + paymentDate: new Date("2026-06-10T00: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: 1500, + dueDate: "2026-06-15T00:00:00.000Z", + status: "PENDING", }); - expect( - updateExpense(1, { - amount: 100, - dueDate: "2026-07-15T00:00:00.000Z", - }), - ).rejects.toThrow("Solo se pueden editar gastos pendientes"); + expect(result.status).toBe(PaymentStatus.PENDING); + }); + + test("sets amountPayed and paymentDate to null when reverting to PENDING", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 1500, + amountPayed: 1500, + dueDate: new Date("2026-06-15T00:00:00.000Z"), + paymentDate: new Date("2026-06-10T00: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: 1500, + dueDate: "2026-06-15T00:00:00.000Z", + status: "PENDING", + }); + + expect(result.amountPayed).toBeNull(); + expect(result.paymentDate).toBeNull(); }); }); diff --git a/apps/frontend/src/features/expenses/components/EditExpenseDialog.tsx b/apps/frontend/src/features/expenses/components/EditExpenseDialog.tsx index 4626976..8ecfe4a 100644 --- a/apps/frontend/src/features/expenses/components/EditExpenseDialog.tsx +++ b/apps/frontend/src/features/expenses/components/EditExpenseDialog.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useRef } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { DatePicker } from "@/components/ui/date-picker"; @@ -17,6 +17,9 @@ import { useExpensesContext } from "../ExpensesProvider"; const editSchema = z.object({ amount: z.number().positive("El monto debe ser mayor a 0"), dueDate: z.date({ message: "La fecha es obligatoria" }), + amountPayed: z.number().positive("El monto pagado debe ser mayor a 0").optional(), + paymentDate: z.date().optional(), + markAsPending: z.boolean(), }); type EditFormValues = z.infer; @@ -35,6 +38,8 @@ export function EditExpenseDialog({ const { updateExpense } = useExpensesContext(); const amountInputRef = useRef(null); + const isPayed = expense?.status === "PAYED"; + const { register, handleSubmit, @@ -46,17 +51,29 @@ export function EditExpenseDialog({ defaultValues: { amount: 0, dueDate: undefined, + amountPayed: undefined, + paymentDate: undefined, + markAsPending: false, }, }); const { ref: amountRef, ...amountField } = register("amount", { setValueAs: (value) => Number(value), }); + const markAsPending = useWatch({ control, name: "markAsPending" }); + useEffect(() => { if (expense) { reset({ amount: Number(expense.amount), dueDate: new Date(expense.dueDate), + amountPayed: expense.amountPayed + ? Number(expense.amountPayed) + : undefined, + paymentDate: expense.paymentDate + ? new Date(expense.paymentDate) + : undefined, + markAsPending: false, }); } }, [expense, reset]); @@ -79,10 +96,27 @@ export function EditExpenseDialog({ async function onSubmit(data: EditFormValues) { if (!expense) return; - await updateExpense(expense.id, { - amount: data.amount, - dueDate: data.dueDate.toISOString(), - }); + + if (isPayed && data.markAsPending) { + await updateExpense(expense.id, { + amount: data.amount, + dueDate: data.dueDate.toISOString(), + status: "PENDING", + }); + } else if (isPayed) { + await updateExpense(expense.id, { + amount: data.amount, + dueDate: data.dueDate.toISOString(), + amountPayed: data.amountPayed!, + paymentDate: data.paymentDate!.toISOString(), + }); + } else { + await updateExpense(expense.id, { + amount: data.amount, + dueDate: data.dueDate.toISOString(), + }); + } + handleClose(false); } @@ -146,6 +180,111 @@ export function EditExpenseDialog({ )} + + {isPayed && ( + <> +
+ +
+ + Información del pago + + + {!markAsPending && ( + <> +
+ + Number(value), + })} + 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.amountPayed && ( + + {errors.amountPayed.message} + + )} +
+ +
+ + Fecha de pago + + ( + + )} + /> + {errors.paymentDate && ( + + {errors.paymentDate.message} + + )} +
+ + )} + + + + {markAsPending && ( +

+ Al marcar como pendiente, se borrarán el monto pagado y la + fecha de pago. +

+ )} +
+ + )} diff --git a/apps/frontend/src/features/expenses/components/ExpensesTable.tsx b/apps/frontend/src/features/expenses/components/ExpensesTable.tsx index 0577e97..b0c36ea 100644 --- a/apps/frontend/src/features/expenses/components/ExpensesTable.tsx +++ b/apps/frontend/src/features/expenses/components/ExpensesTable.tsx @@ -138,21 +138,22 @@ export function ExpensesTable({ id: "actions", cell: ({ row }) => { const expense = row.original; - if (expense.status === "PAYED") return null; return (
- + {expense.status !== "PAYED" && ( + + )}
); }, @@ -233,16 +234,16 @@ export function ExpensesTable({

- {expense.status !== "PAYED" && ( -
- +
+ + {expense.status !== "PAYED" && ( -
- )} + )} +
))} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 632b3d2..62884c2 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -157,6 +157,9 @@ export type PayExpenseInput = { export type UpdateExpenseInput = { amount: number; dueDate: string; + status?: "PENDING"; + amountPayed?: number; + paymentDate?: string; }; export function getPeriodicExpenses(): Promise {