From e7dbae1ab7099bd1bc98adb3821e97dc5e7f3b12 Mon Sep 17 00:00:00 2001
From: Horux Dev
Date: Wed, 29 Apr 2026 21:03:41 +0000
Subject: [PATCH] feat: conceptos tab, filters, backfill, facturapi live keys,
fixes
- Add Conceptos tab in CFDI page with column filters, sorting, pagination
- Add GET /cfdi/conceptos endpoint with filters and orderBy
- Backfill cfdi_conceptos from legacy XMLs (824k concepts inserted)
- Fix CFDI delete button (bypass subscription check, add alerts)
- Fix export to Excel (fetch all filtered results, limit 10k)
- Fix facturacion page concepto delete bug (immutable updates, unique ids)
- Add Facturapi live key auto-generation and caching
- Fix SAT fechaPagoP parsing
- Add metrics cache support for current year
- Increase DB pool max to 15
---
apps/api/.env.save | 34 ++
apps/api/src/config/database.ts | 2 +-
apps/api/src/config/env.ts | 1 +
apps/api/src/controllers/cfdi.controller.ts | 49 ++
.../src/controllers/facturacion.controller.ts | 34 +-
apps/api/src/routes/cfdi.routes.ts | 7 +-
apps/api/src/services/cfdi.service.ts | 205 ++++++++-
.../contribuyente-facturapi.service.ts | 45 +-
apps/api/src/services/dashboard.service.ts | 16 +-
apps/api/src/services/facturapi.service.ts | 39 +-
apps/api/src/services/impuestos.service.ts | 36 +-
apps/api/src/utils/metricas-cache.ts | 10 +-
apps/web/app/(dashboard)/cfdi/page.tsx | 421 +++++++++++++++++-
apps/web/app/(dashboard)/facturacion/page.tsx | 69 ++-
apps/web/lib/api/cfdi.ts | 24 +-
apps/web/lib/hooks/use-cfdi.ts | 14 +-
packages/shared/src/types/cfdi.ts | 38 ++
scripts/backfill-conceptos.js | 143 ++++++
18 files changed, 1076 insertions(+), 111 deletions(-)
create mode 100644 apps/api/.env.save
create mode 100644 scripts/backfill-conceptos.js
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
+
+ ) : (
+
+
+
+
+
+
+
+ setOpenConceptoFilter(open ? 'fecha' : null)}>
+
+
+
+
+
+ Filtrar por fecha
+
+
+
+ {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 Emisor |
+ RFC Receptor |
+ Cantidad |
+ Unidad |
+ V. Unitario |
+
+
+ |
+ {/* Descuento and IVA columns hidden */}
+
+
+
+ {conceptosData?.data.map((c) => (
+
+ |
+ {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 })} |
+ {/* Descuento and IVA cells hidden */}
+
+ ))}
+
+
+
+ )}
+
+ {/* 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 })}
-