feat(expenses): add import functionality for expenses and total pending calculation

This commit is contained in:
Jose Selesan
2026-05-29 14:30:10 -03:00
parent 4b17940134
commit b1968cece9
11 changed files with 600 additions and 19 deletions

View File

@@ -9,6 +9,9 @@ import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExp
import { payExpenseHandler } from "./handlers/payExpense";
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
import { getTotalPendingHandler } from "./handlers/getTotalPending";
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
import { importExpensesHandler } from "./handlers/importExpenses";
const app = new Hono();
@@ -16,12 +19,15 @@ app.get("/periodic-expenses", listPeriodicExpensesHandler);
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.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
app.get("/expenses/totals", getMonthlyTotalsHandler);
app.get("/expenses/pending-total", getTotalPendingHandler);
app.get("/expenses", listExpensesHandler);
app.post("/expenses", createNonPeriodicExpenseHandler);
app.post("/expenses/import", importExpensesHandler);
app.put("/expenses/:id/pay", payExpenseHandler);
export default app;

View File

@@ -39,7 +39,7 @@ export function getCurrentYearMonthUTC(): { year: number; month: number } {
export async function listPeriodicExpenses() {
return prisma.periodicExpense.findMany({
where: { isDeleted: false },
orderBy: { createdAt: "desc" },
orderBy: { description: "asc" },
});
}
@@ -163,6 +163,15 @@ export async function getMonthlyTotals(year: number, month: number) {
};
}
export async function getTotalPending() {
const expenses = await prisma.expense.findMany({
where: { status: PaymentStatus.PENDING },
select: { amount: true },
});
const total = expenses.reduce((sum, e) => sum + Number(e.amount), 0);
return { total };
}
export async function generateExpensesForCurrentMonth() {
const { year, month } = getCurrentYearMonthUTC();

View File

@@ -0,0 +1,7 @@
import type { Context } from "hono";
import { getTotalPending } from "../expenses.service";
export async function getTotalPendingHandler(c: Context) {
const result = await getTotalPending();
return c.json(result);
}

View File

@@ -0,0 +1,121 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { logger } from "../../../lib/logger";
import { PaymentStatus } from "../../../generated/prisma/client";
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;
}
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;
}
export async function importExpensesHandler(c: Context) {
const body = await c.req.parseBody();
const file = body["file"];
if (!file || !(file instanceof File)) {
return c.json({ error: "No se envió ningún archivo" }, 400);
}
const text = await file.text();
const lines = text.trim().split("\n");
if (lines.length < 2) {
return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400);
}
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const parts = line.split("|");
if (parts.length < 10) {
errors.push(`Línea ${i + 1}: formato inválido (${parts.length} columnas)`);
continue;
}
const description = parts[9].trim();
const year = Number(parts[2].trim());
const month = Number(parts[3].trim());
const rawAmount = parts[4].trim();
const rawAmountPayed = parts[5].trim();
const rawStatus = parts[6].trim();
const rawDueDate = parts[7].trim();
const rawPaymentDate = parts[8].trim();
try {
const amount = parseAmount(rawAmount);
if (amount === null) {
errors.push(`Línea ${i + 1} ("${description}"): monto inválido`);
continue;
}
const amountPayed = parseAmount(rawAmountPayed);
const dueDate = parseDate(rawDueDate);
const paymentDate = parseDate(rawPaymentDate);
if (!dueDate) {
errors.push(`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`);
continue;
}
const existing = await prisma.expense.findFirst({
where: { description, year, month },
});
if (existing) {
skipped++;
continue;
}
let periodicExpenseId: number | null = null;
if (parts[1].trim()) {
const periodic = await prisma.periodicExpense.findFirst({
where: { description: { equals: description, mode: "insensitive" } },
});
if (periodic) {
periodicExpenseId = periodic.id;
}
}
const status = rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING;
await prisma.expense.create({
data: {
description,
year,
month,
amount,
amountPayed,
status,
dueDate,
paymentDate,
periodicExpenseId,
},
});
imported++;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`Línea ${i + 1} ("${description}"): ${message}`);
logger.error({ err, description }, "Error importing expense");
}
}
return c.json({
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
});
}

View File

@@ -0,0 +1,94 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { logger } from "../../../lib/logger";
function parseArgentineAmount(raw: string): number {
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
return Number(cleaned);
}
function parsePeriods(raw: string): number[] {
const inner = raw.replace(/^\{|\}$/g, "").trim();
if (!inner) return [];
return inner.split(",").map(Number);
}
export async function importPeriodicExpensesHandler(c: Context) {
const body = await c.req.parseBody();
const file = body["file"];
if (!file || !(file instanceof File)) {
return c.json({ error: "No se envió ningún archivo" }, 400);
}
const text = await file.text();
const lines = text.trim().split("\n");
if (lines.length < 2) {
return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400);
}
let imported = 0;
let skippedNoPeriods = 0;
let skippedDuplicate = 0;
const errors: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const parts = line.split("|");
if (parts.length < 5) {
errors.push(`Línea ${i + 1}: formato inválido`);
continue;
}
const description = parts[1].trim();
const defaultDueDay = Number(parts[2].trim());
const rawAmount = parts[3].trim();
const rawPeriods = parts[4].trim();
const periods = parsePeriods(rawPeriods);
if (periods.length === 0) {
skippedNoPeriods++;
continue;
}
try {
const existing = await prisma.periodicExpense.findFirst({
where: { description: { equals: description, mode: "insensitive" } },
});
if (existing) {
skippedDuplicate++;
continue;
}
const defaultAmount = parseArgentineAmount(rawAmount);
await prisma.periodicExpense.create({
data: {
description,
defaultDueDay: isNaN(defaultDueDay) ? 1 : defaultDueDay,
defaultAmount: isNaN(defaultAmount) ? 0 : defaultAmount,
periods,
},
});
imported++;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`Línea ${i + 1} ("${description}"): ${message}`);
logger.error({ err, description }, "Error importing periodic expense");
}
}
return c.json({
imported,
skipped: skippedNoPeriods + skippedDuplicate,
skippedNoPeriods,
skippedDuplicate,
errors: errors.length > 0 ? errors : undefined,
});
}