diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c3ec9d5..0fae043 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import { env } from './config/env.js'; 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'; const app = express(); @@ -27,6 +28,7 @@ app.get('/health', (req, res) => { // API Routes app.use('/api/auth', authRoutes); app.use('/api/dashboard', dashboardRoutes); +app.use('/api/cfdi', cfdiRoutes); // Error handling app.use(errorMiddleware); diff --git a/apps/api/src/controllers/cfdi.controller.ts b/apps/api/src/controllers/cfdi.controller.ts new file mode 100644 index 0000000..a44aede --- /dev/null +++ b/apps/api/src/controllers/cfdi.controller.ts @@ -0,0 +1,62 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as cfdiService from '../services/cfdi.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import type { CfdiFilters } from '@horux/shared'; + +export async function getCfdis(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const filters: CfdiFilters = { + tipo: req.query.tipo as any, + estado: req.query.estado as any, + fechaInicio: req.query.fechaInicio as string, + fechaFin: req.query.fechaFin as string, + rfc: req.query.rfc as string, + search: req.query.search as string, + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + }; + + const result = await cfdiService.getCfdis(req.tenantSchema, filters); + res.json(result); + } catch (error) { + next(error); + } +} + +export async function getCfdiById(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const cfdi = await cfdiService.getCfdiById(req.tenantSchema, req.params.id); + + if (!cfdi) { + return next(new AppError(404, 'CFDI no encontrado')); + } + + res.json(cfdi); + } catch (error) { + next(error); + } +} + +export async function getResumen(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 cfdiService.getResumenCfdis(req.tenantSchema, año, mes); + res.json(resumen); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/routes/cfdi.routes.ts b/apps/api/src/routes/cfdi.routes.ts new file mode 100644 index 0000000..8a52750 --- /dev/null +++ b/apps/api/src/routes/cfdi.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as cfdiController from '../controllers/cfdi.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', cfdiController.getCfdis); +router.get('/resumen', cfdiController.getResumen); +router.get('/:id', cfdiController.getCfdiById); + +export { router as cfdiRoutes }; diff --git a/apps/api/src/services/cfdi.service.ts b/apps/api/src/services/cfdi.service.ts new file mode 100644 index 0000000..9553b7f --- /dev/null +++ b/apps/api/src/services/cfdi.service.ts @@ -0,0 +1,128 @@ +import { prisma } from '../config/database.js'; +import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared'; + +export async function getCfdis(schema: string, filters: CfdiFilters): Promise { + const page = filters.page || 1; + const limit = filters.limit || 20; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + if (filters.tipo) { + whereClause += ` AND tipo = $${paramIndex++}`; + params.push(filters.tipo); + } + + if (filters.estado) { + whereClause += ` AND estado = $${paramIndex++}`; + params.push(filters.estado); + } + + if (filters.fechaInicio) { + whereClause += ` AND fecha_emision >= $${paramIndex++}`; + params.push(filters.fechaInicio); + } + + if (filters.fechaFin) { + whereClause += ` AND fecha_emision <= $${paramIndex++}`; + params.push(filters.fechaFin); + } + + if (filters.rfc) { + whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`; + params.push(`%${filters.rfc}%`); + } + + if (filters.search) { + whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`; + params.push(`%${filters.search}%`); + } + + const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(` + SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause} + `, ...params); + + const total = Number(countResult[0]?.count || 0); + + params.push(limit, offset); + const data = await prisma.$queryRawUnsafe(` + SELECT + id, uuid_fiscal as "uuidFiscal", tipo, serie, folio, + fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + subtotal, descuento, iva, isr_retenido as "isrRetenido", + iva_retenido as "ivaRetenido", total, moneda, + tipo_cambio as "tipoCambio", metodo_pago as "metodoPago", + forma_pago as "formaPago", uso_cfdi as "usoCfdi", + estado, xml_url as "xmlUrl", pdf_url as "pdfUrl", + created_at as "createdAt" + FROM "${schema}".cfdis + ${whereClause} + ORDER BY fecha_emision DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex} + `, ...params); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; +} + +export async function getCfdiById(schema: string, id: string): Promise { + const result = await prisma.$queryRawUnsafe(` + SELECT + id, uuid_fiscal as "uuidFiscal", tipo, serie, folio, + fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + subtotal, descuento, iva, isr_retenido as "isrRetenido", + iva_retenido as "ivaRetenido", total, moneda, + tipo_cambio as "tipoCambio", metodo_pago as "metodoPago", + forma_pago as "formaPago", uso_cfdi as "usoCfdi", + estado, xml_url as "xmlUrl", pdf_url as "pdfUrl", + created_at as "createdAt" + FROM "${schema}".cfdis + WHERE id = $1 + `, id); + + return result[0] || null; +} + +export async function getResumenCfdis(schema: string, año: number, mes: number) { + const result = await prisma.$queryRawUnsafe<[{ + total_ingresos: number; + total_egresos: number; + count_ingresos: number; + count_egresos: number; + iva_trasladado: number; + iva_acreditable: number; + }]>(` + SELECT + COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos, + COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos, + COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as count_ingresos, + COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as count_egresos, + COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado, + COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable + FROM "${schema}".cfdis + WHERE estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) = $2 + `, año, mes); + + const r = result[0]; + return { + totalIngresos: Number(r?.total_ingresos || 0), + totalEgresos: Number(r?.total_egresos || 0), + countIngresos: Number(r?.count_ingresos || 0), + countEgresos: Number(r?.count_egresos || 0), + ivaTrasladado: Number(r?.iva_trasladado || 0), + ivaAcreditable: Number(r?.iva_acreditable || 0), + }; +}