Files
personal-admin-2026/apps/backend/tests/expenses.test.ts

666 lines
19 KiB
TypeScript

import { beforeEach, describe, expect, mock, test } 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(),
findUniqueOrThrow: mock(),
create: mock(),
update: mock(),
count: 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();
}
}
mockPrisma.expense.count.mockResolvedValue(0);
});
const {
createPeriodicExpenseSchema,
createNonPeriodicExpenseSchema,
payExpenseSchema,
updateExpenseSchema,
buildDueDate,
getCurrentYearMonthUTC,
listPeriodicExpenses,
createPeriodicExpense,
updatePeriodicExpense,
softDeletePeriodicExpense,
listExpenses,
createNonPeriodicExpense,
payExpense,
updateExpense,
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.5,
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("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", () => {
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: { description: "asc" },
});
});
});
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: "desc" },
skip: 0,
take: 10,
});
});
test("filters by status when provided", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({ status: "PENDING" });
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { status: "PENDING" },
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
test("filters by periodicExpenseId when provided", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({ periodicExpenseId: 3 });
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { periodicExpenseId: 3 },
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
test("filters by search query with case-insensitive contains", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({ search: "Gim" });
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { description: { contains: "Gim", mode: "insensitive" } },
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
test("combines status, periodicExpenseId, and search filters", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({
status: "PENDING",
periodicExpenseId: 1,
search: "Agua",
});
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: {
status: "PENDING",
periodicExpenseId: 1,
description: { contains: "Agua", mode: "insensitive" },
},
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
});
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("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,
amountPayed: null,
paymentDate: null,
};
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("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(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();
});
});
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);
});
});