feat(expenses): add import functionality for expenses and total pending calculation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user