chore: add Biome for lint and fix all lint errors
- Install @biomejs/biome as devDependency at root - Configure biome.json with 2-space indent, double quotes, Tailwind CSS support - Add lint/lint:fix/format/format:fix scripts to root and app package.json - Fix noNonNullAssertion: env vars extracted to variables with suppression, <div role=button> replaced with <button> - Fix noUnusedVariables: remove unused destructured vars - Fix useIterableCallbackReturn: arrow functions with block body - Fix noExplicitAny: recharts Tooltip formatters - Fix noLabelWithoutControl: add htmlFor+id or use <span> for non-input labels - Fix noStaticElementInteractions/useKeyWithClickEvents: role+keyboard events for overlays - Fix noArrayIndexKey: use error string as key - Fix CSS parse: enable tailwindDirectives parser - Normalize formatting across 80 files with biome check --write
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -10,6 +10,6 @@ export default defineConfig({
|
||||
seed: "bun ./prisma/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof createPeriodicExpenseSchema>) {
|
||||
export async function createPeriodicExpense(
|
||||
data: z.infer<typeof createPeriodicExpenseSchema>,
|
||||
) {
|
||||
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<typeof updatePeriodicExpenseSchema>) {
|
||||
export async function updatePeriodicExpense(
|
||||
id: number,
|
||||
data: z.infer<typeof updatePeriodicExpenseSchema>,
|
||||
) {
|
||||
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<typeof createNonPeriodicExpenseSchema>) {
|
||||
export async function createNonPeriodicExpense(
|
||||
data: z.infer<typeof createNonPeriodicExpenseSchema>,
|
||||
) {
|
||||
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<typeof createNonPer
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) {
|
||||
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date();
|
||||
export async function payExpense(
|
||||
id: number,
|
||||
data: z.infer<typeof payExpenseSchema>,
|
||||
) {
|
||||
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<Array<{ payed: string | null; pending: string | null }>>`
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -45,7 +45,10 @@ export async function processBeloQuote(): Promise<void> {
|
||||
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<void> {
|
||||
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({
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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({
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Array<{
|
||||
date: string;
|
||||
minBuy: number;
|
||||
maxBuy: number;
|
||||
minSell: number;
|
||||
maxSell: number;
|
||||
}>>`
|
||||
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",
|
||||
|
||||
@@ -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<Array<{
|
||||
date: string;
|
||||
buy: number;
|
||||
sell: number;
|
||||
}>>`
|
||||
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"
|
||||
|
||||
@@ -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<Array<{
|
||||
minBuy: number;
|
||||
maxBuy: number;
|
||||
minBuyDate: string;
|
||||
maxBuyDate: string;
|
||||
}>>`
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, ReturnType<typeof mock>>)) {
|
||||
for (const fn of Object.values(
|
||||
model as Record<string, ReturnType<typeof mock>>,
|
||||
)) {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user