591 lines
17 KiB
TypeScript
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;
|
|
}
|