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

@@ -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"

View File

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

View File

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

View File

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

View 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 * *");
}

View 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;

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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