Files
personal-admin-2026/apps/backend/src/modules/expenses/expenses.service.ts

591 lines
17 KiB
TypeScript

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<number | null> {
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<typeof createPeriodicExpenseSchema>,
) {
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<typeof updatePeriodicExpenseSchema>,
) {
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<string, unknown> = {};
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<typeof createNonPeriodicExpenseSchema>,
) {
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<typeof payExpenseSchema>,
) {
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<typeof updateExpenseSchema>,
) {
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<Array<{ total: string | null }>>`
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<Array<{ total: string | null }>>`
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;
}