feat(expenses): add edit amount and due date for pending expenses

This commit is contained in:
Jose Selesan
2026-06-04 11:00:36 -03:00
parent 5b6ccf0fa6
commit 55098a7b95
10 changed files with 366 additions and 11 deletions

View File

@@ -12,6 +12,7 @@ import { listExpensesHandler } from "./handlers/listExpenses";
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses"; import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
import { payExpenseHandler } from "./handlers/payExpense"; import { payExpenseHandler } from "./handlers/payExpense";
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense"; import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
import { updateExpenseHandler } from "./handlers/updateExpense";
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense"; import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
const app = new Hono(); const app = new Hono();
@@ -34,5 +35,6 @@ app.get("/expenses", listExpensesHandler);
app.post("/expenses", createNonPeriodicExpenseHandler); app.post("/expenses", createNonPeriodicExpenseHandler);
app.post("/expenses/import", importExpensesHandler); app.post("/expenses/import", importExpensesHandler);
app.put("/expenses/:id/pay", payExpenseHandler); app.put("/expenses/:id/pay", payExpenseHandler);
app.put("/expenses/:id", updateExpenseHandler);
export default app; export default app;

View File

@@ -23,6 +23,11 @@ export const payExpenseSchema = z.object({
paymentDate: z.string().datetime().optional(), 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 { function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate(); return new Date(year, month, 0).getDate();
} }
@@ -187,6 +192,32 @@ export async function payExpense(
return result; return result;
} }
export async function updateExpense(
id: number,
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: {
amount: data.amount,
dueDate,
year,
month,
},
});
cache.invalidateByPrefix("expenses:");
return result;
}
export async function getMonthlyPayedTotal(year: number, month: number) { export async function getMonthlyPayedTotal(year: number, month: number) {
const cacheKey = `expenses:monthly-total:${year}:${month}`; const cacheKey = `expenses:monthly-total:${year}:${month}`;
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);

View File

@@ -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);
}

View File

@@ -11,6 +11,7 @@ const mockPrisma = {
expense: { expense: {
findMany: mock(), findMany: mock(),
findFirst: mock(), findFirst: mock(),
findUniqueOrThrow: mock(),
create: mock(), create: mock(),
update: mock(), update: mock(),
count: mock(), count: mock(),
@@ -36,6 +37,7 @@ const {
createPeriodicExpenseSchema, createPeriodicExpenseSchema,
createNonPeriodicExpenseSchema, createNonPeriodicExpenseSchema,
payExpenseSchema, payExpenseSchema,
updateExpenseSchema,
buildDueDate, buildDueDate,
getCurrentYearMonthUTC, getCurrentYearMonthUTC,
listPeriodicExpenses, listPeriodicExpenses,
@@ -45,6 +47,7 @@ const {
listExpenses, listExpenses,
createNonPeriodicExpense, createNonPeriodicExpense,
payExpense, payExpense,
updateExpense,
generateExpensesForCurrentMonth, generateExpensesForCurrentMonth,
generateMonthlyExpense, generateMonthlyExpense,
} = await import("../src/modules/expenses/expenses.service"); } = await import("../src/modules/expenses/expenses.service");
@@ -179,6 +182,35 @@ describe("schemas", () => {
expect(() => payExpenseSchema.parse({ amountPayed: 0 })).toThrow(); 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", () => { 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", () => { describe("generateExpensesForCurrentMonth", () => {
test("skips existing expenses to avoid duplicates", async () => { test("skips existing expenses to avoid duplicates", async () => {
const { month } = getCurrentYearMonthUTC(); const { month } = getCurrentYearMonthUTC();

View File

@@ -18,6 +18,7 @@ import type {
PaginatedResponse, PaginatedResponse,
PayExpenseInput, PayExpenseInput,
PeriodicExpense, PeriodicExpense,
UpdateExpenseInput,
} from "@/lib/api"; } from "@/lib/api";
import { import {
useCreateNonPeriodicExpense, useCreateNonPeriodicExpense,
@@ -31,6 +32,7 @@ import {
usePayExpense, usePayExpense,
usePeriodicExpenses, usePeriodicExpenses,
useTotalPending, useTotalPending,
useUpdateExpense,
useUpdatePeriodicExpense, useUpdatePeriodicExpense,
} from "@/lib/queries"; } from "@/lib/queries";
@@ -68,6 +70,7 @@ interface ExpensesContextValue {
data: CreateNonPeriodicExpenseInput, data: CreateNonPeriodicExpenseInput,
) => Promise<void>; ) => Promise<void>;
payExpense: (id: number, data: PayExpenseInput) => Promise<void>; payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
updateExpense: (id: number, data: UpdateExpenseInput) => Promise<void>;
importPeriodicExpenses: (file: File) => Promise<ImportResult>; importPeriodicExpenses: (file: File) => Promise<ImportResult>;
importExpenses: (file: File) => Promise<ImportExpensesResult>; importExpenses: (file: File) => Promise<ImportExpensesResult>;
} }
@@ -133,6 +136,7 @@ export function ExpensesProvider({
const generateMonthlyMutation = useGenerateMonthlyExpense(); const generateMonthlyMutation = useGenerateMonthlyExpense();
const createExpenseMutation = useCreateNonPeriodicExpense(); const createExpenseMutation = useCreateNonPeriodicExpense();
const payExpenseMutation = usePayExpense(); const payExpenseMutation = usePayExpense();
const updateExpenseMutation = useUpdateExpense();
const importPeriodicMutation = useImportPeriodicExpenses(); const importPeriodicMutation = useImportPeriodicExpenses();
const importExpensesMutation = useImportExpenses(); const importExpensesMutation = useImportExpenses();
@@ -192,6 +196,13 @@ export function ExpensesProvider({
[payExpenseMutation], [payExpenseMutation],
); );
const updateExpenseFn = useCallback(
async (id: number, data: UpdateExpenseInput) => {
await updateExpenseMutation.mutateAsync({ id, data });
},
[updateExpenseMutation],
);
return ( return (
<ExpensesContext <ExpensesContext
value={{ value={{
@@ -218,6 +229,7 @@ export function ExpensesProvider({
generateMonthlyExpense: generateMonthlyExpenseFn, generateMonthlyExpense: generateMonthlyExpenseFn,
createNonPeriodicExpense: createNonPeriodicExpenseFn, createNonPeriodicExpense: createNonPeriodicExpenseFn,
payExpense: payExpenseFn, payExpense: payExpenseFn,
updateExpense: updateExpenseFn,
importPeriodicExpenses: importPeriodicExpensesFn, importPeriodicExpenses: importPeriodicExpensesFn,
importExpenses: importExpensesFn, importExpenses: importExpensesFn,
}} }}

View File

@@ -0,0 +1,170 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import type { Expense } from "@/lib/api";
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" }),
});
type EditFormValues = z.infer<typeof editSchema>;
interface EditExpenseDialogProps {
expense: Expense | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditExpenseDialog({
expense,
open,
onOpenChange,
}: EditExpenseDialogProps) {
const { updateExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null);
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<EditFormValues>({
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 (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Editar gasto</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
<span className="font-medium">{expense.description}</span>
</div>
<form
id="edit-expense-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="amount" className="text-sm font-medium">
Monto
</label>
<input
id="amount"
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...amountField}
ref={(node) => {
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 && (
<span className="text-xs text-destructive">
{errors.amount.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha de vencimiento</span>
<Controller
control={control}
name="dueDate"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
placeholder="Seleccionar fecha"
/>
)}
/>
{errors.dueDate && (
<span className="text-xs text-destructive">
{errors.dueDate.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="submit"
form="edit-expense-form"
disabled={isSubmitting}
>
Guardar cambios
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { Expense, ImportExpensesResult } from "@/lib/api"; import type { Expense, ImportExpensesResult } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider"; import { useExpensesContext } from "../ExpensesProvider";
import { EditExpenseDialog } from "./EditExpenseDialog";
import { ExpensesTable } from "./ExpensesTable"; import { ExpensesTable } from "./ExpensesTable";
import { NewExpenseDialog } from "./NewExpenseDialog"; import { NewExpenseDialog } from "./NewExpenseDialog";
import { PayExpenseDialog } from "./PayExpenseDialog"; import { PayExpenseDialog } from "./PayExpenseDialog";
@@ -49,6 +50,9 @@ export function ExpensesTabContent() {
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>( const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(
null, null,
); );
const [editDialogExpense, setEditDialogExpense] = useState<Expense | null>(
null,
);
const [newExpenseOpen, setNewExpenseOpen] = useState(false); const [newExpenseOpen, setNewExpenseOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
@@ -153,6 +157,7 @@ export function ExpensesTabContent() {
<ExpensesTable <ExpensesTable
data={expenses.data} data={expenses.data}
onPay={setPayDialogExpense} onPay={setPayDialogExpense}
onEdit={setEditDialogExpense}
page={expenses.page} page={expenses.page}
total={expenses.total} total={expenses.total}
pageSize={expenses.pageSize} pageSize={expenses.pageSize}
@@ -199,6 +204,14 @@ export function ExpensesTabContent() {
}} }}
/> />
<EditExpenseDialog
expense={editDialogExpense}
open={editDialogExpense !== null}
onOpenChange={(open) => {
if (!open) setEditDialogExpense(null);
}}
/>
<NewExpenseDialog <NewExpenseDialog
open={newExpenseOpen} open={newExpenseOpen}
onOpenChange={setNewExpenseOpen} onOpenChange={setNewExpenseOpen}

View File

@@ -8,6 +8,7 @@ import {
ChevronsLeftIcon, ChevronsLeftIcon,
ChevronsRightIcon, ChevronsRightIcon,
CircleDollarSign, CircleDollarSign,
Pencil,
SkipBackIcon, SkipBackIcon,
SkipForwardIcon, SkipForwardIcon,
} from "lucide-react"; } from "lucide-react";
@@ -26,6 +27,7 @@ import type { Expense } from "@/lib/api";
interface ExpensesTableProps { interface ExpensesTableProps {
data: Expense[]; data: Expense[];
onPay: (expense: Expense) => void; onPay: (expense: Expense) => void;
onEdit: (expense: Expense) => void;
page: number; page: number;
total: number; total: number;
pageSize: number; pageSize: number;
@@ -70,6 +72,7 @@ function ExpenseStatusBadge({ status }: { status: Expense["status"] }) {
export function ExpensesTable({ export function ExpensesTable({
data, data,
onPay, onPay,
onEdit,
page, page,
total, total,
pageSize, pageSize,
@@ -137,7 +140,15 @@ export function ExpensesTable({
const expense = row.original; const expense = row.original;
if (expense.status === "PAYED") return null; if (expense.status === "PAYED") return null;
return ( return (
<div className="flex justify-end"> <div className="flex justify-end gap-1">
<Button
variant="ghost"
size="xs"
onClick={() => onEdit(expense)}
>
<Pencil className="size-3.5" />
Editar
</Button>
<Button <Button
variant="outline" variant="outline"
size="xs" size="xs"
@@ -151,7 +162,7 @@ export function ExpensesTable({
}, },
}, },
], ],
[onPay], [onPay, onEdit],
); );
const table = useReactTable({ const table = useReactTable({
@@ -227,15 +238,25 @@ export function ExpensesTable({
</div> </div>
{expense.status !== "PAYED" && ( {expense.status !== "PAYED" && (
<Button <div className="flex gap-1">
variant="outline" <Button
size="sm" variant="ghost"
className="shrink-0" size="sm"
onClick={() => onPay(expense)} className="shrink-0"
> onClick={() => onEdit(expense)}
<CircleDollarSign className="size-3.5" /> >
Pagar <Pencil className="size-3.5" />
</Button> </Button>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onPay(expense)}
>
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -154,6 +154,11 @@ export type PayExpenseInput = {
paymentDate?: string; paymentDate?: string;
}; };
export type UpdateExpenseInput = {
amount: number;
dueDate: string;
};
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> { export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
return fetcher<PeriodicExpense[]>("/api/periodic-expenses"); return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
} }
@@ -263,6 +268,13 @@ export function payExpense(
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data); return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
} }
export function updateExpense(
id: number,
data: UpdateExpenseInput,
): Promise<Expense> {
return mutator<Expense>(`/api/expenses/${id}`, "PUT", data);
}
export type ImportResult = { export type ImportResult = {
imported: number; imported: number;
skipped: number; skipped: number;

View File

@@ -27,6 +27,8 @@ import {
importPeriodicExpenses, importPeriodicExpenses,
type PayExpenseInput, type PayExpenseInput,
payExpense as payExpenseApi, payExpense as payExpenseApi,
type UpdateExpenseInput,
updateExpense as updateExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi, updatePeriodicExpense as updatePeriodicExpenseApi,
} from "./api"; } 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 });
},
});
}