feat(expenses): allow editing paid expenses (status, amountPayed, paymentDate)

This commit is contained in:
Jose Selesan
2026-06-08 08:12:46 -03:00
parent 6f06ee18da
commit b08399908b
5 changed files with 273 additions and 43 deletions

View File

@@ -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<typeof updateExpenseSchema>,
) {
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: {
const updateData: Record<string, unknown> = {
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: updateData,
});
cache.invalidateByPrefix("expenses:");
return result;

View File

@@ -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);
});
expect(
updateExpense(1, {
amount: 100,
dueDate: "2026-07-15T00:00:00.000Z",
}),
).rejects.toThrow("Solo se pueden editar gastos pendientes");
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(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();
});
});

View File

@@ -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<typeof editSchema>;
@@ -35,6 +38,8 @@ export function EditExpenseDialog({
const { updateExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(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;
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({
</span>
)}
</div>
{isPayed && (
<>
<hr className="border-border" />
<div className="flex flex-col gap-3">
<span className="text-sm font-medium">
Información del pago
</span>
{!markAsPending && (
<>
<div className="flex flex-col gap-1.5">
<label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado
</label>
<input
id="amountPayed"
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...register("amountPayed", {
setValueAs: (value) => 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 && (
<span className="text-xs text-destructive">
{errors.amountPayed.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
Fecha de pago
</span>
<Controller
control={control}
name="paymentDate"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
placeholder="Seleccionar fecha"
/>
)}
/>
{errors.paymentDate && (
<span className="text-xs text-destructive">
{errors.paymentDate.message}
</span>
)}
</div>
</>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Controller
control={control}
name="markAsPending"
render={({ field }) => (
<button
type="button"
role="checkbox"
aria-checked={field.value}
onClick={() => field.onChange(!field.value)}
className={
field.value
? "flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-primary text-primary-foreground ring-offset-background transition-colors"
: "flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-background ring-offset-background transition-colors hover:border-ring"
}
>
{field.value && (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 5.5L4 7.5L8 3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
)}
/>
<span>Marcar como pendiente</span>
</label>
{markAsPending && (
<p className="text-xs text-muted-foreground">
Al marcar como pendiente, se borrarán el monto pagado y la
fecha de pago.
</p>
)}
</div>
</>
)}
</form>
<ResponsiveDialogFooter>

View File

@@ -138,13 +138,13 @@ export function ExpensesTable({
id: "actions",
cell: ({ row }) => {
const expense = row.original;
if (expense.status === "PAYED") return null;
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="xs" onClick={() => onEdit(expense)}>
<Pencil className="size-3.5" />
Editar
</Button>
{expense.status !== "PAYED" && (
<Button
variant="outline"
size="xs"
@@ -153,6 +153,7 @@ export function ExpensesTable({
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
)}
</div>
);
},
@@ -233,7 +234,6 @@ export function ExpensesTable({
</p>
</div>
{expense.status !== "PAYED" && (
<div className="flex gap-1">
<Button
variant="ghost"
@@ -243,6 +243,7 @@ export function ExpensesTable({
>
<Pencil className="size-3.5" />
</Button>
{expense.status !== "PAYED" && (
<Button
variant="outline"
size="sm"
@@ -252,10 +253,10 @@ export function ExpensesTable({
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
</div>
)}
</div>
</div>
</div>
))}
{data.length === 0 && (
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">

View File

@@ -157,6 +157,9 @@ export type PayExpenseInput = {
export type UpdateExpenseInput = {
amount: number;
dueDate: string;
status?: "PENDING";
amountPayed?: number;
paymentDate?: string;
};
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {