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" && (
Pagar
-
- )}
+ )}
+
))}
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 {