import { z } from "zod"; import { PaymentStatus, QuoteType, type Prisma } from "../../generated/prisma/client"; import { cache } from "../../lib/cache"; import { prisma } from "../../lib/prisma"; import { roundTo } from "../../lib/utils"; 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(), walletId: z.number().int().positive(), usdcConversionRate: z.number().positive().optional(), }); export const payExpenseSchema = z.object({ amountPayed: z.number().positive(), paymentDate: z.string().datetime().optional(), walletId: z.number().int().positive(), usdcConversionRate: z.number().positive().optional(), }); export const updateExpenseSchema = z.object({ amount: z.number().positive("El monto debe ser mayor a 0"), dueDate: z .string() .datetime({ message: "La fecha debe ser una fecha ISO válida" }), status: z.enum(["PENDING"]).optional(), amountPayed: z .number() .positive("El monto pagado debe ser mayor a 0") .optional(), paymentDate: z .string() .datetime({ message: "La fecha debe ser una fecha ISO válida" }) .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 }; } function isSameDay(a: Date, b: Date): boolean { return ( a.getUTCFullYear() === b.getUTCFullYear() && a.getUTCMonth() === b.getUTCMonth() && a.getUTCDate() === b.getUTCDate() ); } export async function getBeloSellPriceForDate( paymentDate: Date, ): Promise { const now = new Date(); if (isSameDay(paymentDate, now)) { const quote = await prisma.quote.findFirst({ where: { type: QuoteType.BELO }, }); return quote ? Number(quote.sell) : null; } const target = new Date( Date.UTC( paymentDate.getUTCFullYear(), paymentDate.getUTCMonth(), paymentDate.getUTCDate(), 17, 0, 0, 0, ), ); const before = await prisma.quoteHistory.findFirst({ where: { type: QuoteType.BELO, timeStamp: { lte: target }, }, orderBy: { timeStamp: "desc" }, }); const after = await prisma.quoteHistory.findFirst({ where: { type: QuoteType.BELO, timeStamp: { gte: target }, }, orderBy: { timeStamp: "asc" }, }); if (before && after) { const beforeDiff = Math.abs(before.timeStamp.getTime() - target.getTime()); const afterDiff = Math.abs(after.timeStamp.getTime() - target.getTime()); return beforeDiff <= afterDiff ? Number(before.sell) : Number(after.sell); } if (before) return Number(before.sell); if (after) return Number(after.sell); return null; } export async function listPeriodicExpenses() { const cached = cache.get("expenses:periodic"); if (cached) return cached; const data = await prisma.periodicExpense.findMany({ where: { isDeleted: false }, orderBy: { description: "asc" }, }); cache.set("expenses:periodic", data); return data; } export async function createPeriodicExpense( data: z.infer, ) { const result = await prisma.periodicExpense.create({ data }); const { month } = getCurrentYearMonthUTC(); const currentMonthApplicable = data.periods.includes(month); cache.invalidateByPrefix("expenses:"); return { ...result, currentMonthApplicable }; } export async function updatePeriodicExpense( id: number, data: z.infer, ) { const result = await prisma.periodicExpense.update({ where: { id }, data }); cache.invalidateByPrefix("expenses:"); return result; } export async function softDeletePeriodicExpense(id: number) { const result = await prisma.periodicExpense.update({ where: { id }, data: { isDeleted: true }, }); cache.invalidateByPrefix("expenses:"); return result; } 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); const result = await prisma.expense.create({ data: { description: periodic.description, periodicExpenseId: id, year, month, amount: periodic.defaultAmount, dueDate, status: PaymentStatus.PENDING, }, }); cache.invalidateByPrefix("expenses:"); return result; } export async function listExpenses(params: { status?: string; page?: number; pageSize?: number; periodicExpenseId?: number; search?: string; }) { const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params; const cacheKey = `expenses:list:${status ?? "all"}:${page}:${pageSize}:${periodicExpenseId ?? "none"}:${search ?? ""}`; const cached = cache.get(cacheKey); if (cached) return cached; const where: Record = {}; if (status === "PENDING" || status === "PAYED") { where.status = status; } if (periodicExpenseId !== undefined) { where.periodicExpenseId = periodicExpenseId; } if (search) { where.description = { contains: search, mode: "insensitive" }; } const [data, total] = await Promise.all([ prisma.expense.findMany({ where, include: { periodicExpense: true }, orderBy: { dueDate: "desc" }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.expense.count({ where }), ]); const result = { data, total, page, pageSize }; cache.set(cacheKey, result); return result; } export async function createNonPeriodicExpense( data: z.infer, ) { const dueDate = new Date(data.dueDate); const year = dueDate.getUTCFullYear(); const month = dueDate.getUTCMonth() + 1; const wallet = await prisma.wallet.findUnique({ where: { id: data.walletId }, }); if (!wallet) { throw new Error("Billetera no encontrada"); } const isUSDC = wallet.currency === "USDC"; if (isUSDC && !data.usdcConversionRate) { throw new Error("La tasa de conversión es obligatoria para billeteras USDC"); } const belo = isUSDC ? null : await getBeloSellPriceForDate(dueDate); const movementAmount = isUSDC && data.usdcConversionRate ? -roundTo(data.amount / data.usdcConversionRate, 4) : -data.amount; const expenseData: Prisma.ExpenseUncheckedCreateInput = { description: data.description, amount: data.amount, amountPayed: data.amount, dueDate, paymentDate: dueDate, year, month, status: PaymentStatus.PAYED, walletId: data.walletId, }; if (isUSDC && data.usdcConversionRate) { expenseData.beloPrice = data.usdcConversionRate; expenseData.usdcEquivalent = roundTo( data.amount / data.usdcConversionRate, 6, ); } else if (belo !== null) { expenseData.beloPrice = belo; expenseData.usdcEquivalent = roundTo(data.amount / belo, 6); } const [result] = await prisma.$transaction([ prisma.expense.create({ data: expenseData }), prisma.wallet.update({ where: { id: data.walletId }, data: { balance: { increment: movementAmount } }, }), prisma.walletMovement.create({ data: { walletId: data.walletId, type: "WITHDRAWAL", amount: movementAmount, description: `Pago: ${data.description}`, }, }), ]); cache.invalidateByPrefix("expenses:"); return result; } export async function payExpense( id: number, data: z.infer, ) { const expense = await prisma.expense.findUniqueOrThrow({ where: { id } }); const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date(); const wallet = await prisma.wallet.findUnique({ where: { id: data.walletId }, }); if (!wallet) { throw new Error("Billetera no encontrada"); } const isUSDC = wallet.currency === "USDC"; if (isUSDC && !data.usdcConversionRate) { throw new Error("La tasa de conversión es obligatoria para billeteras USDC"); } const belo = isUSDC ? null : await getBeloSellPriceForDate(paymentDate); const movementAmount = isUSDC && data.usdcConversionRate ? -roundTo(data.amountPayed / data.usdcConversionRate, 4) : -data.amountPayed; const expenseUpdateData: Prisma.ExpenseUncheckedUpdateInput = { status: PaymentStatus.PAYED, amountPayed: data.amountPayed, paymentDate, walletId: data.walletId, }; if (isUSDC && data.usdcConversionRate) { expenseUpdateData.beloPrice = data.usdcConversionRate; expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / data.usdcConversionRate, 6); } else if (belo !== null) { expenseUpdateData.beloPrice = belo; expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / belo, 6); } const [result] = await prisma.$transaction([ prisma.expense.update({ where: { id }, data: expenseUpdateData }), prisma.wallet.update({ where: { id: data.walletId }, data: { balance: { increment: movementAmount } }, }), prisma.walletMovement.create({ data: { walletId: data.walletId, type: "WITHDRAWAL", amount: movementAmount, description: `Pago: ${expense.description}`, }, }), ]); cache.invalidateByPrefix("expenses:"); return result; } export async function updateExpense( id: number, data: z.infer, ) { const existing = await prisma.expense.findUniqueOrThrow({ where: { id } }); const dueDate = new Date(data.dueDate); const year = dueDate.getUTCFullYear(); const month = dueDate.getUTCMonth() + 1; const updateData: Prisma.ExpenseUncheckedUpdateInput = { amount: data.amount, dueDate, year, month, }; let walletAdjustment: { walletId: number; amount: number; description: string; } | null = null; if (data.status === "PENDING") { updateData.status = PaymentStatus.PENDING; updateData.amountPayed = null; updateData.paymentDate = null; updateData.beloPrice = null; updateData.usdcEquivalent = null; if (existing.status === PaymentStatus.PAYED && existing.walletId) { const refundAmount = existing.amountPayed ? Number(existing.amountPayed) : Number(existing.amount); walletAdjustment = { walletId: existing.walletId, amount: refundAmount, description: "Anulación de gasto", }; } } else if (existing.status === PaymentStatus.PAYED) { if (data.amountPayed !== undefined) { updateData.amountPayed = data.amountPayed; } if (data.paymentDate !== undefined) { updateData.paymentDate = new Date(data.paymentDate); } const resolvedPaymentDate = updateData.paymentDate instanceof Date ? updateData.paymentDate : (existing.paymentDate ?? undefined); const amountPayed = updateData.amountPayed ?? existing.amountPayed; if (resolvedPaymentDate && amountPayed !== undefined && amountPayed !== null) { const belo = await getBeloSellPriceForDate(resolvedPaymentDate); if (belo !== null) { updateData.beloPrice = belo; updateData.usdcEquivalent = roundTo(Number(amountPayed) / belo, 6); } else { updateData.beloPrice = null; updateData.usdcEquivalent = null; } } if (existing.walletId) { const oldPayed = existing.amountPayed ? Number(existing.amountPayed) : Number(existing.amount); const newPayed = data.amountPayed !== undefined ? Number(data.amountPayed) : Number(data.amount); if (newPayed !== oldPayed) { const difference = newPayed - oldPayed; walletAdjustment = { walletId: existing.walletId, amount: -difference, description: "Ajuste por edición de gasto", }; } } } if (walletAdjustment) { const wallet = await prisma.wallet.findUnique({ where: { id: walletAdjustment.walletId }, }); if (!wallet) { throw new Error("Billetera no encontrada"); } const [result] = await prisma.$transaction([ prisma.expense.update({ where: { id }, data: updateData }), prisma.wallet.update({ where: { id: walletAdjustment.walletId }, data: { balance: { increment: walletAdjustment.amount } }, }), prisma.walletMovement.create({ data: { walletId: walletAdjustment.walletId, type: walletAdjustment.amount > 0 ? "DEPOSIT" : "WITHDRAWAL", amount: walletAdjustment.amount, description: walletAdjustment.description, }, }), ]); cache.invalidateByPrefix("expenses:"); return result; } const result = await prisma.expense.update({ where: { id }, data: updateData, }); cache.invalidateByPrefix("expenses:"); return result; } export async function getMonthlyPayedTotal(year: number, month: number) { const cacheKey = `expenses:monthly-total:${year}:${month}`; const cached = cache.get(cacheKey); if (cached) return cached; const result = await prisma.$queryRaw>` SELECT CAST(SUM("amountPayed") AS NUMERIC) as total FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus" `; const data = { total: Number(result[0]?.total ?? 0) }; cache.set(cacheKey, data); return data; } export async function getMonthlyTotals(year: number, month: number) { const cacheKey = `expenses:totals:${year}:${month}`; const cached = cache.get(cacheKey); if (cached) return cached; 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 `; const data = { payed: Number(result[0]?.payed ?? 0), pending: Number(result[0]?.pending ?? 0), }; cache.set(cacheKey, data); return data; } export async function getTotalPending() { const cached = cache.get("expenses:pending-total"); if (cached) return cached; const result = await prisma.$queryRaw>` SELECT CAST(SUM(amount) AS NUMERIC) as total FROM expenses WHERE status = ${PaymentStatus.PENDING}::"PaymentStatus" `; const data = { total: Number(result[0]?.total ?? 0) }; cache.set("expenses:pending-total", data); return data; } 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 fiveDaysLater = new Date(today); fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5); const data = await prisma.expense.findMany({ where: { status: PaymentStatus.PENDING, dueDate: { lte: fiveDaysLater }, }, include: { periodicExpense: true }, orderBy: { dueDate: "asc" }, }); return data.map((expense) => ({ ...expense, dueType: expense.dueDate < today ? ("overdue" as const) : ("upcoming" as const), })); } 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" }); } cache.invalidateByPrefix("expenses:"); return results; }