feat(expenses): add edit amount and due date for pending expenses
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<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) {
|
||||
const cacheKey = `expenses:monthly-total:${year}:${month}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
|
||||
10
apps/backend/src/modules/expenses/handlers/updateExpense.ts
Normal file
10
apps/backend/src/modules/expenses/handlers/updateExpense.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void>;
|
||||
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
|
||||
updateExpense: (id: number, data: UpdateExpenseInput) => Promise<void>;
|
||||
importPeriodicExpenses: (file: File) => Promise<ImportResult>;
|
||||
importExpenses: (file: File) => Promise<ImportExpensesResult>;
|
||||
}
|
||||
@@ -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 (
|
||||
<ExpensesContext
|
||||
value={{
|
||||
@@ -218,6 +229,7 @@ export function ExpensesProvider({
|
||||
generateMonthlyExpense: generateMonthlyExpenseFn,
|
||||
createNonPeriodicExpense: createNonPeriodicExpenseFn,
|
||||
payExpense: payExpenseFn,
|
||||
updateExpense: updateExpenseFn,
|
||||
importPeriodicExpenses: importPeriodicExpensesFn,
|
||||
importExpenses: importExpensesFn,
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<Expense | null>(
|
||||
null,
|
||||
);
|
||||
const [editDialogExpense, setEditDialogExpense] = useState<Expense | null>(
|
||||
null,
|
||||
);
|
||||
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
||||
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
@@ -153,6 +157,7 @@ export function ExpensesTabContent() {
|
||||
<ExpensesTable
|
||||
data={expenses.data}
|
||||
onPay={setPayDialogExpense}
|
||||
onEdit={setEditDialogExpense}
|
||||
page={expenses.page}
|
||||
total={expenses.total}
|
||||
pageSize={expenses.pageSize}
|
||||
@@ -199,6 +204,14 @@ export function ExpensesTabContent() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditExpenseDialog
|
||||
expense={editDialogExpense}
|
||||
open={editDialogExpense !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditDialogExpense(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NewExpenseDialog
|
||||
open={newExpenseOpen}
|
||||
onOpenChange={setNewExpenseOpen}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
CircleDollarSign,
|
||||
Pencil,
|
||||
SkipBackIcon,
|
||||
SkipForwardIcon,
|
||||
} from "lucide-react";
|
||||
@@ -26,6 +27,7 @@ import type { Expense } from "@/lib/api";
|
||||
interface ExpensesTableProps {
|
||||
data: Expense[];
|
||||
onPay: (expense: Expense) => 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 (
|
||||
<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
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -151,7 +162,7 @@ export function ExpensesTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[onPay],
|
||||
[onPay, onEdit],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -227,15 +238,25 @@ export function ExpensesTable({
|
||||
</div>
|
||||
|
||||
{expense.status !== "PAYED" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onPay(expense)}
|
||||
>
|
||||
<CircleDollarSign className="size-3.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onEdit(expense)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onPay(expense)}
|
||||
>
|
||||
<CircleDollarSign className="size-3.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,6 +154,11 @@ export type PayExpenseInput = {
|
||||
paymentDate?: string;
|
||||
};
|
||||
|
||||
export type UpdateExpenseInput = {
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
};
|
||||
|
||||
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
|
||||
return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
|
||||
}
|
||||
@@ -263,6 +268,13 @@ export function payExpense(
|
||||
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 = {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user