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:
@@ -18,7 +18,8 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3"
|
||||
"pino-pretty": "^13.1.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "bun run prisma/seed.ts"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PAYED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "periodic_expenses" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"description" VARCHAR(50) NOT NULL,
|
||||
"defaultDueDay" INTEGER NOT NULL,
|
||||
"defaultAmount" MONEY NOT NULL,
|
||||
"periods" INTEGER[],
|
||||
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "periodic_expenses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "expenses" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"description" VARCHAR(50) NOT NULL,
|
||||
"periodicExpenseId" INTEGER,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"amount" MONEY NOT NULL,
|
||||
"amountPayed" MONEY,
|
||||
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"paymentDate" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "expenses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_periodicExpenseId_fkey" FOREIGN KEY ("periodicExpenseId") REFERENCES "periodic_expenses"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -41,4 +41,41 @@ model QuoteHistory {
|
||||
type QuoteType
|
||||
|
||||
@@map("quotes_history")
|
||||
}
|
||||
|
||||
// Expenses
|
||||
|
||||
enum PaymentStatus {
|
||||
PENDING
|
||||
PAYED
|
||||
}
|
||||
|
||||
model PeriodicExpense {
|
||||
id Int @id @default(autoincrement())
|
||||
description String @db.VarChar(50)
|
||||
defaultDueDay Int
|
||||
defaultAmount Decimal @db.Money
|
||||
periods Int[]
|
||||
isDeleted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
expenses Expense[]
|
||||
|
||||
@@map("periodic_expenses")
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id Int @id @default(autoincrement())
|
||||
description String @db.VarChar(50)
|
||||
periodicExpense PeriodicExpense? @relation(fields: [periodicExpenseId], references: [id])
|
||||
periodicExpenseId Int?
|
||||
year Int
|
||||
month Int
|
||||
amount Decimal @db.Money
|
||||
amountPayed Decimal? @db.Money
|
||||
status PaymentStatus @default(PENDING)
|
||||
dueDate DateTime
|
||||
paymentDate DateTime?
|
||||
|
||||
@@map("expenses")
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { startQuoteJob } from "./modules/quotes/quote.job";
|
||||
import quotesRouter from "./modules/quotes/quotes.routes";
|
||||
import expensesRouter from "./modules/expenses/expenses.routes";
|
||||
import { startExpenseJob } from "./modules/expenses/expense.job";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -11,11 +13,13 @@ app.get("/api/health", (c) => {
|
||||
});
|
||||
|
||||
app.route("/api/quotes", quotesRouter);
|
||||
app.route("/api", expensesRouter);
|
||||
|
||||
app.use("/assets/*", serveStatic({ root: "./web" }));
|
||||
app.get("*", serveStatic({ path: "./web/index.html" }));
|
||||
|
||||
startQuoteJob();
|
||||
startExpenseJob();
|
||||
|
||||
logger.info("Backend started");
|
||||
|
||||
|
||||
16
apps/backend/src/modules/expenses/expense.job.ts
Normal file
16
apps/backend/src/modules/expenses/expense.job.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import cron from "node-cron";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { generateExpensesForCurrentMonth } from "./expenses.service";
|
||||
|
||||
export function startExpenseJob(): void {
|
||||
cron.schedule("0 1 1 * *", async () => {
|
||||
logger.info("Generating monthly expenses...");
|
||||
try {
|
||||
const results = await generateExpensesForCurrentMonth();
|
||||
logger.info({ results }, "Monthly expenses generated");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to generate monthly expenses");
|
||||
}
|
||||
});
|
||||
logger.info("Expense cron scheduled: 0 1 1 * *");
|
||||
}
|
||||
23
apps/backend/src/modules/expenses/expenses.routes.ts
Normal file
23
apps/backend/src/modules/expenses/expenses.routes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Hono } from "hono";
|
||||
import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
|
||||
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
|
||||
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
|
||||
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
|
||||
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
|
||||
import { listExpensesHandler } from "./handlers/listExpenses";
|
||||
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
|
||||
import { payExpenseHandler } from "./handlers/payExpense";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/periodic-expenses", listPeriodicExpensesHandler);
|
||||
app.post("/periodic-expenses", createPeriodicExpenseHandler);
|
||||
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
|
||||
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
|
||||
app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler);
|
||||
|
||||
app.get("/expenses", listExpensesHandler);
|
||||
app.post("/expenses", createNonPeriodicExpenseHandler);
|
||||
app.put("/expenses/:id/pay", payExpenseHandler);
|
||||
|
||||
export default app;
|
||||
160
apps/backend/src/modules/expenses/expenses.service.ts
Normal file
160
apps/backend/src/modules/expenses/expenses.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import { PaymentStatus } from "../../generated/prisma/client";
|
||||
|
||||
export const createPeriodicExpenseSchema = z.object({
|
||||
description: z.string().min(1).max(50),
|
||||
defaultDueDay: z.number().int().min(1).max(31),
|
||||
defaultAmount: z.number().positive(),
|
||||
periods: z.array(z.number().int().min(1).max(12)).min(1),
|
||||
});
|
||||
|
||||
export const updatePeriodicExpenseSchema = createPeriodicExpenseSchema;
|
||||
|
||||
export const createNonPeriodicExpenseSchema = z.object({
|
||||
description: z.string().min(1).max(50),
|
||||
amount: z.number().positive(),
|
||||
dueDate: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const payExpenseSchema = z.object({
|
||||
amountPayed: z.number().positive(),
|
||||
paymentDate: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
function lastDayOfMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
export function buildDueDate(year: number, month: number, dueDay: number): Date {
|
||||
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
|
||||
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
export function getCurrentYearMonthUTC(): { year: number; month: number } {
|
||||
const now = new Date();
|
||||
return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 };
|
||||
}
|
||||
|
||||
export async function listPeriodicExpenses() {
|
||||
return prisma.periodicExpense.findMany({
|
||||
where: { isDeleted: false },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPeriodicExpense(data: z.infer<typeof createPeriodicExpenseSchema>) {
|
||||
const result = await prisma.periodicExpense.create({ data });
|
||||
const { year, month } = getCurrentYearMonthUTC();
|
||||
const currentMonthApplicable = data.periods.includes(month);
|
||||
return { ...result, currentMonthApplicable };
|
||||
}
|
||||
|
||||
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) {
|
||||
return prisma.periodicExpense.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
export async function softDeletePeriodicExpense(id: number) {
|
||||
return prisma.periodicExpense.update({
|
||||
where: { id },
|
||||
data: { isDeleted: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateMonthlyExpense(id: number) {
|
||||
const periodic = await prisma.periodicExpense.findUniqueOrThrow({ where: { id } });
|
||||
const { year, month } = getCurrentYearMonthUTC();
|
||||
|
||||
const existing = await prisma.expense.findFirst({
|
||||
where: { periodicExpenseId: id, year, month },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
|
||||
return prisma.expense.create({
|
||||
data: {
|
||||
description: periodic.description,
|
||||
periodicExpenseId: id,
|
||||
year,
|
||||
month,
|
||||
amount: periodic.defaultAmount,
|
||||
dueDate,
|
||||
status: PaymentStatus.PENDING,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listExpenses(status?: string) {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (status === "PENDING" || status === "PAYED") {
|
||||
where.status = status;
|
||||
}
|
||||
return prisma.expense.findMany({
|
||||
where,
|
||||
include: { periodicExpense: true },
|
||||
orderBy: { dueDate: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
|
||||
const dueDate = new Date(data.dueDate);
|
||||
const year = dueDate.getUTCFullYear();
|
||||
const month = dueDate.getUTCMonth() + 1;
|
||||
return prisma.expense.create({
|
||||
data: {
|
||||
description: data.description,
|
||||
amount: data.amount,
|
||||
amountPayed: data.amount,
|
||||
dueDate,
|
||||
paymentDate: dueDate,
|
||||
year,
|
||||
month,
|
||||
status: PaymentStatus.PAYED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) {
|
||||
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date();
|
||||
return prisma.expense.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: PaymentStatus.PAYED,
|
||||
amountPayed: data.amountPayed,
|
||||
paymentDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateExpensesForCurrentMonth() {
|
||||
const { year, month } = getCurrentYearMonthUTC();
|
||||
|
||||
const periodics = await prisma.periodicExpense.findMany({
|
||||
where: { isDeleted: false, periods: { has: month } },
|
||||
});
|
||||
|
||||
const results: Array<{ id: number; status: string }> = [];
|
||||
for (const periodic of periodics) {
|
||||
const existing = await prisma.expense.findFirst({
|
||||
where: { periodicExpenseId: periodic.id, year, month },
|
||||
});
|
||||
if (existing) {
|
||||
results.push({ id: periodic.id, status: "already_exists" });
|
||||
continue;
|
||||
}
|
||||
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
|
||||
await prisma.expense.create({
|
||||
data: {
|
||||
description: periodic.description,
|
||||
periodicExpenseId: periodic.id,
|
||||
year,
|
||||
month,
|
||||
amount: periodic.defaultAmount,
|
||||
dueDate,
|
||||
status: PaymentStatus.PENDING,
|
||||
},
|
||||
});
|
||||
results.push({ id: periodic.id, status: "created" });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Context } from "hono";
|
||||
import { createNonPeriodicExpense, createNonPeriodicExpenseSchema } from "../expenses.service";
|
||||
|
||||
export async function createNonPeriodicExpenseHandler(c: Context) {
|
||||
const body = await c.req.json();
|
||||
const parsed = createNonPeriodicExpenseSchema.parse(body);
|
||||
const result = await createNonPeriodicExpense(parsed);
|
||||
return c.json(result, 201);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Context } from "hono";
|
||||
import { createPeriodicExpense, createPeriodicExpenseSchema } from "../expenses.service";
|
||||
|
||||
export async function createPeriodicExpenseHandler(c: Context) {
|
||||
const body = await c.req.json();
|
||||
const parsed = createPeriodicExpenseSchema.parse(body);
|
||||
const result = await createPeriodicExpense(parsed);
|
||||
return c.json(result, 201);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Context } from "hono";
|
||||
import { generateMonthlyExpense } from "../expenses.service";
|
||||
|
||||
export async function generateMonthlyExpenseHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
const result = await generateMonthlyExpense(id);
|
||||
return c.json(result);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Context } from "hono";
|
||||
import { listExpenses } from "../expenses.service";
|
||||
|
||||
export async function listExpensesHandler(c: Context) {
|
||||
const status = c.req.query("status");
|
||||
const expenses = await listExpenses(status);
|
||||
return c.json(expenses);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Context } from "hono";
|
||||
import { listPeriodicExpenses } from "../expenses.service";
|
||||
|
||||
export async function listPeriodicExpensesHandler(c: Context) {
|
||||
const expenses = await listPeriodicExpenses();
|
||||
return c.json(expenses);
|
||||
}
|
||||
10
apps/backend/src/modules/expenses/handlers/payExpense.ts
Normal file
10
apps/backend/src/modules/expenses/handlers/payExpense.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Context } from "hono";
|
||||
import { payExpense, payExpenseSchema } from "../expenses.service";
|
||||
|
||||
export async function payExpenseHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = await c.req.json();
|
||||
const parsed = payExpenseSchema.parse(body);
|
||||
const result = await payExpense(id, parsed);
|
||||
return c.json(result);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Context } from "hono";
|
||||
import { softDeletePeriodicExpense } from "../expenses.service";
|
||||
|
||||
export async function softDeletePeriodicExpenseHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
await softDeletePeriodicExpense(id);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Context } from "hono";
|
||||
import { updatePeriodicExpense, updatePeriodicExpenseSchema } from "../expenses.service";
|
||||
|
||||
export async function updatePeriodicExpenseHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = await c.req.json();
|
||||
const parsed = updatePeriodicExpenseSchema.parse(body);
|
||||
const result = await updatePeriodicExpense(id, parsed);
|
||||
return c.json(result);
|
||||
}
|
||||
450
apps/backend/tests/expenses.test.ts
Normal file
450
apps/backend/tests/expenses.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user