diff --git a/apps/api/.env.save b/apps/api/.env.save new file mode 100644 index 0000000..a5c6f03 --- /dev/null +++ b/apps/api/.env.save @@ -0,0 +1,34 @@ +NODE_ENV=development +PORT=4000 +DATABASE_URL="postgresql://postgres:ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb@localhost:5432/horux_despachos?schema=public" +JWT_SECRET=n8tbAhp+H2wBKvGqhzq7qgCVqEztby5FzjWZM4oa9epq0CFTgSyvJN8LFLid5J1O +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d +CORS_ORIGIN=http://localhost:3000 +FRONTEND_URL=https://horuxfin.com +FIEL_ENCRYPTION_KEY=8E46fcpVBbnu0qxEECIE9wrE0nLugDca09YqYBytgY8DTOE0XaJgxrjuS4N6zQ3/ +FIEL_STORAGE_PATH=/var/horux/fiel +MP_ACCESS_TOKEN= +MP_WEBHOOK_SECRET= +MP_NOTIFICATION_URL= +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=soporte@horuxfin.com +SMTP_PASS=fumuflfahizubnok +SMTP_FROM=Horux Despachos +ADMIN_EMAIL=soporte@horuxfin.com +FACTURAPI_USER_KEY=sk_user_8NGmCxP6NT4Wb6QF3NBR9W8kNAvcfDbYXZYzCois5U +FACTURAPI_MODE=live +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_TUNNEL_DOMAIN=tunnel.horux.mx +CONNECTOR_ENCRYPTION_KEY=8E46fcpVBbnu0qxEECIE9wrE0nLugDca09YqYBytgY8DTOE0XaJgxrjuS4N6zQ3/ + +# Metabase integration +METABASE_URL=http://192.168.10.170:3000 +METABASE_USERNAME=ialcarazsalazar@consultoria-as.com +METABASE_PASSWORD=Aasi940812 +METABASE_PG_HOST=192.168.10.90 +METABASE_PG_PORT=5432 +METABASE_PG_USER=postgres +METABASE_PG_PASSWORD=ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb diff --git a/apps/api/src/config/database.ts b/apps/api/src/config/database.ts index 3f6b162..9c398ca 100644 --- a/apps/api/src/config/database.ts +++ b/apps/api/src/config/database.ts @@ -71,7 +71,7 @@ class TenantConnectionManager { user: connectionOverride?.user ?? this.dbConfig.user, password: connectionOverride?.password ?? this.dbConfig.password, database: databaseName, - max: 3, + max: 15, idleTimeoutMillis: 300_000, connectionTimeoutMillis: 10_000, }; diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 46f4f26..e9f6662 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -38,6 +38,7 @@ const envSchema = z.object({ // Facturapi FACTURAPI_USER_KEY: z.string().optional(), + FACTURAPI_MODE: z.enum(['test', 'live']).optional(), // Cloudflare Tunnel (connector BYO-DB) CLOUDFLARE_API_TOKEN: z.string().optional(), diff --git a/apps/api/src/controllers/cfdi.controller.ts b/apps/api/src/controllers/cfdi.controller.ts index e7fb105..7e97b71 100644 --- a/apps/api/src/controllers/cfdi.controller.ts +++ b/apps/api/src/controllers/cfdi.controller.ts @@ -85,6 +85,37 @@ export async function getConceptos(req: Request, res: Response, next: NextFuncti } } +export async function getAllConceptos(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const filters = { + fechaInicio: req.query.fechaInicio as string, + fechaFin: req.query.fechaFin as string, + tipo: req.query.tipo as any, + tipoComprobante: req.query.tipoComprobante as any, + estado: req.query.estado as any, + rfc: req.query.rfc as string, + search: req.query.search as string, + uuid: req.query.uuid as string, + claveProdServ: req.query.claveProdServ as string, + descripcion: req.query.descripcion as string, + orderBy: req.query.orderBy as 'fecha' | 'importe', + orderDir: req.query.orderDir as 'asc' | 'desc', + contribuyenteId: req.query.contribuyenteId as string, + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + }; + + const result = await cfdiService.getAllConceptos(req.tenantPool, filters); + res.json(result); + } catch (error) { + next(error); + } +} + export async function drillDown(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) { @@ -444,3 +475,21 @@ export async function deleteCfdi(req: Request, res: Response, next: NextFunction next(error); } } + +export async function backfillConceptos(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + if (req.user!.role !== 'owner') { + return next(new AppError(403, 'Solo el owner puede ejecutar backfill')); + } + + const batchSize = parseInt(req.query.batchSize as string) || 100; + const result = await cfdiService.backfillConceptos(req.tenantPool, batchSize); + res.json(result); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/facturacion.controller.ts b/apps/api/src/controllers/facturacion.controller.ts index a694379..a3c750d 100644 --- a/apps/api/src/controllers/facturacion.controller.ts +++ b/apps/api/src/controllers/facturacion.controller.ts @@ -101,6 +101,37 @@ export async function emitir(req: Request, res: Response, next: NextFunction) { const tenantId = effectiveTenantId(req); const contribuyenteId = req.body.contribuyenteId as string | undefined; + // ── Validar CFDIs relacionados ── + // Cada UUID relacionado debe existir en la BD del tenant, estar vigente, + // y su rfc_receptor debe coincidir con el RFC del receptor de la factura + // que se está emitiendo (customer.taxId). + const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || []; + const customerRfc = req.body.customer?.taxId?.toUpperCase()?.trim(); + if (relatedDocs.length > 0 && customerRfc) { + const allUuids = relatedDocs.flatMap(r => r.uuids).filter(u => typeof u === 'string' && u.trim() !== ''); + for (const uuid of allUuids) { + const { rows } = await req.tenantPool!.query( + `SELECT rfc_receptor, status FROM cfdis WHERE LOWER(uuid) = LOWER($1) LIMIT 1`, + [uuid.trim()], + ); + if (rows.length === 0) { + throw new AppError(400, `El CFDI relacionado con UUID ${uuid} no existe en el sistema.`); + } + const relacionado = rows[0]; + if (relacionado.status === 'Cancelado' || relacionado.status === '0') { + throw new AppError(400, `El CFDI relacionado con UUID ${uuid} está cancelado.`); + } + const rfcReceptorRelacionado = (relacionado.rfc_receptor || '').toUpperCase().trim(); + if (rfcReceptorRelacionado !== customerRfc) { + throw new AppError( + 400, + `El CFDI relacionado con UUID ${uuid} no corresponde al RFC del receptor de esta factura. ` + + `RFC esperado: ${customerRfc}. RFC del receptor del CFDI relacionado: ${rfcReceptorRelacionado}.`, + ); + } + } + } + // Reservar timbre — si falla emisión en Facturapi, revertimos abajo const consumedTimbre = await facturapiService.consumeTimbre(tenantId); @@ -182,8 +213,7 @@ export async function emitir(req: Request, res: Response, next: NextFunction) { [parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null], ); - // Extraer relaciones para persistencia en BD - const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || []; + // Extraer relaciones para persistencia en BD (ya declarado arriba en la validación) const cfdiTipoRelacion = relatedDocs.length > 0 ? relatedDocs[0].relationship : null; const cfdisRelacionados = relatedDocs.length > 0 ? relatedDocs.flatMap(r => r.uuids).join('|') diff --git a/apps/api/src/routes/cfdi.routes.ts b/apps/api/src/routes/cfdi.routes.ts index b8b6689..ffdce3b 100644 --- a/apps/api/src/routes/cfdi.routes.ts +++ b/apps/api/src/routes/cfdi.routes.ts @@ -10,6 +10,10 @@ const router: IRouter = Router(); router.use(authenticate); router.use(tenantMiddleware); + +// DELETE: permitir eliminación local sin suscripción activa (operación de limpieza) +router.delete('/:id', cfdiController.deleteCfdi); + router.use(checkPlanLimits); router.get('/', cfdiController.getCfdis); @@ -17,12 +21,13 @@ router.get('/resumen', cfdiController.getResumen); router.get('/emisores', cfdiController.getEmisores); router.get('/receptores', cfdiController.getReceptores); router.get('/drill-down', cfdiController.drillDown); +router.get('/conceptos', cfdiController.getAllConceptos); +router.post('/backfill-conceptos', cfdiController.backfillConceptos); router.get('/:id', cfdiController.getCfdiById); router.get('/:id/conceptos', cfdiController.getConceptos); router.get('/:id/xml', cfdiController.getXml); router.post('/', checkCfdiLimit, cfdiController.createCfdi); // Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis); -router.delete('/:id', cfdiController.deleteCfdi); export { router as cfdiRoutes }; diff --git a/apps/api/src/services/cfdi.service.ts b/apps/api/src/services/cfdi.service.ts index 53e71ca..7136e8f 100644 --- a/apps/api/src/services/cfdi.service.ts +++ b/apps/api/src/services/cfdi.service.ts @@ -1,7 +1,8 @@ import type { Pool } from 'pg'; -import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared'; +import type { Cfdi, CfdiFilters, CfdiListResponse, CfdiConceptoFilters, CfdiConceptoListResponse } from '@horux/shared'; import { markForInvalidation } from './metricas.service.js'; import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../utils/saldo.js'; +import { parseXml } from './sat/sat-parser.service.js'; // Common SELECT columns mapping DB → camelCase const CFDI_SELECT = ` @@ -208,6 +209,124 @@ export async function getConceptos(pool: Pool, cfdiId: string): Promise { return rows; } +export async function getAllConceptos(pool: Pool, filters: CfdiConceptoFilters): 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.fechaInicio) { + whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`; + params.push(filters.fechaInicio); + } + if (filters.fechaFin) { + whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`; + params.push(filters.fechaFin); + } + if (filters.tipoComprobante) { + whereClause += ` AND c.tipo_comprobante = $${paramIndex++}`; + params.push(filters.tipoComprobante); + } + if (filters.estado) { + whereClause += ` AND c.status = $${paramIndex++}`; + params.push(filters.estado); + } + if (filters.rfc) { + whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex++})`; + params.push(`%${filters.rfc}%`); + } + if (filters.search) { + whereClause += ` AND (cc.descripcion ILIKE $${paramIndex} OR cc.clave_prod_serv ILIKE $${paramIndex} OR c.uuid ILIKE $${paramIndex++})`; + params.push(`%${filters.search}%`); + } + if (filters.uuid) { + whereClause += ` AND c.uuid ILIKE $${paramIndex++}`; + params.push(`%${filters.uuid}%`); + } + if (filters.claveProdServ) { + whereClause += ` AND cc.clave_prod_serv ILIKE $${paramIndex++}`; + params.push(`%${filters.claveProdServ}%`); + } + if (filters.descripcion) { + whereClause += ` AND cc.descripcion ILIKE $${paramIndex++}`; + params.push(`%${filters.descripcion}%`); + } + + // Contribuyente filter (same logic as getCfdis) + if (filters.contribuyenteId) { + const safeId = filters.contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + const { rows: contribRows } = await pool.query( + 'SELECT rfc FROM contribuyentes WHERE entidad_id = $1', + [safeId], + ); + const rfc = (contribRows[0]?.rfc || '').replace(/[^A-Z0-9]/gi, '').toUpperCase(); + + if (filters.tipo === 'EMITIDO' && rfc) { + whereClause += ` AND UPPER(c.rfc_emisor) = '${rfc}'`; + } else if (filters.tipo === 'RECIBIDO' && rfc) { + whereClause += ` AND UPPER(c.rfc_receptor) = '${rfc}'`; + } else if (rfc) { + whereClause += ` AND (c.contribuyente_id = '${safeId}' OR UPPER(c.rfc_emisor) = '${rfc}' OR UPPER(c.rfc_receptor) = '${rfc}')`; + } + } else if (filters.tipo) { + whereClause += ` AND c.type = $${paramIndex++}`; + params.push(filters.tipo); + } + + // Order by + const orderBy = filters.orderBy === 'importe' ? 'cc.importe' : 'c.fecha_emision'; + const orderDir = filters.orderDir === 'asc' ? 'ASC' : 'DESC'; + + params.push(limit, offset); + const { rows: dataWithCount } = await pool.query(` + SELECT + cc.id, cc.cfdi_id as "cfdiId", + cc.clave_prod_serv as "claveProdServ", + cc.no_identificacion as "noIdentificacion", + cc.descripcion, cc.cantidad, + cc.clave_unidad as "claveUnidad", cc.unidad, + cc.valor_unitario as "valorUnitario", + cc.valor_unitario_mxn as "valorUnitarioMxn", + cc.importe, cc.importe_mxn as "importeMxn", + cc.descuento, cc.descuento_mxn as "descuentoMxn", + cc.isr_retencion as "isrRetencion", + cc.isr_retencion_mxn as "isrRetencionMxn", + cc.iva_traslado as "ivaTraslado", + cc.iva_traslado_mxn as "ivaTrasladoMxn", + cc.iva_retencion as "ivaRetencion", + cc.iva_retencion_mxn as "ivaRetencionMxn", + c.uuid as "cfdiUuid", + c.fecha_emision as "cfdiFechaEmision", + c.rfc_emisor as "cfdiRfcEmisor", + c.nombre_emisor as "cfdiNombreEmisor", + c.rfc_receptor as "cfdiRfcReceptor", + c.nombre_receptor as "cfdiNombreReceptor", + c.tipo_comprobante as "cfdiTipoComprobante", + c.status as "cfdiStatus", + c.type as "cfdiTipo", + COUNT(*) OVER() as total_count + FROM cfdi_conceptos cc + JOIN cfdis c ON c.id = cc.cfdi_id + ${whereClause} + ORDER BY ${orderBy} ${orderDir}, cc.id + LIMIT $${paramIndex++} OFFSET $${paramIndex} + `, params); + + const total = Number(dataWithCount[0]?.total_count || 0); + const data = dataWithCount.map(({ total_count, ...row }: any) => row); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; +} + export async function getXmlById(pool: Pool, id: string): Promise { const { rows } = await pool.query(` SELECT xml_original FROM cfdis WHERE id = $1 @@ -553,7 +672,18 @@ export async function deleteCfdi(pool: Pool, id: string): Promise { `SELECT fecha_emision, contribuyente_id FROM cfdis WHERE id = $1`, [id] ); - await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]); + if (pre.length === 0) { + throw new Error('CFDI no encontrado'); + } + + try { + await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]); + } catch (err: any) { + if (err.code === '23503') { + throw new Error('No se puede eliminar este CFDI porque está vinculado a una conciliación u otro registro. Elimina primero las relaciones.'); + } + throw err; + } // Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs try { @@ -632,3 +762,74 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con ivaAcreditable: Number(r?.iva_acreditable || 0), }; } + +export async function backfillConceptos(pool: Pool, batchSize: number = 100): Promise<{ processed: number; conceptosInserted: number; remaining: number }> { + // CFDIs con XML pero sin conceptos + const { rows: cfdis } = await pool.query(` + SELECT c.id, c.xml_original, c.type + FROM cfdis c + LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id + WHERE c.xml_original IS NOT NULL AND cc.id IS NULL + LIMIT $1 + `, [batchSize]); + + let conceptosInserted = 0; + + for (const row of cfdis) { + try { + const parsed = parseXml(row.xml_original, row.type === 'RECIBIDO' ? 'recibidos' : 'emitidos'); + if (!parsed || !parsed.conceptos || parsed.conceptos.length === 0) continue; + + const tc = parsed.tipoCambio || 1; + const m = (v: number) => v * tc; + + for (const c of parsed.conceptos) { + await pool.query(` + INSERT INTO cfdi_conceptos ( + cfdi_id, + clave_prod_serv, no_identificacion, descripcion, cantidad, + clave_unidad, unidad, + valor_unitario, valor_unitario_mxn, importe, importe_mxn, + descuento, descuento_mxn, + isr_retencion, isr_retencion_mxn, + iva_traslado, iva_traslado_mxn, + iva_retencion, iva_retencion_mxn, + ieps_traslado, ieps_traslado_mxn, + ieps_retencion, ieps_retencion_mxn + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10, + $11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,$22,$23 + ) + `, [ + row.id, + c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad, + c.claveUnidad, c.unidad, + c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe), + c.descuento, m(c.descuento), + c.isrRetencion, m(c.isrRetencion), + c.ivaTraslado, m(c.ivaTraslado), + c.ivaRetencion, m(c.ivaRetencion), + c.iepsTraslado, m(c.iepsTraslado), + c.iepsRetencion, m(c.iepsRetencion), + ]); + conceptosInserted++; + } + } catch (err) { + console.error(`[BackfillConceptos] Error procesando CFDI ${row.id}:`, err); + } + } + + const { rows: remainingRow } = await pool.query(` + SELECT COUNT(*) as count + FROM cfdis c + LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id + WHERE c.xml_original IS NOT NULL AND cc.id IS NULL + `); + + return { + processed: cfdis.length, + conceptosInserted, + remaining: Number(remainingRow[0]?.count || 0), + }; +} diff --git a/apps/api/src/services/contribuyente-facturapi.service.ts b/apps/api/src/services/contribuyente-facturapi.service.ts index 61cdb16..b62907d 100644 --- a/apps/api/src/services/contribuyente-facturapi.service.ts +++ b/apps/api/src/services/contribuyente-facturapi.service.ts @@ -8,33 +8,58 @@ function getUserClient(): Facturapi { return new Facturapi(env.FACTURAPI_USER_KEY); } -async function getOrgApiKey(orgId: string): Promise { +async function getOrgApiKey(pool: Pool, orgId: string): Promise { const userKey = env.FACTURAPI_USER_KEY!; - let apiKey: string | undefined; + // 1. Reutilizar Live Secret Key si ya fue guardada en BD (evita PUT en cada emit) + const { rows: [orgRow] } = await pool.query( + 'SELECT api_key FROM facturapi_orgs WHERE facturapi_org_id = $1 LIMIT 1', + [orgId], + ); + if (orgRow?.api_key) { + return orgRow.api_key; + } + + // 2. Obtener Live Secret Key vía PUT /apikeys/live (idempotente) + let apiKey: string | undefined; try { - const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, { - headers: { 'Authorization': `Bearer ${userKey}` }, + const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${userKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), }); if (res.ok) { const key = await res.text(); apiKey = key.replace(/"/g, '').trim(); } - } catch { /* no test key */ } + } catch { /* fallback below */ } + // 3. Fallback a test key si el PUT falla (modo desarrollo/prueba) if (!apiKey) { try { - const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys`, { + const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, { headers: { 'Authorization': `Bearer ${userKey}` }, }); if (res.ok) { - const data = await res.json() as any; - if (data?.data?.length > 0) apiKey = data.data[0].api_key; + const key = await res.text(); + apiKey = key.replace(/"/g, '').trim(); } - } catch { /* no live keys */ } + } catch { /* no test key */ } } if (!apiKey) throw new Error('Organización Facturapi sin API key'); + + // 4. Guardar en BD para reutilizar en siguientes emisiones + if (apiKey.startsWith('sk_live_')) { + await pool.query( + 'UPDATE facturapi_orgs SET api_key = $1 WHERE facturapi_org_id = $2', + [apiKey, orgId], + ); + } + return apiKey; } @@ -180,7 +205,7 @@ export async function getOrgClientContribuyente( ); if (rows.length === 0) throw new Error('Contribuyente no tiene organización Facturapi configurada'); - const apiKey = await getOrgApiKey(rows[0].facturapi_org_id); + const apiKey = await getOrgApiKey(pool, rows[0].facturapi_org_id); return new Facturapi(apiKey); } diff --git a/apps/api/src/services/dashboard.service.ts b/apps/api/src/services/dashboard.service.ts index 72a4f4d..46c6b5c 100644 --- a/apps/api/src/services/dashboard.service.ts +++ b/apps/api/src/services/dashboard.service.ts @@ -299,10 +299,11 @@ export async function calcularIngresosPorRegimen( const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); - // Read-through cache: si el rango cae en años pasados con meses completos - // y hay un contribuyente seleccionado, lee de metricas_mensuales. Si hit, - // retorna de inmediato (evita ~3 queries SQL por régimen). - const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId); + // Read-through cache: solo si flags son default (true) para garantizar + // que las queries filtradas no lean datos del caché completo. + const cacheRange = considerarActivos && considerarNCs + ? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId) + : null; if (cacheRange) { const cached = await readIngresosFromCache(pool, cacheRange, ignorados, descMap); if (cached) return cached; @@ -474,8 +475,11 @@ export async function calcularEgresosPorRegimen( const ignorados = await getIgnorados(tenantId, _ignorados); const descMap = await getDescMap(_descMap); - // Read-through cache: ver nota en calcularIngresosPorRegimen. - const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId); + // Read-through cache: solo si flags son default (true) para garantizar + // que las queries filtradas no lean datos del caché completo. + const cacheRange = considerarActivos && considerarNCs + ? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId) + : null; if (cacheRange) { const cached = await readEgresosFromCache(pool, cacheRange, ignorados, descMap); if (cached) return cached; diff --git a/apps/api/src/services/facturapi.service.ts b/apps/api/src/services/facturapi.service.ts index dd5168e..cd743b4 100644 --- a/apps/api/src/services/facturapi.service.ts +++ b/apps/api/src/services/facturapi.service.ts @@ -20,7 +20,7 @@ function getUserClient(): Facturapi { async function getOrgClient(tenantId: string): Promise { const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, - select: { facturapiOrgId: true }, + select: { id: true, facturapiOrgId: true, facturapiOrgKey: true }, }); if (!tenant?.facturapiOrgId) { @@ -31,36 +31,53 @@ async function getOrgClient(tenantId: string): Promise { const orgId = tenant.facturapiOrgId; const userKey = env.FACTURAPI_USER_KEY!; - let apiKey: string | undefined; + // 1. Reutilizar Live Secret Key si ya fue guardada en BD (evita PUT en cada emit) + if (tenant.facturapiOrgKey) { + return new Facturapi(tenant.facturapiOrgKey); + } - // Intentar test key primero (más común en desarrollo) + // 2. Obtener Live Secret Key vía PUT /apikeys/live (idempotente) + let apiKey: string | undefined; try { - const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, { - headers: { 'Authorization': `Bearer ${userKey}` }, + const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${userKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), }); if (res.ok) { const key = await res.text(); apiKey = key.replace(/"/g, '').trim(); } - } catch { /* no test key */ } + } catch { /* fallback below */ } - // Fallback a live key + // 3. Fallback a test key si el PUT falla (modo desarrollo/prueba) if (!apiKey) { try { - const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys`, { + const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, { headers: { 'Authorization': `Bearer ${userKey}` }, }); if (res.ok) { - const data = await res.json() as any; - if (data?.data?.length > 0) apiKey = data.data[0].api_key; + const key = await res.text(); + apiKey = key.replace(/"/g, '').trim(); } - } catch { /* no live keys */ } + } catch { /* no test key */ } } if (!apiKey) { throw new Error('Organización Facturapi sin API key'); } + // 4. Guardar en BD para reutilizar en siguientes emisiones + if (tenant && apiKey.startsWith('sk_live_')) { + await prisma.tenant.update({ + where: { id: tenant.id }, + data: { facturapiOrgKey: apiKey }, + }); + } + return new Facturapi(apiKey); } diff --git a/apps/api/src/services/impuestos.service.ts b/apps/api/src/services/impuestos.service.ts index 468033d..a761101 100644 --- a/apps/api/src/services/impuestos.service.ts +++ b/apps/api/src/services/impuestos.service.ts @@ -353,11 +353,12 @@ export async function getIvaMensual( considerarActivos: boolean = true, considerarNCs: boolean = true, ): Promise { - // Cache read-through: solo si año pasado, sin conciliación, con contribuyente y flags default - const currentYear = new Date().getFullYear(); + // Cache read-through: sin conciliación, con contribuyente y flags default. + // El año actual YA NO se excluye: si los meses están precalculados en + // metricas_mensuales se usan; si faltan, readIvaMensualFromCache retorna + // null y caemos al path on-the-fly. const cacheable = process.env.METRICAS_BYPASS_CACHE !== '1' && - año < currentYear && !conciliacion && considerarActivos && considerarNCs && @@ -607,25 +608,20 @@ async function readResumenIvaFromCache( const resultado = trasladado - acreditable - retenido; - // Acumulado anual: su rango (year-01-01 → fechaFin) difiere del rango cacheado; - // se calcula on-the-fly contra raw cfdis. Una sola query. + // Acumulado anual: en vez de consultar raw cfdis, sumamos desde + // metricas_mensuales (enero → fechaFin). Como planCache garantiza que + // fechaFin es último día de mes, el acumulado coincide mes a mes. const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); - const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; - const REGIMEN_TENANT = regimenTenantExpr(ctx); - const acumRow = (await pool.query(` + const acumEnd = `${añoInicio}-${String(new Date(fechaFin + 'T00:00:00').getMonth() + 1).padStart(2, '0')}-01`; + const { rows: [acumRow] } = await pool.query(` SELECT - COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - - COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - - ( - COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - - COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) - ) as total - FROM cfdis - WHERE ${VIGENTE} - AND (${REGIMEN_TENANT}) = ANY($3) - AND ${acumFR} - AND (${ctx.esEmisor} OR ${ctx.esReceptor}) - `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0]; + COALESCE(SUM(iva_trasladado_total), 0) - + COALESCE(SUM(iva_acreditable), 0) - + COALESCE(SUM(iva_retenido_cobrado), 0) as total + FROM metricas_mensuales + WHERE contribuyente_id = $1 + AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date + `, [range.contribuyenteId, `${añoInicio}-01-01`, acumEnd]); return { trasladado, diff --git a/apps/api/src/utils/metricas-cache.ts b/apps/api/src/utils/metricas-cache.ts index ab9d83b..599aaf2 100644 --- a/apps/api/src/utils/metricas-cache.ts +++ b/apps/api/src/utils/metricas-cache.ts @@ -9,9 +9,13 @@ export interface CacheRange { * (read-through cache de Tanda B). Requisitos: * - `contribuyenteId` presente (la tabla solo tiene datos por contribuyente) * - `conciliacion` desactivada (la tabla guarda flujo normal, no id_conciliacion) - * - `fechaFin` antes del primer día del año actual (años cerrados) * - `fechaInicio` es día 1 del mes; `fechaFin` es último día del mes * + * El año actual YA NO se excluye: si los meses están precalculados en + * metricas_mensuales, se usan; si faltan, read*FromCache retorna null y el + * caller cae a on-the-fly. Esto permite cachear 2026 y años futuros sin + * modificar esta función cada año. + * * Retorna `null` si no califica — el caller debe caer al path on-the-fly. */ export function planCache( @@ -27,8 +31,8 @@ export function planCache( const fi = new Date(fechaInicio + 'T00:00:00Z'); const ff = new Date(fechaFin + 'T00:00:00Z'); if (isNaN(fi.getTime()) || isNaN(ff.getTime())) return null; - const currentYearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); - if (ff >= currentYearStart) return null; + // Ya no se excluye el año actual: si hay datos precalculados se usan, + // si no, read*FromCache retorna null y el caller cae a on-the-fly. if (fi.getUTCDate() !== 1) return null; const lastDay = new Date(Date.UTC(ff.getUTCFullYear(), ff.getUTCMonth() + 1, 0)).getUTCDate(); if (ff.getUTCDate() !== lastDay) return null; diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index ba75557..27c29b8 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -4,12 +4,12 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { useDebounce } from '@horux/shared-ui'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui'; -import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; -import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi'; -import { cancelarFactura } from '@/lib/api/facturacion'; -import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared'; +import { useCfdis, useCfdiConceptos, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; +import { getCfdis, createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi'; +import { cancelarFactura, downloadPdf, downloadXml } from '@/lib/api/facturacion'; +import type { CfdiFilters, CfdiConceptoFilters, TipoCfdi, Cfdi } from '@horux/shared'; import type { CreateCfdiData } from '@/lib/api/cfdi'; -import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react'; +import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'; import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; @@ -262,6 +262,30 @@ export default function CfdiPage() { const [loadingReceptor, setLoadingReceptor] = useState(false); const [showForm, setShowForm] = useState(false); + // Tabs: CFDIs vs Conceptos + const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis'); + + // Conceptos filters & state + const [conceptoFilters, setConceptoFilters] = useState({ + page: 1, + limit: 20, + }); + const [conceptoSearch, setConceptoSearch] = useState(''); + const [conceptoColumnFilters, setConceptoColumnFilters] = useState({ + fechaInicio: '', + fechaFin: '', + uuid: '', + claveProdServ: '', + descripcion: '', + }); + const [conceptoOrder, setConceptoOrder] = useState<{ by: 'fecha' | 'importe'; dir: 'asc' | 'desc' }>({ by: 'fecha', dir: 'desc' }); + const [openConceptoFilter, setOpenConceptoFilter] = useState<'fecha' | 'uuid' | 'clave' | 'descripcion' | null>(null); + const hasConceptoDateFilter = !!conceptoColumnFilters.fechaInicio || !!conceptoColumnFilters.fechaFin; + const hasConceptoUuidFilter = !!conceptoColumnFilters.uuid; + const hasConceptoClaveFilter = !!conceptoColumnFilters.claveProdServ; + const hasConceptoDescFilter = !!conceptoColumnFilters.descripcion; + const { data: conceptosData, isLoading: conceptosLoading } = useCfdiConceptos(conceptoFilters); + // Debounced values for autocomplete const debouncedEmisor = useDebounce(columnFilters.emisor, 300); const debouncedReceptor = useDebounce(columnFilters.receptor, 300); @@ -322,6 +346,21 @@ export default function CfdiPage() { const [viewingCfdi, setViewingCfdi] = useState(null); const [loadingCfdi, setLoadingCfdi] = useState(null); + // Sync shared filters between CFDIs and Conceptos tabs + useEffect(() => { + setConceptoFilters(prev => ({ + ...prev, + fechaInicio: columnFilters.fechaInicio || undefined, + fechaFin: columnFilters.fechaFin || undefined, + tipo: filters.tipo, + tipoComprobante: filters.tipoComprobante, + estado: filters.estado, + rfc: filters.rfc, + search: searchTerm || undefined, + page: 1, + })); + }, [filters.tipo, filters.tipoComprobante, filters.estado, filters.rfc, columnFilters.fechaInicio, columnFilters.fechaFin, searchTerm]); + // Cancelación Facturapi state const [cancelTarget, setCancelTarget] = useState(null); const [cancelMotive, setCancelMotive] = useState<'01' | '02' | '03' | '04'>('02'); @@ -343,11 +382,58 @@ export default function CfdiPage() { }; const canEdit = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'contador' || user?.role === 'auxiliar'; + const canDelete = user?.role === 'owner' || user?.role === 'contador'; const handleSearch = () => { setFilters({ ...filters, search: searchTerm, page: 1 }); }; + // Conceptos column filters & sorting + const applyConceptoColumnFilters = () => { + setConceptoFilters(prev => ({ + ...prev, + fechaInicio: conceptoColumnFilters.fechaInicio || undefined, + fechaFin: conceptoColumnFilters.fechaFin || undefined, + uuid: conceptoColumnFilters.uuid || undefined, + claveProdServ: conceptoColumnFilters.claveProdServ || undefined, + descripcion: conceptoColumnFilters.descripcion || undefined, + orderBy: conceptoOrder.by, + orderDir: conceptoOrder.dir, + page: 1, + })); + }; + + const toggleConceptoOrder = (by: 'fecha' | 'importe') => { + setConceptoOrder(prev => { + const dir = prev.by === by && prev.dir === 'desc' ? 'asc' : 'desc'; + return { by, dir }; + }); + // Apply after state update using the new values + setConceptoFilters(prev => ({ + ...prev, + orderBy: by, + orderDir: by === conceptoOrder.by && conceptoOrder.dir === 'desc' ? 'asc' : 'desc', + page: 1, + })); + }; + + const clearConceptoDateFilter = () => { + setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: '', fechaFin: '' })); + setConceptoFilters(prev => ({ ...prev, fechaInicio: undefined, fechaFin: undefined, page: 1 })); + }; + const clearConceptoUuidFilter = () => { + setConceptoColumnFilters(prev => ({ ...prev, uuid: '' })); + setConceptoFilters(prev => ({ ...prev, uuid: undefined, page: 1 })); + }; + const clearConceptoClaveFilter = () => { + setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: '' })); + setConceptoFilters(prev => ({ ...prev, claveProdServ: undefined, page: 1 })); + }; + const clearConceptoDescFilter = () => { + setConceptoColumnFilters(prev => ({ ...prev, descripcion: '' })); + setConceptoFilters(prev => ({ ...prev, descripcion: undefined, page: 1 })); + }; + // Export to Excel const [exporting, setExporting] = useState(false); @@ -356,7 +442,17 @@ export default function CfdiPage() { setExporting(true); try { - const exportData = data.data.map(cfdi => ({ + // Traer TODOS los CFDIs que coinciden con los filtros (sin paginación) + const allFilters: CfdiFilters = { ...filters, page: 1, limit: 10000 }; + const allData = await getCfdis(allFilters); + const rows = allData.data; + + if (!rows.length) { + alert('No hay datos para exportar'); + return; + } + + const exportData = rows.map(cfdi => ({ 'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'), 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), 'Serie': cfdi.serie || '', @@ -399,6 +495,40 @@ export default function CfdiPage() { } }; + const handleDownloadPdf = async (facturapiId: string | null) => { + if (!facturapiId) return; + try { + const blob = await downloadPdf(facturapiId); + const url = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `factura-${facturapiId}.pdf`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error: any) { + alert('Error al descargar PDF: ' + (error?.message || 'Desconocido')); + } + }; + + const handleDownloadXml = async (facturapiId: string | null) => { + if (!facturapiId) return; + try { + const blob = await downloadXml(facturapiId); + const url = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `factura-${facturapiId}.xml`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error: any) { + alert('Error al descargar XML: ' + (error?.message || 'Desconocido')); + } + }; + const exportSingleCfdiToExcel = (cfdi: Cfdi) => { const row = { 'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'), @@ -741,11 +871,19 @@ export default function CfdiPage() { const handleDelete = async (id: string | number) => { const idStr = String(id); + console.log('[DeleteCFDI] Intentando eliminar ID:', idStr, 'tipo:', typeof id); + if (!id || idStr === 'undefined' || idStr === 'null' || idStr === '0') { + alert('ID de CFDI inválido: ' + idStr); + return; + } if (confirm('¿Eliminar este CFDI?')) { try { await deleteCfdi.mutateAsync(idStr); - } catch (error) { + alert('CFDI eliminado correctamente'); + } catch (error: any) { console.error('Error deleting CFDI:', error); + const msg = error?.response?.data?.message || error?.message || 'Error al eliminar el CFDI'; + alert('Error: ' + msg); } } }; @@ -809,6 +947,21 @@ export default function CfdiPage() { <>
+ {/* Tabs */} +
+ + +
{/* Filters */} @@ -899,6 +1052,8 @@ export default function CfdiPage() { + {activeTab === 'cfdis' && ( + <> {/* Add CFDI Form */} {showForm && canEdit && ( @@ -1586,6 +1741,7 @@ export default function CfdiPage() { + Uso CFDI Total Estado @@ -1625,6 +1781,9 @@ export default function CfdiPage() {

+ + {cfdi.usoCfdi || '-'} + {formatCurrency(cfdi.total)} @@ -1654,6 +1813,21 @@ export default function CfdiPage() { )} + {(cfdi as any).source === 'facturapi' && cfdi.facturapiId && ( + <> + + + + {/* XML download hidden */} + + )} )} - + {canDelete && ( + + )} )} @@ -1729,6 +1905,219 @@ export default function CfdiPage() { )}
+ + )} + + {activeTab === 'conceptos' && ( + + + + + Conceptos ({conceptosData?.total || 0}) + + + + {conceptosLoading ? ( +
+ {[...Array(8)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : conceptosData?.data.length === 0 ? ( +
+ No se encontraron conceptos +
+ ) : ( +
+ + + + + + + + + + + + + + {/* Descuento and IVA columns hidden */} + + + + {conceptosData?.data.map((c) => ( + + + + + + + + + + + + {/* Descuento and IVA cells hidden */} + + ))} + +
+
+ + setOpenConceptoFilter(open ? 'fecha' : null)}> + + + + +
+

Filtrar por fecha

+
+
+ + setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: e.target.value }))} /> +
+
+ + setConceptoColumnFilters(prev => ({ ...prev, fechaFin: e.target.value }))} /> +
+
+
+ + {hasConceptoDateFilter && } +
+
+
+
+
+
+
+ UUID + setOpenConceptoFilter(open ? 'uuid' : null)}> + + + + +
+

Filtrar por UUID

+ setConceptoColumnFilters(prev => ({ ...prev, uuid: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} /> +
+ + {hasConceptoUuidFilter && } +
+
+
+
+
+
+
+ Clave + setOpenConceptoFilter(open ? 'clave' : null)}> + + + + +
+

Filtrar por clave

+ setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} /> +
+ + {hasConceptoClaveFilter && } +
+
+
+
+
+
+
+ Descripción + setOpenConceptoFilter(open ? 'descripcion' : null)}> + + + + +
+

Filtrar por descripción

+ setConceptoColumnFilters(prev => ({ ...prev, descripcion: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} /> +
+ + {hasConceptoDescFilter && } +
+
+
+
+
+
RFC EmisorRFC ReceptorCantidadUnidadV. Unitario + +
+ {c.cfdiFechaEmision ? new Date(c.cfdiFechaEmision).toLocaleDateString('es-MX') : '-'} + + {c.cfdiUuid || '-'} + {c.claveProdServ || '-'}{c.descripcion}{c.cfdiRfcEmisor || '-'}{c.cfdiRfcReceptor || '-'}{c.cantidad}{c.claveUnidad || c.unidad || '-'}${Number(c.valorUnitario).toLocaleString('es-MX', { minimumFractionDigits: 2 })}${Number(c.importe).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
+
+ )} + + {/* Pagination for conceptos */} + {conceptosData && conceptosData.totalPages > 1 && ( +
+

+ Pagina {conceptosData.page} de {conceptosData.totalPages} +

+
+ + +
+
+ )} +
+
+ )}
= { }; interface ConceptoForm { + id: string; description: string; productKey: string; productKeyLabel: string; @@ -65,7 +66,7 @@ const defaultTaxes: TaxLine[] = [ ]; const emptyConcepto: ConceptoForm = { - description: '', productKey: '', productKeyLabel: '', + id: '', description: '', productKey: '', productKeyLabel: '', unitKey: 'E48', quantity: 1, price: 0, discount: 0, objetoImp: '02', taxes: [...defaultTaxes], }; @@ -327,7 +328,7 @@ export default function FacturacionPage() { const [condiciones, setCondiciones] = useState(''); // Conceptos - const [conceptos, setConceptos] = useState([{ ...emptyConcepto }]); + const [conceptos, setConceptos] = useState([{ ...emptyConcepto, id: crypto.randomUUID() }]); // CFDIs relacionados (Ingreso / Egreso) const [relatedDocs, setRelatedDocs] = useState([]); @@ -365,7 +366,7 @@ export default function FacturacionPage() { setSerie(''); setFolio(''); setCondiciones(''); - setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]); + setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]); setRelatedDocs([]); setPagoUuid(''); setPagoMonto(0); @@ -514,7 +515,7 @@ export default function FacturacionPage() { setMetodoPago(c.defaultMetodoPago); // Resetear conceptos con unidad default según tipo const defaultUnit = tipo === 'T' ? 'H87' : 'E48'; - setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]); + setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]); }; // Unidades de servicio que no aplican para Traslado @@ -532,30 +533,28 @@ export default function FacturacionPage() { }; const selectProduct = (idx: number, clave: string, descripcion: string) => { - const updated = [...conceptos]; - updated[idx].productKey = clave; - updated[idx].productKeyLabel = `${clave} - ${descripcion}`; - setConceptos(updated); + setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, productKey: clave, productKeyLabel: `${clave} - ${descripcion}` } : c)); setProdResults([]); setSearchingIdx(null); }; const updateConcepto = (idx: number, field: keyof ConceptoForm, value: any) => { - const updated = [...conceptos]; - (updated[idx] as any)[field] = value; - // Si cambió la unidad, re-evaluar recomendación de impuestos - if (field === 'unitKey' && tipoComprobante === 'I') { - const recommended = getRecommendedTaxes( - emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, value - ); - if (recommended) { - updated[idx].taxes = recommended; - } else { - // Si ya no aplica retención, dejar solo IVA 16% - updated[idx].taxes = [...defaultTaxes]; + setConceptos(prev => prev.map((c, i) => { + if (i !== idx) return c; + const updated = { ...c, [field]: value } as ConceptoForm; + // Si cambió la unidad, re-evaluar recomendación de impuestos + if (field === 'unitKey' && tipoComprobante === 'I') { + const recommended = getRecommendedTaxes( + emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, value + ); + if (recommended) { + updated.taxes = recommended; + } else { + updated.taxes = [...defaultTaxes]; + } } - } - setConceptos(updated); + return updated; + })); }; // Aplica recomendación de impuestos a un concepto si corresponde (solo tipo I) @@ -571,12 +570,14 @@ export default function FacturacionPage() { }; const addConcepto = () => { - const newConcepto = applyTaxRecommendation({ ...emptyConcepto }); - setConceptos([...conceptos, newConcepto]); + const newConcepto = applyTaxRecommendation({ ...emptyConcepto, id: crypto.randomUUID() }); + setConceptos(prev => [...prev, newConcepto]); }; const removeConcepto = (idx: number) => { - if (conceptos.length === 1) return; - setConceptos(conceptos.filter((_, i) => i !== idx)); + setConceptos(prev => { + if (prev.length === 1) return prev; + return prev.filter((_, i) => i !== idx); + }); }; // Cálculos @@ -708,7 +709,7 @@ export default function FacturacionPage() {

Total

${result.total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}

- @@ -1258,7 +1259,7 @@ export default function FacturacionPage() { {conceptos.map((c, idx) => ( -
+
Concepto {idx + 1}
@@ -1350,9 +1351,7 @@ export default function FacturacionPage() { { const opt = TAX_OPTIONS.retencion.find(o => `${o.type}-${o.rate}` === v); if (!opt) return; - const updated = [...conceptos]; - updated[idx].taxes = [...updated[idx].taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }]; - setConceptos(updated); + setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c)); }}> @@ -1406,9 +1403,7 @@ export default function FacturacionPage() { {tax.category === 'retencion' ? '-' : ''}${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })} diff --git a/apps/web/lib/api/cfdi.ts b/apps/web/lib/api/cfdi.ts index a30132b..ed04be0 100644 --- a/apps/web/lib/api/cfdi.ts +++ b/apps/web/lib/api/cfdi.ts @@ -1,5 +1,5 @@ import { apiClient } from './client'; -import type { CfdiListResponse, CfdiFilters, Cfdi } from '@horux/shared'; +import type { CfdiListResponse, CfdiFilters, Cfdi, CfdiConceptoFilters, CfdiConceptoListResponse } from '@horux/shared'; export async function getCfdis(filters: CfdiFilters): Promise { const params = new URLSearchParams(); @@ -102,6 +102,28 @@ export async function getCfdiConceptos(id: number | string): Promise { return response.data; } +export async function getAllCfdiConceptos(filters: CfdiConceptoFilters): Promise { + const params = new URLSearchParams(); + if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio); + if (filters.fechaFin) params.set('fechaFin', filters.fechaFin); + if (filters.tipo) params.set('tipo', filters.tipo); + if (filters.tipoComprobante) params.set('tipoComprobante', filters.tipoComprobante); + if (filters.estado) params.set('estado', filters.estado); + if (filters.rfc) params.set('rfc', filters.rfc); + if (filters.search) params.set('search', filters.search); + if (filters.uuid) params.set('uuid', filters.uuid); + if (filters.claveProdServ) params.set('claveProdServ', filters.claveProdServ); + if (filters.descripcion) params.set('descripcion', filters.descripcion); + if (filters.orderBy) params.set('orderBy', filters.orderBy); + if (filters.orderDir) params.set('orderDir', filters.orderDir); + if (filters.contribuyenteId) params.set('contribuyenteId', filters.contribuyenteId); + if (filters.page) params.set('page', filters.page.toString()); + if (filters.limit) params.set('limit', filters.limit.toString()); + + const response = await apiClient.get(`/cfdi/conceptos?${params}`); + return response.data; +} + export async function deleteCfdi(id: string): Promise { await apiClient.delete(`/cfdi/${id}`); } diff --git a/apps/web/lib/hooks/use-cfdi.ts b/apps/web/lib/hooks/use-cfdi.ts index 2ff2393..c0b99fa 100644 --- a/apps/web/lib/hooks/use-cfdi.ts +++ b/apps/web/lib/hooks/use-cfdi.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import * as cfdiApi from '@/lib/api/cfdi'; -import type { CfdiFilters } from '@horux/shared'; +import type { CfdiFilters, CfdiConceptoFilters } from '@horux/shared'; import type { CreateCfdiData } from '@/lib/api/cfdi'; import { useContribuyenteStore } from '@/stores/contribuyente-store'; @@ -58,6 +58,18 @@ export function useCreateManyCfdis() { }); } +export function useCfdiConceptos(filters: CfdiConceptoFilters) { + const { selectedContribuyenteId } = useContribuyenteStore(); + const filtersWithContribuyente: CfdiConceptoFilters = { + ...filters, + contribuyenteId: selectedContribuyenteId || undefined, + }; + return useQuery({ + queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId], + queryFn: () => cfdiApi.getAllCfdiConceptos(filtersWithContribuyente), + }); +} + export function useDeleteCfdi() { const queryClient = useQueryClient(); diff --git a/packages/shared/src/types/cfdi.ts b/packages/shared/src/types/cfdi.ts index 6caa699..e86ed6a 100644 --- a/packages/shared/src/types/cfdi.ts +++ b/packages/shared/src/types/cfdi.ts @@ -167,6 +167,44 @@ export interface CfdiConcepto { creadoEn: string; } +export interface CfdiConceptoFilters { + fechaInicio?: string; + fechaFin?: string; + tipo?: TipoCfdi; + tipoComprobante?: string; + estado?: string; + rfc?: string; + search?: string; + uuid?: string; + claveProdServ?: string; + descripcion?: string; + orderBy?: 'fecha' | 'importe'; + orderDir?: 'asc' | 'desc'; + contribuyenteId?: string; + page?: number; + limit?: number; +} + +export interface CfdiConceptoListItem extends CfdiConcepto { + cfdiUuid: string | null; + cfdiFechaEmision: string | null; + cfdiRfcEmisor: string | null; + cfdiNombreEmisor: string | null; + cfdiRfcReceptor: string | null; + cfdiNombreReceptor: string | null; + cfdiTipoComprobante: string | null; + cfdiStatus: string | null; + cfdiTipo: string | null; +} + +export interface CfdiConceptoListResponse { + data: CfdiConceptoListItem[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + export interface CfdiListResponse { data: Cfdi[]; total: number; diff --git a/scripts/backfill-conceptos.js b/scripts/backfill-conceptos.js new file mode 100644 index 0000000..ede48b9 --- /dev/null +++ b/scripts/backfill-conceptos.js @@ -0,0 +1,143 @@ +const { Pool } = require('pg'); +const { XMLParser } = require('fast-xml-parser'); + +const DB_NAME = process.argv[2] || 'horux_roem691011ez4'; +const BATCH_SIZE = parseInt(process.argv[3]) || 100; + +const pool = new Pool({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb', + database: DB_NAME, +}); + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + removeNSPrefix: true, +}); + +function toArray(val) { + if (!val) return []; + return Array.isArray(val) ? val : [val]; +} + +function pf(v) { + const n = parseFloat(v); + return isNaN(n) ? 0 : n; +} + +function extractConceptos(comprobante) { + const conceptosNode = comprobante.Conceptos?.Concepto; + if (!conceptosNode) return []; + const conceptos = toArray(conceptosNode); + return conceptos.map((c) => { + const trasladosC = toArray(c.Impuestos?.Traslados?.Traslado); + const retencionesC = toArray(c.Impuestos?.Retenciones?.Retencion); + let ivaTraslado = 0, iepsTraslado = 0; + for (const t of trasladosC) { + const importe = pf(t['@_Importe']); + if (t['@_Impuesto'] === '002') ivaTraslado += importe; + else if (t['@_Impuesto'] === '003') iepsTraslado += importe; + } + let isrRetencion = 0, ivaRetencion = 0, iepsRetencion = 0; + for (const r of retencionesC) { + const importe = pf(r['@_Importe']); + if (r['@_Impuesto'] === '001') isrRetencion += importe; + else if (r['@_Impuesto'] === '002') ivaRetencion += importe; + else if (r['@_Impuesto'] === '003') iepsRetencion += importe; + } + return { + claveProdServ: c['@_ClaveProdServ'] || null, + noIdentificacion: c['@_NoIdentificacion'] || null, + descripcion: c['@_Descripcion'] || '', + cantidad: pf(c['@_Cantidad']) || 1, + claveUnidad: c['@_ClaveUnidad'] || null, + unidad: c['@_Unidad'] || null, + valorUnitario: pf(c['@_ValorUnitario']), + importe: pf(c['@_Importe']), + descuento: pf(c['@_Descuento']), + isrRetencion, + ivaTraslado, + ivaRetencion, + iepsTraslado, + iepsRetencion, + }; + }); +} + +function parseXml(xmlContent) { + try { + const result = xmlParser.parse(xmlContent); + const comprobante = result.Comprobante; + if (!comprobante) return null; + const tc = pf(comprobante['@_TipoCambio']) || 1; + return { tipoCambio: tc, conceptos: extractConceptos(comprobante) }; + } catch (e) { + console.error('Parse error:', e.message); + return null; + } +} + +async function run() { + let totalProcessed = 0; + let totalConceptos = 0; + let batch = 0; + + while (true) { + batch++; + const { rows: cfdis } = await pool.query(` + SELECT c.id, c.xml_original + FROM cfdis c + LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id + WHERE c.xml_original IS NOT NULL AND cc.id IS NULL + LIMIT $1 + `, [BATCH_SIZE]); + + if (cfdis.length === 0) break; + + let batchConceptos = 0; + for (const row of cfdis) { + try { + const parsed = parseXml(row.xml_original); + if (!parsed || !parsed.conceptos || parsed.conceptos.length === 0) continue; + + const tc = parsed.tipoCambio || 1; + const m = (v) => v * tc; + + for (const c of parsed.conceptos) { + await pool.query(` + INSERT INTO cfdi_conceptos ( + cfdi_id, clave_prod_serv, no_identificacion, descripcion, cantidad, + clave_unidad, unidad, valor_unitario, valor_unitario_mxn, importe, importe_mxn, + descuento, descuento_mxn, isr_retencion, isr_retencion_mxn, + iva_traslado, iva_traslado_mxn, iva_retencion, iva_retencion_mxn, + ieps_traslado, ieps_traslado_mxn, ieps_retencion, ieps_retencion_mxn + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) + `, [ + row.id, c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad, + c.claveUnidad, c.unidad, c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe), + c.descuento, m(c.descuento), c.isrRetencion, m(c.isrRetencion), + c.ivaTraslado, m(c.ivaTraslado), c.ivaRetencion, m(c.ivaRetencion), + c.iepsTraslado, m(c.iepsTraslado), c.iepsRetencion, m(c.iepsRetencion), + ]); + batchConceptos++; + } + } catch (err) { + console.error(`Error CFDI ${row.id}:`, err.message); + } + } + + totalProcessed += cfdis.length; + totalConceptos += batchConceptos; + console.log(`Batch ${batch}: procesados ${cfdis.length}, conceptos ${batchConceptos}, total procesados ${totalProcessed}, total conceptos ${totalConceptos}`); + + if (cfdis.length < BATCH_SIZE) break; + } + + console.log(`\nBackfill completado. Total CFDIs procesados: ${totalProcessed}, total conceptos insertados: ${totalConceptos}`); + await pool.end(); +} + +run().catch((e) => { console.error(e); process.exit(1); });