diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0fae043..893da43 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,6 +6,7 @@ import { errorMiddleware } from './middlewares/error.middleware.js'; import { authRoutes } from './routes/auth.routes.js'; import { dashboardRoutes } from './routes/dashboard.routes.js'; import { cfdiRoutes } from './routes/cfdi.routes.js'; +import { impuestosRoutes } from './routes/impuestos.routes.js'; const app = express(); @@ -29,6 +30,7 @@ app.get('/health', (req, res) => { app.use('/api/auth', authRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/cfdi', cfdiRoutes); +app.use('/api/impuestos', impuestosRoutes); // Error handling app.use(errorMiddleware); diff --git a/apps/api/src/controllers/impuestos.controller.ts b/apps/api/src/controllers/impuestos.controller.ts new file mode 100644 index 0000000..7ebfe7e --- /dev/null +++ b/apps/api/src/controllers/impuestos.controller.ts @@ -0,0 +1,63 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as impuestosService from '../services/impuestos.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +export async function getIvaMensual(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const data = await impuestosService.getIvaMensual(req.tenantSchema, año); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getResumenIva(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1; + + const resumen = await impuestosService.getResumenIva(req.tenantSchema, año, mes); + res.json(resumen); + } catch (error) { + next(error); + } +} + +export async function getIsrMensual(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const data = await impuestosService.getIsrMensual(req.tenantSchema, año); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getResumenIsr(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1; + + const resumen = await impuestosService.getResumenIsr(req.tenantSchema, año, mes); + res.json(resumen); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/routes/impuestos.routes.ts b/apps/api/src/routes/impuestos.routes.ts new file mode 100644 index 0000000..caa1cb1 --- /dev/null +++ b/apps/api/src/routes/impuestos.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as impuestosController from '../controllers/impuestos.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/iva/mensual', impuestosController.getIvaMensual); +router.get('/iva/resumen', impuestosController.getResumenIva); +router.get('/isr/mensual', impuestosController.getIsrMensual); +router.get('/isr/resumen', impuestosController.getResumenIsr); + +export { router as impuestosRoutes }; diff --git a/apps/api/src/services/impuestos.service.ts b/apps/api/src/services/impuestos.service.ts new file mode 100644 index 0000000..32b6654 --- /dev/null +++ b/apps/api/src/services/impuestos.service.ts @@ -0,0 +1,158 @@ +import { prisma } from '../config/database.js'; +import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared'; + +export async function getIvaMensual(schema: string, año: number): Promise { + const data = await prisma.$queryRawUnsafe(` + SELECT + id, año, mes, + iva_trasladado as "ivaTrasladado", + iva_acreditable as "ivaAcreditable", + COALESCE(iva_retenido, 0) as "ivaRetenido", + resultado, acumulado, estado, + fecha_declaracion as "fechaDeclaracion" + FROM "${schema}".iva_mensual + WHERE año = $1 + ORDER BY mes + `, año); + + return data.map(row => ({ + ...row, + ivaTrasladado: Number(row.ivaTrasladado), + ivaAcreditable: Number(row.ivaAcreditable), + ivaRetenido: Number(row.ivaRetenido), + resultado: Number(row.resultado), + acumulado: Number(row.acumulado), + })); +} + +export async function getResumenIva(schema: string, año: number, mes: number): Promise { + // Get from iva_mensual if exists + const existing = await prisma.$queryRawUnsafe(` + SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2 + `, año, mes); + + if (existing && existing.length > 0) { + const record = existing[0]; + const [acumuladoResult] = await prisma.$queryRawUnsafe<[{ total: number }]>(` + SELECT COALESCE(SUM(resultado), 0) as total + FROM "${schema}".iva_mensual + WHERE año = $1 AND mes <= $2 + `, año, mes); + + return { + trasladado: Number(record.iva_trasladado || 0), + acreditable: Number(record.iva_acreditable || 0), + retenido: Number(record.iva_retenido || 0), + resultado: Number(record.resultado || 0), + acumuladoAnual: Number(acumuladoResult?.total || 0), + }; + } + + // Calculate from CFDIs if no iva_mensual record + const [calcResult] = await prisma.$queryRawUnsafe<[{ + trasladado: number; + acreditable: number; + retenido: number; + }]>(` + SELECT + COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado, + COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable, + COALESCE(SUM(iva_retenido), 0) as retenido + FROM "${schema}".cfdis + WHERE estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) = $2 + `, año, mes); + + const trasladado = Number(calcResult?.trasladado || 0); + const acreditable = Number(calcResult?.acreditable || 0); + const retenido = Number(calcResult?.retenido || 0); + const resultado = trasladado - acreditable - retenido; + + return { + trasladado, + acreditable, + retenido, + resultado, + acumuladoAnual: resultado, + }; +} + +export async function getIsrMensual(schema: string, año: number): Promise { + // Check if isr_mensual table exists + try { + const data = await prisma.$queryRawUnsafe(` + SELECT + id, año, mes, + ingresos_acumulados as "ingresosAcumulados", + deducciones, + base_gravable as "baseGravable", + isr_causado as "isrCausado", + isr_retenido as "isrRetenido", + isr_a_pagar as "isrAPagar", + estado, + fecha_declaracion as "fechaDeclaracion" + FROM "${schema}".isr_mensual + WHERE año = $1 + ORDER BY mes + `, año); + + return data.map(row => ({ + ...row, + ingresosAcumulados: Number(row.ingresosAcumulados), + deducciones: Number(row.deducciones), + baseGravable: Number(row.baseGravable), + isrCausado: Number(row.isrCausado), + isrRetenido: Number(row.isrRetenido), + isrAPagar: Number(row.isrAPagar), + })); + } catch { + // Table doesn't exist, return empty array + return []; + } +} + +export async function getResumenIsr(schema: string, año: number, mes: number): Promise { + // Calculate from CFDIs + const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(` + SELECT COALESCE(SUM(total), 0) as total + FROM "${schema}".cfdis + WHERE tipo = 'ingreso' AND estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) <= $2 + `, año, mes); + + const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(` + SELECT COALESCE(SUM(total), 0) as total + FROM "${schema}".cfdis + WHERE tipo = 'egreso' AND estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) <= $2 + `, año, mes); + + const [retenido] = await prisma.$queryRawUnsafe<[{ total: number }]>(` + SELECT COALESCE(SUM(isr_retenido), 0) as total + FROM "${schema}".cfdis + WHERE estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) <= $2 + `, año, mes); + + const ingresosAcumulados = Number(ingresos?.total || 0); + const deducciones = Number(egresos?.total || 0); + const baseGravable = Math.max(0, ingresosAcumulados - deducciones); + + // Simplified ISR calculation (actual calculation would use SAT tables) + const isrCausado = baseGravable * 0.30; // 30% simplified rate + const isrRetenido = Number(retenido?.total || 0); + const isrAPagar = Math.max(0, isrCausado - isrRetenido); + + return { + ingresosAcumulados, + deducciones, + baseGravable, + isrCausado, + isrRetenido, + isrAPagar, + }; +}