feat(expenses): add import functionality for expenses and total pending calculation
This commit is contained in:
121
apps/backend/src/modules/expenses/handlers/importExpenses.ts
Normal file
121
apps/backend/src/modules/expenses/handlers/importExpenses.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user