diff --git a/apps/backend/package.json b/apps/backend/package.json index d3004de..fe15250 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,7 +9,9 @@ "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", "prisma:migrate:deploy": "prisma migrate deploy", - "prisma:seed": "bun run prisma/seed.ts" + "prisma:seed": "bun run prisma/seed.ts", + "lint": "biome check src/", + "lint:fix": "biome check --write src/" }, "dependencies": { "better-auth": "^1.6.11", diff --git a/apps/backend/prisma.config.ts b/apps/backend/prisma.config.ts index d0279d3..449ea5c 100644 --- a/apps/backend/prisma.config.ts +++ b/apps/backend/prisma.config.ts @@ -10,6 +10,6 @@ export default defineConfig({ seed: "bun ./prisma/seed.ts", }, datasource: { - url: process.env["DATABASE_URL"], + url: process.env.DATABASE_URL, }, }); diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index ca721ff..625fa78 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1,10 +1,13 @@ import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { PrismaClient } from "../src/generated/prisma/client"; -import { PrismaPg } from "@prisma/adapter-pg"; -const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); +const adapter = new PrismaPg({ + // biome-ignore lint/style/noNonNullAssertion: required env var + connectionString: process.env.DATABASE_URL!, +}); const prisma = new PrismaClient({ adapter }); async function main() { @@ -26,6 +29,7 @@ async function main() { disableSignUp: false, minPasswordLength: 4, }, + // biome-ignore lint/style/noNonNullAssertion: required env var secret: process.env.BETTER_AUTH_SECRET!, baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000", }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 7f02e72..3b38334 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,14 +2,14 @@ import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { auth } from "./lib/auth"; import { authMiddleware } from "./lib/auth-middleware"; -import { requestLogger } from "./lib/request-logger"; -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 { cache } from "./lib/cache"; import { eventBus } from "./lib/event-bus"; import { logger } from "./lib/logger"; +import { requestLogger } from "./lib/request-logger"; +import { startExpenseJob } from "./modules/expenses/expense.job"; +import expensesRouter from "./modules/expenses/expenses.routes"; +import { startQuoteJob } from "./modules/quotes/quote.job"; +import quotesRouter from "./modules/quotes/quotes.routes"; const app = new Hono(); diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts index fb530dc..eec0ade 100644 --- a/apps/backend/src/lib/auth.ts +++ b/apps/backend/src/lib/auth.ts @@ -2,6 +2,11 @@ import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { prisma } from "./prisma"; +// biome-ignore lint/style/noNonNullAssertion: required env vars +const secret = process.env.BETTER_AUTH_SECRET!; +// biome-ignore lint/style/noNonNullAssertion: required env vars +const baseURL = process.env.BETTER_AUTH_URL!; + export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", @@ -11,10 +16,7 @@ export const auth = betterAuth({ disableSignUp: true, minPasswordLength: 4, }, - secret: process.env.BETTER_AUTH_SECRET!, - baseURL: process.env.BETTER_AUTH_URL!, - trustedOrigins: [ - process.env.BETTER_AUTH_URL!, - "http://localhost:5173", - ], + secret, + baseURL, + trustedOrigins: [baseURL, "http://localhost:5173"], }); diff --git a/apps/backend/src/lib/event-bus.ts b/apps/backend/src/lib/event-bus.ts index 230f593..65f81da 100644 --- a/apps/backend/src/lib/event-bus.ts +++ b/apps/backend/src/lib/event-bus.ts @@ -7,12 +7,14 @@ class EventBus { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } - this.listeners.get(event)!.add(listener); + this.listeners.get(event)?.add(listener); return () => this.listeners.get(event)?.delete(listener); } emit(event: string, ...args: unknown[]): void { - this.listeners.get(event)?.forEach((listener) => listener(...args)); + this.listeners.get(event)?.forEach((listener) => { + listener(...args); + }); } } diff --git a/apps/backend/src/lib/prisma.ts b/apps/backend/src/lib/prisma.ts index 9567412..5a4c6a0 100644 --- a/apps/backend/src/lib/prisma.ts +++ b/apps/backend/src/lib/prisma.ts @@ -2,5 +2,7 @@ import "dotenv/config"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "../generated/prisma/client"; -const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); +// biome-ignore lint/style/noNonNullAssertion: required env var +const databaseUrl = process.env.DATABASE_URL!; +const adapter = new PrismaPg({ connectionString: databaseUrl }); export const prisma = new PrismaClient({ adapter }); diff --git a/apps/backend/src/modules/expenses/expenses.routes.ts b/apps/backend/src/modules/expenses/expenses.routes.ts index 8b45589..b9a8aaf 100644 --- a/apps/backend/src/modules/expenses/expenses.routes.ts +++ b/apps/backend/src/modules/expenses/expenses.routes.ts @@ -1,18 +1,18 @@ 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"; +import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense"; +import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense"; import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal"; import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals"; -import { getTotalPendingHandler } from "./handlers/getTotalPending"; import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses"; -import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses"; +import { getTotalPendingHandler } from "./handlers/getTotalPending"; import { importExpensesHandler } from "./handlers/importExpenses"; +import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses"; +import { listExpensesHandler } from "./handlers/listExpenses"; +import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses"; +import { payExpenseHandler } from "./handlers/payExpense"; +import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense"; +import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense"; const app = new Hono(); @@ -21,7 +21,10 @@ app.post("/periodic-expenses", createPeriodicExpenseHandler); app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler); app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler); app.post("/periodic-expenses/import", importPeriodicExpensesHandler); -app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler); +app.post( + "/periodic-expenses/:id/generate-month", + generateMonthlyExpenseHandler, +); app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler); app.get("/expenses/totals", getMonthlyTotalsHandler); diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index 9a077c7..a8fd179 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { prisma } from "../../lib/prisma"; -import { cache } from "../../lib/cache"; import { PaymentStatus } from "../../generated/prisma/client"; +import { cache } from "../../lib/cache"; +import { prisma } from "../../lib/prisma"; export const createPeriodicExpenseSchema = z.object({ description: z.string().min(1).max(50), @@ -27,7 +27,11 @@ function lastDayOfMonth(year: number, month: number): number { return new Date(year, month, 0).getDate(); } -export function buildDueDate(year: number, month: number, dueDay: number): Date { +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)); } @@ -49,15 +53,20 @@ export async function listPeriodicExpenses() { return data; } -export async function createPeriodicExpense(data: z.infer) { +export async function createPeriodicExpense( + data: z.infer, +) { const result = await prisma.periodicExpense.create({ data }); - const { year, month } = getCurrentYearMonthUTC(); + const { month } = getCurrentYearMonthUTC(); const currentMonthApplicable = data.periods.includes(month); cache.invalidateByPrefix("expenses:"); return { ...result, currentMonthApplicable }; } -export async function updatePeriodicExpense(id: number, data: z.infer) { +export async function updatePeriodicExpense( + id: number, + data: z.infer, +) { const result = await prisma.periodicExpense.update({ where: { id }, data }); cache.invalidateByPrefix("expenses:"); return result; @@ -73,7 +82,9 @@ export async function softDeletePeriodicExpense(id: number) { } export async function generateMonthlyExpense(id: number) { - const periodic = await prisma.periodicExpense.findUniqueOrThrow({ where: { id } }); + const periodic = await prisma.periodicExpense.findUniqueOrThrow({ + where: { id }, + }); const { year, month } = getCurrentYearMonthUTC(); const existing = await prisma.expense.findFirst({ @@ -135,7 +146,9 @@ export async function listExpenses(params: { return result; } -export async function createNonPeriodicExpense(data: z.infer) { +export async function createNonPeriodicExpense( + data: z.infer, +) { const dueDate = new Date(data.dueDate); const year = dueDate.getUTCFullYear(); const month = dueDate.getUTCMonth() + 1; @@ -155,8 +168,13 @@ export async function createNonPeriodicExpense(data: z.infer) { - const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date(); +export async function payExpense( + id: number, + data: z.infer, +) { + const paymentDate = data.paymentDate + ? new Date(data.paymentDate) + : new Date(); const result = await prisma.expense.update({ where: { id }, data: { @@ -189,7 +207,9 @@ export async function getMonthlyTotals(year: number, month: number) { const cached = cache.get(cacheKey); if (cached) return cached; - const result = await prisma.$queryRaw>` + const result = await prisma.$queryRaw< + Array<{ payed: string | null; pending: string | null }> + >` SELECT (SELECT CAST(SUM("amountPayed") AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus") as payed, (SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending @@ -218,7 +238,17 @@ export async function getTotalPending() { export async function listPendingUpcomingExpenses() { const now = new Date(); - const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); + const today = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + 0, + 0, + 0, + 0, + ), + ); const fiveDaysLater = new Date(today); fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5); @@ -233,7 +263,8 @@ export async function listPendingUpcomingExpenses() { return data.map((expense) => ({ ...expense, - dueType: expense.dueDate < today ? "overdue" as const : "upcoming" as const, + dueType: + expense.dueDate < today ? ("overdue" as const) : ("upcoming" as const), })); } diff --git a/apps/backend/src/modules/expenses/handlers/createNonPeriodicExpense.ts b/apps/backend/src/modules/expenses/handlers/createNonPeriodicExpense.ts index f319c45..83c6e4f 100644 --- a/apps/backend/src/modules/expenses/handlers/createNonPeriodicExpense.ts +++ b/apps/backend/src/modules/expenses/handlers/createNonPeriodicExpense.ts @@ -1,5 +1,8 @@ import type { Context } from "hono"; -import { createNonPeriodicExpense, createNonPeriodicExpenseSchema } from "../expenses.service"; +import { + createNonPeriodicExpense, + createNonPeriodicExpenseSchema, +} from "../expenses.service"; export async function createNonPeriodicExpenseHandler(c: Context) { const body = await c.req.json(); diff --git a/apps/backend/src/modules/expenses/handlers/createPeriodicExpense.ts b/apps/backend/src/modules/expenses/handlers/createPeriodicExpense.ts index 446cc03..1d6c9f5 100644 --- a/apps/backend/src/modules/expenses/handlers/createPeriodicExpense.ts +++ b/apps/backend/src/modules/expenses/handlers/createPeriodicExpense.ts @@ -1,5 +1,8 @@ import type { Context } from "hono"; -import { createPeriodicExpense, createPeriodicExpenseSchema } from "../expenses.service"; +import { + createPeriodicExpense, + createPeriodicExpenseSchema, +} from "../expenses.service"; export async function createPeriodicExpenseHandler(c: Context) { const body = await c.req.json(); diff --git a/apps/backend/src/modules/expenses/handlers/getMonthlyPayedTotal.ts b/apps/backend/src/modules/expenses/handlers/getMonthlyPayedTotal.ts index 4bb7e0f..f24e2b3 100644 --- a/apps/backend/src/modules/expenses/handlers/getMonthlyPayedTotal.ts +++ b/apps/backend/src/modules/expenses/handlers/getMonthlyPayedTotal.ts @@ -2,8 +2,14 @@ import type { Context } from "hono"; import { getMonthlyPayedTotal } from "../expenses.service"; export async function getMonthlyPayedTotalHandler(c: Context) { - const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10); - const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10); + const year = parseInt( + c.req.query("year") ?? String(new Date().getUTCFullYear()), + 10, + ); + const month = parseInt( + c.req.query("month") ?? String(new Date().getUTCMonth() + 1), + 10, + ); const result = await getMonthlyPayedTotal(year, month); return c.json(result); } diff --git a/apps/backend/src/modules/expenses/handlers/getMonthlyTotals.ts b/apps/backend/src/modules/expenses/handlers/getMonthlyTotals.ts index a7cedfd..a0d59dd 100644 --- a/apps/backend/src/modules/expenses/handlers/getMonthlyTotals.ts +++ b/apps/backend/src/modules/expenses/handlers/getMonthlyTotals.ts @@ -2,8 +2,14 @@ import type { Context } from "hono"; import { getMonthlyTotals } from "../expenses.service"; export async function getMonthlyTotalsHandler(c: Context) { - const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10); - const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10); + const year = parseInt( + c.req.query("year") ?? String(new Date().getUTCFullYear()), + 10, + ); + const month = parseInt( + c.req.query("month") ?? String(new Date().getUTCMonth() + 1), + 10, + ); const result = await getMonthlyTotals(year, month); return c.json(result); } diff --git a/apps/backend/src/modules/expenses/handlers/importExpenses.ts b/apps/backend/src/modules/expenses/handlers/importExpenses.ts index 1a669b5..7d37b6d 100644 --- a/apps/backend/src/modules/expenses/handlers/importExpenses.ts +++ b/apps/backend/src/modules/expenses/handlers/importExpenses.ts @@ -1,26 +1,26 @@ import type { Context } from "hono"; -import { prisma } from "../../../lib/prisma"; +import { PaymentStatus } from "../../../generated/prisma/client"; import { cache } from "../../../lib/cache"; import { logger } from "../../../lib/logger"; -import { PaymentStatus } from "../../../generated/prisma/client"; +import { prisma } from "../../../lib/prisma"; function parseAmount(raw: string): number | null { const cleaned = raw.replace("$", "").replace(/,/g, "").trim(); if (!cleaned) return null; const n = Number(cleaned); - return isNaN(n) ? null : n; + return Number.isNaN(n) ? null : n; } function parseDate(raw: string): Date | null { const trimmed = raw.trim(); if (!trimmed) return null; const d = new Date(trimmed); - return isNaN(d.getTime()) ? null : d; + return Number.isNaN(d.getTime()) ? null : d; } export async function importExpensesHandler(c: Context) { const body = await c.req.parseBody(); - const file = body["file"]; + const file = body.file; if (!file || !(file instanceof File)) { return c.json({ error: "No se envió ningún archivo" }, 400); @@ -30,7 +30,10 @@ export async function importExpensesHandler(c: Context) { const lines = text.trim().split("\n"); if (lines.length < 2) { - return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400); + return c.json( + { error: "El archivo está vacío o solo tiene encabezados" }, + 400, + ); } let imported = 0; @@ -43,7 +46,9 @@ export async function importExpensesHandler(c: Context) { const parts = line.split("|"); if (parts.length < 10) { - errors.push(`Línea ${i + 1}: formato inválido (${parts.length} columnas)`); + errors.push( + `Línea ${i + 1}: formato inválido (${parts.length} columnas)`, + ); continue; } @@ -68,7 +73,9 @@ export async function importExpensesHandler(c: Context) { const paymentDate = parseDate(rawPaymentDate); if (!dueDate) { - errors.push(`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`); + errors.push( + `Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`, + ); continue; } @@ -90,7 +97,8 @@ export async function importExpensesHandler(c: Context) { } } - const status = rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING; + const status = + rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING; await prisma.expense.create({ data: { diff --git a/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts b/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts index 78c594c..04d969a 100644 --- a/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts +++ b/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts @@ -1,7 +1,7 @@ import type { Context } from "hono"; -import { prisma } from "../../../lib/prisma"; import { cache } from "../../../lib/cache"; import { logger } from "../../../lib/logger"; +import { prisma } from "../../../lib/prisma"; function parseArgentineAmount(raw: string): number { const cleaned = raw.replace("$", "").replace(/,/g, "").trim(); @@ -16,7 +16,7 @@ function parsePeriods(raw: string): number[] { export async function importPeriodicExpensesHandler(c: Context) { const body = await c.req.parseBody(); - const file = body["file"]; + const file = body.file; if (!file || !(file instanceof File)) { return c.json({ error: "No se envió ningún archivo" }, 400); @@ -26,7 +26,10 @@ export async function importPeriodicExpensesHandler(c: Context) { const lines = text.trim().split("\n"); if (lines.length < 2) { - return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400); + return c.json( + { error: "El archivo está vacío o solo tiene encabezados" }, + 400, + ); } let imported = 0; @@ -71,8 +74,8 @@ export async function importPeriodicExpensesHandler(c: Context) { await prisma.periodicExpense.create({ data: { description, - defaultDueDay: isNaN(defaultDueDay) ? 1 : defaultDueDay, - defaultAmount: isNaN(defaultAmount) ? 0 : defaultAmount, + defaultDueDay: Number.isNaN(defaultDueDay) ? 1 : defaultDueDay, + defaultAmount: Number.isNaN(defaultAmount) ? 0 : defaultAmount, periods, }, }); diff --git a/apps/backend/src/modules/expenses/handlers/listExpenses.ts b/apps/backend/src/modules/expenses/handlers/listExpenses.ts index 7b41d61..e2a8b27 100644 --- a/apps/backend/src/modules/expenses/handlers/listExpenses.ts +++ b/apps/backend/src/modules/expenses/handlers/listExpenses.ts @@ -11,7 +11,9 @@ export async function listExpensesHandler(c: Context) { status, page, pageSize, - periodicExpenseId: periodicExpenseId ? parseInt(periodicExpenseId, 10) : undefined, + periodicExpenseId: periodicExpenseId + ? parseInt(periodicExpenseId, 10) + : undefined, search: search || undefined, }); return c.json(result); diff --git a/apps/backend/src/modules/expenses/handlers/updatePeriodicExpense.ts b/apps/backend/src/modules/expenses/handlers/updatePeriodicExpense.ts index ad29cb0..021deb2 100644 --- a/apps/backend/src/modules/expenses/handlers/updatePeriodicExpense.ts +++ b/apps/backend/src/modules/expenses/handlers/updatePeriodicExpense.ts @@ -1,5 +1,8 @@ import type { Context } from "hono"; -import { updatePeriodicExpense, updatePeriodicExpenseSchema } from "../expenses.service"; +import { + updatePeriodicExpense, + updatePeriodicExpenseSchema, +} from "../expenses.service"; export async function updatePeriodicExpenseHandler(c: Context) { const id = Number(c.req.param("id")); diff --git a/apps/backend/src/modules/quotes/belo.service.ts b/apps/backend/src/modules/quotes/belo.service.ts index ef96214..95f2364 100644 --- a/apps/backend/src/modules/quotes/belo.service.ts +++ b/apps/backend/src/modules/quotes/belo.service.ts @@ -45,7 +45,10 @@ export async function processBeloQuote(): Promise { const diff = roundTo(sell - existingSell, 2); const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0; - logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BELO`); + logger.debug( + { buy, sell, diferencia: diff, porcentaje: pct }, + `Cotización BELO`, + ); const todayStart = startOfToday(); @@ -62,7 +65,8 @@ export async function processBeloQuote(): Promise { if (prevDayHistory) { const prevSell = Number(prevDayHistory.sell); prevDayDiff = roundTo(sell - prevSell, 2); - prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; + prevDayPct = + prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; } await prisma.quote.update({ diff --git a/apps/backend/src/modules/quotes/blue.service.ts b/apps/backend/src/modules/quotes/blue.service.ts index 0c5e783..a5d62a4 100644 --- a/apps/backend/src/modules/quotes/blue.service.ts +++ b/apps/backend/src/modules/quotes/blue.service.ts @@ -10,22 +10,25 @@ interface DolaritoApiResponse { } export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> { - const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", { - credentials: "omit", - headers: { - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0", - Accept: "application/json, text/plain, */*", - "Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3", - "auth-client": "a4b981d93266b37ea6a646f5b3538f8a", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-site", + const response = await fetch( + "https://api.dolarito.ar/api/frontend/quotations/dolar", + { + credentials: "omit", + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0", + Accept: "application/json, text/plain, */*", + "Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3", + "auth-client": "a4b981d93266b37ea6a646f5b3538f8a", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + }, + referrer: "https://www.dolarito.ar/", + method: "GET", + mode: "cors", }, - referrer: "https://www.dolarito.ar/", - method: "GET", - mode: "cors", - }); + ); const data: DolaritoApiResponse = await response.json(); return { buy: data.informal.buy, sell: data.informal.sell }; } @@ -59,7 +62,10 @@ export async function processBlueQuote(): Promise { const diff = roundTo(sell - existingSell, 2); const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0; - logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BLUE`); + logger.debug( + { buy, sell, diferencia: diff, porcentaje: pct }, + `Cotización BLUE`, + ); const todayStart = startOfToday(); @@ -76,7 +82,8 @@ export async function processBlueQuote(): Promise { if (prevDayHistory) { const prevSell = Number(prevDayHistory.sell); prevDayDiff = roundTo(sell - prevSell, 2); - prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; + prevDayPct = + prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; } await prisma.quote.update({ diff --git a/apps/backend/src/modules/quotes/bna.service.ts b/apps/backend/src/modules/quotes/bna.service.ts index 497b8f4..feec2e8 100644 --- a/apps/backend/src/modules/quotes/bna.service.ts +++ b/apps/backend/src/modules/quotes/bna.service.ts @@ -10,22 +10,25 @@ interface DolaritoApiResponse { } export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> { - const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", { - credentials: "omit", - headers: { - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0", - Accept: "application/json, text/plain, */*", - "Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3", - "auth-client": "a4b981d93266b37ea6a646f5b3538f8a", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-site", + const response = await fetch( + "https://api.dolarito.ar/api/frontend/quotations/dolar", + { + credentials: "omit", + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0", + Accept: "application/json, text/plain, */*", + "Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3", + "auth-client": "a4b981d93266b37ea6a646f5b3538f8a", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + }, + referrer: "https://www.dolarito.ar/", + method: "GET", + mode: "cors", }, - referrer: "https://www.dolarito.ar/", - method: "GET", - mode: "cors", - }); + ); const data: DolaritoApiResponse = await response.json(); return { buy: data.oficial.buy, sell: data.oficial.sell }; } @@ -59,7 +62,10 @@ export async function processBnaQuote(): Promise { const diff = roundTo(sell - existingSell, 2); const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0; - logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BNA`); + logger.debug( + { buy, sell, diferencia: diff, porcentaje: pct }, + `Cotización BNA`, + ); const todayStart = startOfToday(); @@ -76,7 +82,8 @@ export async function processBnaQuote(): Promise { if (prevDayHistory) { const prevSell = Number(prevDayHistory.sell); prevDayDiff = roundTo(sell - prevSell, 2); - prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; + prevDayPct = + prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; } await prisma.quote.update({ diff --git a/apps/backend/src/modules/quotes/handlers/fetchQuotes.ts b/apps/backend/src/modules/quotes/handlers/fetchQuotes.ts index 1c33f22..8b2b61d 100644 --- a/apps/backend/src/modules/quotes/handlers/fetchQuotes.ts +++ b/apps/backend/src/modules/quotes/handlers/fetchQuotes.ts @@ -1,14 +1,18 @@ import type { Context } from "hono"; +import { eventBus } from "../../../lib/event-bus"; +import { logger } from "../../../lib/logger"; import { processBeloQuote } from "../belo.service"; import { processBlueQuote } from "../blue.service"; import { processBnaQuote } from "../bna.service"; -import { logger } from "../../../lib/logger"; -import { eventBus } from "../../../lib/event-bus"; export async function fetchQuotes(c: Context) { const results: { type: string; status: string; error?: string }[] = []; - for (const [name, fn] of [["BELO", processBeloQuote], ["BLUE", processBlueQuote], ["BNA", processBnaQuote]] as const) { + for (const [name, fn] of [ + ["BELO", processBeloQuote], + ["BLUE", processBlueQuote], + ["BNA", processBnaQuote], + ] as const) { try { await fn(); results.push({ type: name, status: "ok" }); diff --git a/apps/backend/src/modules/quotes/handlers/getCurrentQuotes.ts b/apps/backend/src/modules/quotes/handlers/getCurrentQuotes.ts index c63ed06..ca49b0a 100644 --- a/apps/backend/src/modules/quotes/handlers/getCurrentQuotes.ts +++ b/apps/backend/src/modules/quotes/handlers/getCurrentQuotes.ts @@ -1,6 +1,6 @@ import type { Context } from "hono"; -import { prisma } from "../../../lib/prisma"; import { cache } from "../../../lib/cache"; +import { prisma } from "../../../lib/prisma"; export async function getCurrentQuotes(c: Context) { const cached = cache.get("quotes:current"); diff --git a/apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts b/apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts index 9ff2114..4b42e83 100644 --- a/apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts +++ b/apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts @@ -1,15 +1,20 @@ import type { Context } from "hono"; -import { prisma } from "../../../lib/prisma"; -import { cache } from "../../../lib/cache"; import type { QuoteType } from "../../../generated/prisma/client"; +import { cache } from "../../../lib/cache"; +import { prisma } from "../../../lib/prisma"; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; export async function getDailyMinMax(c: Context) { - const rawType = c.req.param("type")!.toUpperCase(); + const rawType = c.req.param("type")?.toUpperCase(); - if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) { - return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400); + if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) { + return c.json( + { + error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`, + }, + 400, + ); } const startDateParam = c.req.query("startDate"); @@ -23,7 +28,7 @@ export async function getDailyMinMax(c: Context) { ? new Date(new Date(endDateParam).getTime() + 86_400_000) : now; - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { return c.json({ error: "Invalid date format. Use ISO 8601." }, 400); } @@ -31,13 +36,15 @@ export async function getDailyMinMax(c: Context) { const cached = cache.get(cacheKey); if (cached) return c.json(cached); - const rows = await prisma.$queryRaw>` + const rows = await prisma.$queryRaw< + Array<{ + date: string; + minBuy: number; + maxBuy: number; + minSell: number; + maxSell: number; + }> + >` SELECT DATE("timeStamp")::text AS date, MIN(buy::numeric)::float8 AS "minBuy", diff --git a/apps/backend/src/modules/quotes/handlers/getDailyQuotes.ts b/apps/backend/src/modules/quotes/handlers/getDailyQuotes.ts index c198de6..0a45d61 100644 --- a/apps/backend/src/modules/quotes/handlers/getDailyQuotes.ts +++ b/apps/backend/src/modules/quotes/handlers/getDailyQuotes.ts @@ -1,14 +1,19 @@ import type { Context } from "hono"; -import { prisma } from "../../../lib/prisma"; import { cache } from "../../../lib/cache"; +import { prisma } from "../../../lib/prisma"; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; export async function getDailyQuotes(c: Context) { - const rawType = c.req.param("type")!.toUpperCase(); + const rawType = c.req.param("type")?.toUpperCase(); - if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) { - return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400); + if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) { + return c.json( + { + error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`, + }, + 400, + ); } const startDateParam = c.req.query("startDate"); @@ -22,7 +27,7 @@ export async function getDailyQuotes(c: Context) { ? new Date(new Date(endDateParam).getTime() + 86_400_000) : now; - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { return c.json({ error: "Invalid date format. Use ISO 8601." }, 400); } @@ -30,11 +35,13 @@ export async function getDailyQuotes(c: Context) { const cached = cache.get(cacheKey); if (cached) return c.json(cached); - const rows = await prisma.$queryRaw>` + const rows = await prisma.$queryRaw< + Array<{ + date: string; + buy: number; + sell: number; + }> + >` SELECT DATE("timeStamp")::text AS date, buy::numeric::float8 AS buy, sell::numeric::float8 AS sell FROM ( SELECT DISTINCT ON (DATE("timeStamp")) "timeStamp", buy, sell FROM "quotes_history" diff --git a/apps/backend/src/modules/quotes/handlers/getHistoricalMinMax.ts b/apps/backend/src/modules/quotes/handlers/getHistoricalMinMax.ts index aa1d6dd..7105198 100644 --- a/apps/backend/src/modules/quotes/handlers/getHistoricalMinMax.ts +++ b/apps/backend/src/modules/quotes/handlers/getHistoricalMinMax.ts @@ -1,14 +1,19 @@ import type { Context } from "hono"; -import { prisma } from "../../../lib/prisma"; import { cache } from "../../../lib/cache"; +import { prisma } from "../../../lib/prisma"; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; export async function getHistoricalMinMax(c: Context) { - const rawType = c.req.param("type")!.toUpperCase(); + const rawType = c.req.param("type")?.toUpperCase(); - if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) { - return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400); + if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) { + return c.json( + { + error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`, + }, + 400, + ); } const cacheKey = `quotes:historical:minmax:${rawType}`; @@ -17,12 +22,14 @@ export async function getHistoricalMinMax(c: Context) { const type = rawType as string; - const rows = await prisma.$queryRaw>` + const rows = await prisma.$queryRaw< + Array<{ + minBuy: number; + maxBuy: number; + minBuyDate: string; + maxBuyDate: string; + }> + >` SELECT (SELECT MIN(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "minBuy", (SELECT MAX(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "maxBuy", @@ -30,7 +37,12 @@ export async function getHistoricalMinMax(c: Context) { (SELECT "timeStamp"::text FROM "quotes_history" WHERE "type" = ${type}::"QuoteType" ORDER BY buy::numeric DESC LIMIT 1) AS "maxBuyDate" `; - const result = rows[0] ?? { minBuy: 0, maxBuy: 0, minBuyDate: null, maxBuyDate: null }; + const result = rows[0] ?? { + minBuy: 0, + maxBuy: 0, + minBuyDate: null, + maxBuyDate: null, + }; cache.set(cacheKey, result); return c.json(result); diff --git a/apps/backend/src/modules/quotes/handlers/getQuoteHistory.ts b/apps/backend/src/modules/quotes/handlers/getQuoteHistory.ts index 57d7a40..c5e2012 100644 --- a/apps/backend/src/modules/quotes/handlers/getQuoteHistory.ts +++ b/apps/backend/src/modules/quotes/handlers/getQuoteHistory.ts @@ -1,15 +1,20 @@ import type { Context } from "hono"; -import { prisma } from "../../../lib/prisma"; -import { cache } from "../../../lib/cache"; import type { QuoteType } from "../../../generated/prisma/client"; +import { cache } from "../../../lib/cache"; +import { prisma } from "../../../lib/prisma"; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; export async function getQuoteHistory(c: Context) { - const rawType = c.req.param("type")!.toUpperCase(); + const rawType = c.req.param("type")?.toUpperCase(); - if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) { - return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400); + if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) { + return c.json( + { + error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`, + }, + 400, + ); } const type = rawType as QuoteType; @@ -24,7 +29,7 @@ export async function getQuoteHistory(c: Context) { ? new Date(new Date(endDateParam).getTime() + 86_400_000) : now; - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { return c.json({ error: "Invalid date format. Use ISO 8601." }, 400); } diff --git a/apps/backend/src/modules/quotes/handlers/quoteEvents.ts b/apps/backend/src/modules/quotes/handlers/quoteEvents.ts index 1197539..3c87142 100644 --- a/apps/backend/src/modules/quotes/handlers/quoteEvents.ts +++ b/apps/backend/src/modules/quotes/handlers/quoteEvents.ts @@ -8,7 +8,9 @@ export function quoteEvents(c: Context) { function cleanup() { if (isCancelled) return; isCancelled = true; - cleanups.forEach((fn) => fn()); + cleanups.forEach((fn) => { + fn(); + }); } const stream = new ReadableStream({ @@ -43,7 +45,7 @@ export function quoteEvents(c: Context) { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", - "Connection": "keep-alive", + Connection: "keep-alive", }, }); } diff --git a/apps/backend/src/modules/quotes/quote.job.ts b/apps/backend/src/modules/quotes/quote.job.ts index cbf883b..39d869a 100644 --- a/apps/backend/src/modules/quotes/quote.job.ts +++ b/apps/backend/src/modules/quotes/quote.job.ts @@ -1,9 +1,9 @@ import cron from "node-cron"; +import { eventBus } from "../../lib/event-bus"; +import { logger } from "../../lib/logger"; import { processBeloQuote } from "./belo.service"; import { processBlueQuote } from "./blue.service"; import { processBnaQuote } from "./bna.service"; -import { logger } from "../../lib/logger"; -import { eventBus } from "../../lib/event-bus"; async function safeProcess( name: string, diff --git a/apps/backend/src/modules/quotes/quotes.routes.ts b/apps/backend/src/modules/quotes/quotes.routes.ts index 89828f9..a99a378 100644 --- a/apps/backend/src/modules/quotes/quotes.routes.ts +++ b/apps/backend/src/modules/quotes/quotes.routes.ts @@ -1,10 +1,10 @@ import { Hono } from "hono"; -import { getCurrentQuotes } from "./handlers/getCurrentQuotes"; import { fetchQuotes } from "./handlers/fetchQuotes"; -import { getQuoteHistory } from "./handlers/getQuoteHistory"; -import { getDailyQuotes } from "./handlers/getDailyQuotes"; +import { getCurrentQuotes } from "./handlers/getCurrentQuotes"; import { getDailyMinMax } from "./handlers/getDailyMinMax"; +import { getDailyQuotes } from "./handlers/getDailyQuotes"; import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax"; +import { getQuoteHistory } from "./handlers/getQuoteHistory"; import { quoteEvents } from "./handlers/quoteEvents"; const app = new Hono(); diff --git a/apps/backend/tests/expenses.test.ts b/apps/backend/tests/expenses.test.ts index d84dde0..e748bda 100644 --- a/apps/backend/tests/expenses.test.ts +++ b/apps/backend/tests/expenses.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; import { PaymentStatus } from "../src/generated/prisma/client"; const mockPrisma = { @@ -23,7 +23,9 @@ mock.module("../src/lib/prisma", () => ({ beforeEach(() => { for (const model of Object.values(mockPrisma)) { - for (const fn of Object.values(model as Record>)) { + for (const fn of Object.values( + model as Record>, + )) { fn.mockClear(); } } @@ -32,7 +34,6 @@ beforeEach(() => { const { createPeriodicExpenseSchema, - updatePeriodicExpenseSchema, createNonPeriodicExpenseSchema, payExpenseSchema, buildDueDate, @@ -54,7 +55,7 @@ describe("schemas", () => { const result = createPeriodicExpenseSchema.parse({ description: "Gimnasio", defaultDueDay: 10, - defaultAmount: 1500.50, + defaultAmount: 1500.5, periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], }); expect(result.description).toBe("Gimnasio"); @@ -67,7 +68,7 @@ describe("schemas", () => { defaultDueDay: 10, defaultAmount: 100, periods: [1], - }) + }), ).toThrow(); }); @@ -78,7 +79,7 @@ describe("schemas", () => { defaultDueDay: 10, defaultAmount: 100, periods: [1], - }) + }), ).toThrow(); }); @@ -89,7 +90,7 @@ describe("schemas", () => { defaultDueDay: 0, defaultAmount: 100, periods: [1], - }) + }), ).toThrow(); }); @@ -100,7 +101,7 @@ describe("schemas", () => { defaultDueDay: 32, defaultAmount: 100, periods: [1], - }) + }), ).toThrow(); }); @@ -111,7 +112,7 @@ describe("schemas", () => { defaultDueDay: 15, defaultAmount: 0, periods: [1], - }) + }), ).toThrow(); }); @@ -122,7 +123,7 @@ describe("schemas", () => { defaultDueDay: 15, defaultAmount: 100, periods: [], - }) + }), ).toThrow(); }); @@ -133,7 +134,7 @@ describe("schemas", () => { defaultDueDay: 15, defaultAmount: 100, periods: [0, 13], - }) + }), ).toThrow(); }); }); @@ -154,7 +155,7 @@ describe("schemas", () => { description: "Ropa", amount: -100, dueDate: "2026-06-15T00:00:00.000Z", - }) + }), ).toThrow(); }); }); @@ -175,9 +176,7 @@ describe("schemas", () => { }); test("rejects zero amountPayed", () => { - expect(() => - payExpenseSchema.parse({ amountPayed: 0 }) - ).toThrow(); + expect(() => payExpenseSchema.parse({ amountPayed: 0 })).toThrow(); }); }); }); @@ -244,7 +243,12 @@ describe("createPeriodicExpense", () => { defaultAmount: 1500, periods: [6], }; - const created = { id: 1, ...input, isDeleted: false, createdAt: new Date() }; + const created = { + id: 1, + ...input, + isDeleted: false, + createdAt: new Date(), + }; mockPrisma.periodicExpense.create.mockResolvedValueOnce(created); const result = await createPeriodicExpense(input); @@ -339,7 +343,11 @@ describe("listExpenses", () => { test("combines status, periodicExpenseId, and search filters", async () => { mockPrisma.expense.findMany.mockResolvedValueOnce([]); - await listExpenses({ status: "PENDING", periodicExpenseId: 1, search: "Agua" }); + await listExpenses({ + status: "PENDING", + periodicExpenseId: 1, + search: "Agua", + }); expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({ where: { status: "PENDING", @@ -406,13 +414,15 @@ describe("payExpense", () => { }); 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, - })); + 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); @@ -424,7 +434,13 @@ 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] }, + { + id: 1, + description: "Gas", + defaultDueDay: 15, + defaultAmount: 2000, + periods: [month], + }, ]); mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 }); @@ -471,11 +487,23 @@ describe("generateExpensesForCurrentMonth", () => { 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); + 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", + id: 1, + description: "Gas", + periodicExpenseId: 1, + amount: 2000, + status: "PENDING", }); const result = await generateMonthlyExpense(1); @@ -485,7 +513,11 @@ describe("generateMonthlyExpense", () => { 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], + id: 1, + description: "Gas", + defaultDueDay: 15, + defaultAmount: 2000, + periods: [6], }); mockPrisma.expense.findFirst.mockResolvedValueOnce(existing); diff --git a/apps/frontend/index.html b/apps/frontend/index.html index e041685..fa6e161 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -5,7 +5,7 @@ Personal Admin