feat(expenses): implement responsive dialog for expense management and create expense provider

- Added ResponsiveDialog component for handling responsive dialogs in the UI.
- Created ExpensesProvider to manage state and API interactions for expenses.
- Developed ExpensesTabContent to display expenses with filtering options and dialogs for new and pay expense actions.
- Implemented ExpensesTable for rendering expense data in a tabular format with actions.
- Added dialogs for creating new expenses and paying existing ones with form validation.
- Introduced PeriodicExpenseForm for managing periodic expenses with month selection.
- Created PeriodicExpensesTabContent to manage and display periodic expenses with create/edit functionality.
- Added GenerateCurrentMonthDialog for confirming monthly expense generation.
- Implemented PeriodicExpensesTable for displaying periodic expenses with edit and delete actions.
This commit is contained in:
Jose Selesan
2026-05-29 10:29:20 -03:00
parent bd5e29236b
commit e1786f7384
39 changed files with 4338 additions and 6435 deletions

View File

@@ -0,0 +1,450 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { PaymentStatus } from "../src/generated/prisma/client";
const mockPrisma = {
periodicExpense: {
findMany: mock(),
findUniqueOrThrow: mock(),
create: mock(),
update: mock(),
},
expense: {
findMany: mock(),
findFirst: mock(),
create: mock(),
update: mock(),
},
};
mock.module("../src/lib/prisma", () => ({
prisma: mockPrisma,
}));
beforeEach(() => {
for (const model of Object.values(mockPrisma)) {
for (const fn of Object.values(model as Record<string, ReturnType<typeof mock>>)) {
fn.mockClear();
}
}
});
const {
createPeriodicExpenseSchema,
updatePeriodicExpenseSchema,
createNonPeriodicExpenseSchema,
payExpenseSchema,
buildDueDate,
getCurrentYearMonthUTC,
listPeriodicExpenses,
createPeriodicExpense,
updatePeriodicExpense,
softDeletePeriodicExpense,
listExpenses,
createNonPeriodicExpense,
payExpense,
generateExpensesForCurrentMonth,
generateMonthlyExpense,
} = await import("../src/modules/expenses/expenses.service");
describe("schemas", () => {
describe("createPeriodicExpenseSchema", () => {
test("accepts valid input", () => {
const result = createPeriodicExpenseSchema.parse({
description: "Gimnasio",
defaultDueDay: 10,
defaultAmount: 1500.50,
periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
});
expect(result.description).toBe("Gimnasio");
});
test("rejects empty description", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "",
defaultDueDay: 10,
defaultAmount: 100,
periods: [1],
})
).toThrow();
});
test("rejects description over 50 chars", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "a".repeat(51),
defaultDueDay: 10,
defaultAmount: 100,
periods: [1],
})
).toThrow();
});
test("rejects due day < 1", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 0,
defaultAmount: 100,
periods: [1],
})
).toThrow();
});
test("rejects due day > 31", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 32,
defaultAmount: 100,
periods: [1],
})
).toThrow();
});
test("rejects non-positive amount", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 15,
defaultAmount: 0,
periods: [1],
})
).toThrow();
});
test("rejects empty periods", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 15,
defaultAmount: 100,
periods: [],
})
).toThrow();
});
test("rejects invalid period month", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 15,
defaultAmount: 100,
periods: [0, 13],
})
).toThrow();
});
});
describe("createNonPeriodicExpenseSchema", () => {
test("accepts valid input", () => {
const result = createNonPeriodicExpenseSchema.parse({
description: "Ropa",
amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z",
});
expect(result.description).toBe("Ropa");
});
test("rejects negative amount", () => {
expect(() =>
createNonPeriodicExpenseSchema.parse({
description: "Ropa",
amount: -100,
dueDate: "2026-06-15T00:00:00.000Z",
})
).toThrow();
});
});
describe("payExpenseSchema", () => {
test("accepts valid payment", () => {
const result = payExpenseSchema.parse({
amountPayed: 1400,
paymentDate: "2026-06-10T00:00:00.000Z",
});
expect(result.amountPayed).toBe(1400);
});
test("allows missing paymentDate", () => {
const result = payExpenseSchema.parse({ amountPayed: 1400 });
expect(result.amountPayed).toBe(1400);
expect(result.paymentDate).toBeUndefined();
});
test("rejects zero amountPayed", () => {
expect(() =>
payExpenseSchema.parse({ amountPayed: 0 })
).toThrow();
});
});
});
describe("buildDueDate", () => {
test("creates valid date", () => {
const date = buildDueDate(2026, 6, 15);
expect(date.getUTCFullYear()).toBe(2026);
expect(date.getUTCMonth()).toBe(5); // 0-indexed
expect(date.getUTCDate()).toBe(15);
});
test("clamps day to last day of month when dueDay exceeds month length", () => {
const date = buildDueDate(2026, 2, 31);
expect(date.getUTCMonth()).toBe(1); // February (0-indexed)
expect(date.getUTCDate()).toBe(28);
});
test("handles december correctly", () => {
const date = buildDueDate(2026, 12, 31);
expect(date.getUTCFullYear()).toBe(2026);
expect(date.getUTCMonth()).toBe(11);
expect(date.getUTCDate()).toBe(31);
});
test("handles leap year february", () => {
const date = buildDueDate(2024, 2, 29);
expect(date.getUTCFullYear()).toBe(2024);
expect(date.getUTCMonth()).toBe(1);
expect(date.getUTCDate()).toBe(29);
});
});
describe("getCurrentYearMonthUTC", () => {
test("returns valid year and month", () => {
const { year, month } = getCurrentYearMonthUTC();
expect(year).toBeGreaterThan(2020);
expect(month).toBeGreaterThanOrEqual(1);
expect(month).toBeLessThanOrEqual(12);
});
});
describe("listPeriodicExpenses", () => {
test("returns only non-deleted expenses ordered by createdAt desc", async () => {
const mockItems = [
{ id: 1, description: "Gas", isDeleted: false, createdAt: new Date() },
];
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce(mockItems);
const result = await listPeriodicExpenses();
expect(result).toEqual(mockItems);
expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({
where: { isDeleted: false },
orderBy: { createdAt: "desc" },
});
});
});
describe("createPeriodicExpense", () => {
test("creates and detects current month applicability", async () => {
const input = {
description: "Gimnasio",
defaultDueDay: 10,
defaultAmount: 1500,
periods: [6],
};
const created = { id: 1, ...input, isDeleted: false, createdAt: new Date() };
mockPrisma.periodicExpense.create.mockResolvedValueOnce(created);
const result = await createPeriodicExpense(input);
expect(result.id).toBe(1);
// currentMonthApplicable depends on actual month; just verify truthy/falsy
expect("currentMonthApplicable" in result).toBe(true);
});
});
describe("updatePeriodicExpense", () => {
test("updates and returns the record", async () => {
const input = {
description: "Gas actualizado",
defaultDueDay: 5,
defaultAmount: 2000,
periods: [1, 7],
};
const updated = { id: 1, ...input, isDeleted: false };
mockPrisma.periodicExpense.update.mockResolvedValueOnce(updated);
const result = await updatePeriodicExpense(1, input);
expect(result.description).toBe("Gas actualizado");
expect(mockPrisma.periodicExpense.update).toHaveBeenCalledWith({
where: { id: 1 },
data: input,
});
});
});
describe("softDeletePeriodicExpense", () => {
test("sets isDeleted to true", async () => {
const deleted = { id: 1, isDeleted: true };
mockPrisma.periodicExpense.update.mockResolvedValueOnce(deleted);
const result = await softDeletePeriodicExpense(1);
expect(result.isDeleted).toBe(true);
expect(mockPrisma.periodicExpense.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { isDeleted: true },
});
});
});
describe("listExpenses", () => {
test("returns all expenses when no status filter", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses();
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: {},
include: { periodicExpense: true },
orderBy: { dueDate: "asc" },
});
});
test("filters by status when provided", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses("PENDING");
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { status: "PENDING" },
include: { periodicExpense: true },
orderBy: { dueDate: "asc" },
});
});
});
describe("createNonPeriodicExpense", () => {
test("creates expense with PAYED status and sets amountPayed", async () => {
const input = {
description: "Ropa",
amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z",
};
const created = {
id: 1,
description: "Ropa",
amount: 2500,
amountPayed: 2500,
status: PaymentStatus.PAYED,
year: 2026,
month: 6,
};
mockPrisma.expense.create.mockResolvedValueOnce(created);
const result = await createNonPeriodicExpense(input);
expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.amountPayed).toBe(2500);
expect(mockPrisma.expense.create).toHaveBeenCalledWith({
data: expect.objectContaining({
description: "Ropa",
amount: 2500,
amountPayed: 2500,
status: PaymentStatus.PAYED,
paymentDate: expect.any(Date),
}),
});
});
});
describe("payExpense", () => {
test("updates status to PAYED with amountPayed and paymentDate", async () => {
const paidExpense = {
id: 1,
status: PaymentStatus.PAYED,
amountPayed: 1400,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
};
mockPrisma.expense.update.mockResolvedValueOnce(paidExpense);
const result = await payExpense(1, {
amountPayed: 1400,
paymentDate: "2026-06-10T00:00:00.000Z",
});
expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.amountPayed).toBe(1400);
});
test("defaults paymentDate to now when not provided", async () => {
mockPrisma.expense.update.mockImplementationOnce(async ({ where, data }) => ({
id: where.id,
...data,
status: PaymentStatus.PAYED,
amountPayed: data.amountPayed,
paymentDate: data.paymentDate,
}));
const result = await payExpense(1, { amountPayed: 1500 });
expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.paymentDate).toBeInstanceOf(Date);
});
});
describe("generateExpensesForCurrentMonth", () => {
test("skips existing expenses to avoid duplicates", async () => {
const { month } = getCurrentYearMonthUTC();
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([
{ id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [month] },
]);
mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 });
const results = await generateExpensesForCurrentMonth();
expect(results).toHaveLength(1);
expect(results[0].status).toBe("already_exists");
expect(mockPrisma.expense.create).not.toHaveBeenCalled();
});
test("creates expenses for applicable periodic expenses", async () => {
const { month } = getCurrentYearMonthUTC();
const periodic = {
id: 1,
description: "Gas",
defaultDueDay: 15,
defaultAmount: 2000,
periods: [month],
};
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([periodic]);
mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
mockPrisma.expense.create.mockResolvedValueOnce({
id: 1,
description: "Gas",
periodicExpenseId: 1,
amount: 2000,
status: "PENDING",
});
const results = await generateExpensesForCurrentMonth();
expect(results).toHaveLength(1);
expect(results[0].status).toBe("created");
});
test("ignores deleted periodic expenses", async () => {
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([]);
const results = await generateExpensesForCurrentMonth();
expect(results).toHaveLength(0);
expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({
where: { isDeleted: false, periods: { has: expect.any(Number) } },
});
});
});
describe("generateMonthlyExpense", () => {
test("creates expense for given periodic expense", async () => {
const periodic = { id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [6] };
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce(periodic);
mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
mockPrisma.expense.create.mockResolvedValueOnce({
id: 1, description: "Gas", periodicExpenseId: 1, amount: 2000, status: "PENDING",
});
const result = await generateMonthlyExpense(1);
expect(result.id).toBe(1);
});
test("returns existing expense instead of duplicating", async () => {
const existing = { id: 99, description: "Gas", periodicExpenseId: 1 };
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce({
id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [6],
});
mockPrisma.expense.findFirst.mockResolvedValueOnce(existing);
const result = await generateMonthlyExpense(1);
expect(result.id).toBe(99);
expect(result).toEqual(existing);
});
});