Compare commits

...

3 Commits

Author SHA1 Message Date
Horux Dev
b9bd8cfc1e fix(conciliacion): incluir CFDIs tipo P en filtro de emitidos
La condicion SQL NOT (metodo_pago = 'PPD' AND ...) producia NULL
cuando metodo_pago era NULL (como en complementos de pago tipo P),
lo que excluia silenciosamente todos los tipo P del listado de
emitidos en conciliacion.

Cambiada a: metodo_pago IS NULL OR metodo_pago != 'PPD' OR
regimen_fiscal_emisor IN ('605','616')

Esto mantiene la intencion original (excluir PPD de regimenes
que no son 605/616) sin afectar a tipo P ni otros sin metodo de pago.

Refs: docs/CAMBIOS-2026-05-09.md seccion 8
2026-05-11 17:48:10 +00:00
Horux Dev
6dfcbfc05c fix(conciliacion): complementos de pago usan fecha_pago_p y campos faltantes en visor
- conciliacion.service.ts: filtros y ordenamiento ahora usan
  COALESCE(fecha_pago_p, fecha_emision). Los CFDIs tipo P
  (complementos de pago) aparecen en el periodo del pago real,
  no de la emision del CFDI.

- conciliacion.service.ts: agrega fechaPagoP al SELECT y a la
  interfaz ConciliacionCfdi.

- conciliacion/page.tsx: tablas y export Excel usan
  fechaPagoP || fechaEmision para mostrar la fecha.

- cfdi-invoice.tsx: para tipo P con fechaPagoP, muestra
  'Pago: {fecha}' en el encabezado.

- conciliacion.ts: actualiza interfaz ConciliacionCfdi con
  todos los campos que ya devuelve el backend.

Refs: docs/CAMBIOS-2026-05-09.md secciones 7 y 8
2026-05-11 17:31:35 +00:00
Horux Dev
e21ccd6860 fix(sat,conciliacion): propagar contribuyenteId en sync SAT y campos faltantes en visor de conciliacion
- sat-sync.job.ts: cron diario e incremental ahora iteran contribuyentes
  por tenant y pasan contribuyenteId a startSync(). Evita que CFDIs
  importados del SAT queden con contribuyente_id = NULL.

- sat.service.ts: retryJob() ahora reintenta con job.contribuyenteId.

- conciliacion.service.ts: agrega campos faltantes al SELECT de CFDIs:
  status, formaPago, serie, folio, usoCfdi, subtotal, descuento,
  moneda, tipoCambio, ivaTraslado, ivaRetencion, isrRetencion,
  fechaCertSat. Antes el visor mostraba 'CANCELADO' para todos los
  CFDIs (status era undefined) y faltaban datos de forma de pago,
  impuestos, serie/folio, etc.

Refs: docs/CAMBIOS-2026-05-09.md secciones 6 y 7
2026-05-11 03:58:53 +00:00
7 changed files with 300 additions and 33 deletions

View File

@@ -58,24 +58,56 @@ async function needsInitialSync(tenantId: string): Promise<boolean> {
}
/**
* Ejecuta sincronización para un tenant
* Ejecuta sincronización para un tenant y sus contribuyentes
*/
async function syncTenant(tenantId: string): Promise<void> {
try {
// Verificar si hay sync activo
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
return;
}
// Determinar tipo de sync
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId}`);
const jobId = await startSync(tenantId, syncType);
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`);
// Obtener contribuyentes del tenant
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
let contribuyenteIds: string[] = [];
if (tenant?.databaseName) {
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
contribuyenteIds = rows.map((r: any) => r.entidad_id);
}
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
if (contribuyenteIds.length === 0) {
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
return;
}
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} (sin contribuyentes)`);
const jobId = await startSync(tenantId, syncType);
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`);
return;
}
// Sincronizar cada contribuyente
for (const contribuyenteId of contribuyenteIds) {
try {
const status = await getSyncStatus(tenantId, contribuyenteId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} contribuyente ${contribuyenteId} ya tiene sync activo, omitiendo`);
continue;
}
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} contribuyente ${contribuyenteId}`);
const jobId = await startSync(tenantId, syncType, undefined, undefined, contribuyenteId);
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId} contribuyente ${contribuyenteId}`);
} catch (error: any) {
console.error(`[SAT Cron] Error sincronizando tenant ${tenantId} contribuyente ${contribuyenteId}:`, error.message);
}
}
} catch (error: any) {
console.error(`[SAT Cron] Error sincronizando tenant ${tenantId}:`, error.message);
}
@@ -150,19 +182,11 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
}
/**
* Dispara una sincronización incremental (ventana de 6 horas) para un tenant.
* Si el tenant ya tiene un sync activo, omite para no solapar solicitudes al SAT.
* Si el tenant nunca ha hecho `initial`, omite: el incremental no debe actuar
* como primera descarga — la inicial requiere correrse aparte.
* Dispara una sincronización incremental (ventana de 6 horas) para un tenant
* y sus contribuyentes.
*/
async function incrementalSyncTenant(tenantId: string): Promise<void> {
try {
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`);
return;
}
const completedInitial = await prisma.satSyncJob.findFirst({
where: { tenantId, type: 'initial', status: 'completed' },
});
@@ -171,9 +195,48 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
return;
}
console.log(`[SAT Cron Inc] Iniciando incremental para tenant ${tenantId}`);
const jobId = await startSync(tenantId, 'incremental');
console.log(`[SAT Cron Inc] Job ${jobId} iniciado`);
// Obtener contribuyentes del tenant
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
let contribuyenteIds: string[] = [];
if (tenant?.databaseName) {
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
contribuyenteIds = rows.map((r: any) => r.entidad_id);
}
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
if (contribuyenteIds.length === 0) {
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`);
return;
}
console.log(`[SAT Cron Inc] Iniciando incremental para tenant ${tenantId} (sin contribuyentes)`);
const jobId = await startSync(tenantId, 'incremental');
console.log(`[SAT Cron Inc] Job ${jobId} iniciado`);
return;
}
// Sincronizar cada contribuyente
for (const contribuyenteId of contribuyenteIds) {
try {
const status = await getSyncStatus(tenantId, contribuyenteId);
if (status.hasActiveSync) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} con sync activo, omitiendo`);
continue;
}
console.log(`[SAT Cron Inc] Iniciando incremental para tenant ${tenantId} contribuyente ${contribuyenteId}`);
const jobId = await startSync(tenantId, 'incremental', undefined, undefined, contribuyenteId);
console.log(`[SAT Cron Inc] Job ${jobId} iniciado`);
} catch (error: any) {
console.error(`[SAT Cron Inc] Error para tenant ${tenantId} contribuyente ${contribuyenteId}:`, error.message);
}
}
} catch (error: any) {
console.error(`[SAT Cron Inc] Error para tenant ${tenantId}:`, error.message);
}

View File

@@ -6,6 +6,8 @@ export interface ConciliacionCfdi {
id: number;
uuid: string;
type: string;
serie: string | null;
folio: string | null;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
@@ -13,7 +15,20 @@ export interface ConciliacionCfdi {
nombreReceptor: string;
total: number;
totalMxn: number;
subtotal: number;
descuento: number;
moneda: string;
tipoCambio: number;
tipoComprobante: string | null;
metodoPago: string | null;
formaPago: string | null;
usoCfdi: string | null;
status: string | null;
fechaCertSat: string | null;
fechaPagoP: string | null;
ivaTraslado: number;
ivaRetencion: number;
isrRetencion: number;
conciliado: string | null;
idConciliacion: number | null;
conciliacion: {
@@ -47,16 +62,17 @@ export async function getCfdisConConciliacion(
}
// Excluir PPD en emitidos para todos los regimenes excepto 605 y 616
// (metodo_pago IS NULL cubre tipo P y otros casos sin metodo de pago)
if (filters.tipo === 'EMITIDO') {
where += ` AND NOT (c.metodo_pago = 'PPD' AND (c.regimen_fiscal_emisor IS NULL OR c.regimen_fiscal_emisor NOT IN ('605','616')))`;
where += ` AND (c.metodo_pago IS NULL OR c.metodo_pago != 'PPD' OR c.regimen_fiscal_emisor IN ('605','616'))`;
}
if (filters.fechaInicio) {
where += ` AND c.fecha_emision >= $${idx++}::date`;
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) >= $${idx++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
where += ` AND c.fecha_emision <= ($${idx++}::date + interval '1 day')`;
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) <= ($${idx++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.regimen) {
@@ -78,13 +94,24 @@ export async function getCfdisConConciliacion(
const { rows } = await pool.query(`
SELECT
c.id, c.uuid, c.type,
c.serie, c.folio,
c.fecha_emision as "fechaEmision",
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
c.total, c.total_mxn as "totalMxn",
c.subtotal, c.descuento,
c.moneda, c.tipo_cambio as "tipoCambio",
c.tipo_comprobante as "tipoComprobante",
c.monto_pago_mxn as "montoPagoMxn",
c.metodo_pago as "metodoPago",
c.forma_pago as "formaPago",
c.uso_cfdi as "usoCfdi",
c.status,
c.fecha_cert_sat as "fechaCertSat",
c.fecha_pago_p as "fechaPagoP",
c.iva_traslado as "ivaTraslado",
c.iva_retencion as "ivaRetencion",
c.isr_retencion as "isrRetencion",
c.conciliado,
c.id_conciliacion as "idConciliacion",
con.id as "conId",
@@ -95,13 +122,15 @@ export async function getCfdisConConciliacion(
LEFT JOIN conciliaciones con ON con.id_cfdi = c.id
LEFT JOIN bancos b ON b.id = con.id_banco
${where}
ORDER BY c.fecha_emision DESC
ORDER BY COALESCE(c.fecha_pago_p, c.fecha_emision) DESC
`, params);
return rows.map((r: any) => ({
id: r.id,
uuid: r.uuid,
type: r.type,
serie: r.serie,
folio: r.folio,
fechaEmision: r.fechaEmision,
rfcEmisor: r.rfcEmisor,
nombreEmisor: r.nombreEmisor,
@@ -109,6 +138,10 @@ export async function getCfdisConConciliacion(
nombreReceptor: r.nombreReceptor,
total: Number(r.total),
totalMxn: Number(r.totalMxn),
subtotal: Number(r.subtotal || 0),
descuento: Number(r.descuento || 0),
moneda: r.moneda || 'MXN',
tipoCambio: Number(r.tipoCambio || 1),
tipoComprobante: r.tipoComprobante,
montoPagoMxn: Number(r.montoPagoMxn || 0),
// P usa monto_pago_mxn, PPD conciliada no suma (evitar duplicar con su P), resto usa total_mxn
@@ -116,6 +149,14 @@ export async function getCfdisConConciliacion(
? Number(r.montoPagoMxn || 0)
: (r.metodoPago === 'PPD' && r.conciliado === 'true') ? 0 : Number(r.totalMxn || 0),
metodoPago: r.metodoPago,
formaPago: r.formaPago,
usoCfdi: r.usoCfdi,
status: r.status,
fechaCertSat: r.fechaCertSat,
fechaPagoP: r.fechaPagoP,
ivaTraslado: Number(r.ivaTraslado || 0),
ivaRetencion: Number(r.ivaRetencion || 0),
isrRetencion: Number(r.isrRetencion || 0),
conciliado: r.conciliado,
idConciliacion: r.idConciliacion,
conciliacion: r.conId ? {

View File

@@ -1511,5 +1511,5 @@ export async function retryJob(jobId: string): Promise<string> {
throw new Error('Solo se pueden reintentar jobs fallidos');
}
return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo);
return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo, job.contribuyenteId ?? undefined);
}

View File

@@ -113,7 +113,7 @@ export default function ConciliacionPage() {
exportToExcel(
cfdis.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: new Date(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: getMonto(c),
_estado: c.conciliado === 'true' ? 'Conciliado' : 'Pendiente',
_fechaPago: c.conciliacion?.fechaDePago || '',
@@ -251,7 +251,7 @@ export default function ConciliacionPage() {
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
@@ -347,7 +347,7 @@ export default function ConciliacionPage() {
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">

View File

@@ -129,7 +129,11 @@ export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
{cfdi.serie && <span className="text-blue-300">{cfdi.serie}-</span>}
{cfdi.folio || 'S/N'}
</div>
<p className="text-blue-200 text-sm mt-1">{formatDate(cfdi.fechaEmision)}</p>
<p className="text-blue-200 text-sm mt-1">
{cfdi.tipoComprobante === 'P' && cfdi.fechaPagoP
? `Pago: ${formatDate(cfdi.fechaPagoP)}`
: formatDate(cfdi.fechaEmision)}
</p>
</div>
</div>
</div>

View File

@@ -4,17 +4,31 @@ export interface ConciliacionCfdi {
id: number;
uuid: string;
type: string;
serie: string | null;
folio: string | null;
fechaEmision: string;
fechaPagoP: string | null;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
total: number;
totalMxn: number;
subtotal: number;
descuento: number;
moneda: string;
tipoCambio: number;
tipoComprobante: string | null;
montoPagoMxn: number;
montoMxn: number;
metodoPago: string | null;
formaPago: string | null;
usoCfdi: string | null;
status: string | null;
fechaCertSat: string | null;
ivaTraslado: number;
ivaRetencion: number;
isrRetencion: number;
conciliado: string | null;
idConciliacion: number | null;
conciliacion: {

View File

@@ -142,6 +142,33 @@ WHERE contribuyente_id = '96f98a42-5f27-4f27-acf6-61822dea666c';
---
## 6. Fix: CFDIs sin `contribuyente_id` en sincronizaciones SAT
**Problema:** Todos los CFDIs importados por SAT sync tenían `contribuyente_id = NULL`, aunque la columna ya existía. Esto causaba que no aparecieran facturas para conciliar ni en otros módulos que filtran por contribuyente.
**Causa raíz:** El cron job `sat-sync.job.ts` llamaba a `startSync(tenantId, syncType)` **sin pasar `contribuyenteId`**. Los jobs se creaban con `contribuyenteId = null`, y `saveCfdis()` insertaba los CFDIs con `contribuyente_id = null`.
**Fix aplicado:**
1. **`syncTenant()` (cron diario 3 AM)** — Ahora obtiene los contribuyentes del tenant desde su BD y ejecuta `startSync()` para cada uno pasando `contribuyenteId`. Si no hay contribuyentes, sincroniza a nivel tenant (legacy).
2. **`incrementalSyncTenant()` (cron incremental 11h/15h/19h)** — Mismo fix.
3. **`retryJob()` (reintento manual)** — Ahora pasa `job.contribuyenteId` al reintentar.
4. **Backfill de datos** — Se actualizaron los `contribuyente_id` de los CFDIs existentes para todos los tenants:
- Alexa Torres: 383 CFDIs
- Horux 360: 67 CFDIs
- Miguel Estrada: 84,429 CFDIs
- Aarón Ahumada: 2,290 CFDIs
- Humberto Torres: 33 CFDIs
**Archivos:**
- `apps/api/src/jobs/sat-sync.job.ts`
- `apps/api/src/services/sat/sat.service.ts`
---
## Archivos modificados
### Backend (`apps/api/`)
@@ -187,8 +214,126 @@ WHERE contribuyente_id = '96f98a42-5f27-4f27-acf6-61822dea666c';
---
## 7. Fix: Visor de CFDI en conciliación mostraba todo como "Cancelado" y faltaban datos
**Problema:** Al abrir cualquier CFDI desde el módulo de conciliación, el visor mostraba:
- Estatus: **CANCELADO** (aunque el CFDI estuviera vigente)
- Forma de pago: **-** (vacío)
- Serie/Folio: **S/N**
- Uso CFDI: no aparecía
- Totales desglosados (subtotal, descuento, impuestos): todos en 0
**Causa raíz:** El servicio `conciliacion.service.ts` solo seleccionaba un subconjunto mínimo de campos de la tabla `cfdis`. No incluía `status`, `forma_pago`, `serie`, `folio`, `uso_cfdi`, `subtotal`, `descuento`, `iva_traslado`, `iva_retencion`, `isr_retencion`, `moneda`, `tipo_cambio`, ni `fecha_cert_sat`.
Como `status` llegaba `undefined` al componente `CfdiInvoice`, la condición:
```tsx
cfdi.status === 'Vigente' || cfdi.status === '1' ? 'VIGENTE' : 'CANCELADO'
```
Siempre caía en el `else` mostrando CANCELADO.
**Fix aplicado en `apps/api/src/services/conciliacion.service.ts`:**
- Agregados todos los campos faltantes al `SELECT` SQL
- Agregados a la interfaz `ConciliacionCfdi`
- Agregados al mapeo de resultados con valores por defecto seguros (`|| 0`, `|| 'MXN'`, `|| 1`)
**Campos agregados:**
| Campo | Uso en visor |
|---|---|
| `status` | Badge VIGENTE / CANCELADO |
| `formaPago` | Datos del comprobante |
| `serie`, `folio` | Encabezado (serie-folio) |
| `usoCfdi` | Panel del receptor |
| `subtotal`, `descuento` | Totales |
| `ivaTraslado`, `ivaRetencion`, `isrRetencion` | Desglose de impuestos |
| `moneda`, `tipoCambio` | Moneda y tipo de cambio |
| `fechaCertSat` | Timbre fiscal digital |
---
## 8. Fix: Complementos de pago (tipo P) en conciliación usan fecha de emisión en lugar de fecha de pago
**Problema:** Los complementos de pago emitidos por Husberto en abril no aparecían en la conciliación de "Emitidas" de abril. Estaban en mayo. Ejemplo:
- Factura PPD: 2026-04-22 a TPA210222462 por $167,140.97
- Complemento de pago: emitido 2026-05-03, pero el pago fue el **2026-04-30**
El usuario esperaba verlo en abril porque el pago ocurrió en abril, pero el sistema filtraba por `fecha_emision` (mayo).
**Causa raíz:** El servicio de conciliación filtraba y ordenaba siempre por `fecha_emision`. Para los complementos de pago (tipo P), la fecha relevante es `fecha_pago_p` (fecha del pago documentado), no la fecha de emisión del CFDI.
**Fix aplicado:**
1. **Backend (`apps/api/src/services/conciliacion.service.ts`):**
- Filtros de fecha: `c.fecha_emision``COALESCE(c.fecha_pago_p, c.fecha_emision)`
- ORDER BY: `c.fecha_emision DESC``COALESCE(c.fecha_pago_p, c.fecha_emision) DESC`
- SELECT: agregado `c.fecha_pago_p as "fechaPagoP"`
- Interfaz y mapeo: agregado `fechaPagoP`
2. **Frontend (`apps/web/app/(dashboard)/conciliacion/page.tsx`):**
- Tabla "Por conciliar": `{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}`
- Tabla "Conciliadas": mismo cambio
- Export Excel: mismo cambio
3. **Frontend (`apps/web/lib/api/conciliacion.ts`):**
- Interfaz `ConciliacionCfdi`: agregados todos los campos faltantes que ya existen en el backend (`serie`, `folio`, `fechaPagoP`, `subtotal`, `descuento`, `moneda`, `tipoCambio`, `formaPago`, `usoCfdi`, `status`, `fechaCertSat`, `ivaTraslado`, `ivaRetencion`, `isrRetencion`)
4. **Visor (`apps/web/components/cfdi/cfdi-invoice.tsx`):**
- Para tipo P con `fechaPagoP`, muestra "Pago: {fecha}" en lugar de la fecha de emisión
**Resultado:** Los complementos de pago ahora aparecen en el período donde ocurrió el pago real, no cuando se emitió el CFDI.
---
## Archivos modificados (actualizado)
### Backend (`apps/api/`)
| Archivo | Cambio |
|---|---|
| `.env` | Fix typo `MP_ACCESS_TOKEN` |
| `src/services/payment/mercadopago.service.ts` | Fix validación firma webhook |
| `src/services/payment/invoicing.service.ts` | Notificación email + exports |
| `src/services/email/email.service.ts` | `sendPrimerPagoFacturar()` |
| `src/services/email/templates/primer-pago-facturar.ts` | **Nuevo** template |
| `src/controllers/facturacion.controller.ts` | `getPagosSinFactura()` + `emitirFacturaPago()` + fix `searchRfcs()` + fix `searchConceptos()` |
| `src/routes/facturacion.routes.ts` | Rutas `/pagos-sin-factura` + `/emitir-factura-pago/:paymentId` |
| `src/jobs/sat-sync.job.ts` | Fix: pasa `contribuyenteId` en cron diario e incremental |
| `src/services/sat/sat.service.ts` | Fix: `retryJob()` pasa `contribuyenteId` + `saveCfdis()` usa `contribuyente_id` |
| `src/services/conciliacion.service.ts` | Fix: agrega campos faltantes (`status`, `formaPago`, impuestos, etc.) |
### Frontend (`apps/web/`)
| Archivo | Cambio |
|---|---|
| `lib/api/facturacion.ts` | `getPagosSinFactura()` + `emitirFacturaPago()` |
| `lib/hooks/use-pagos-sin-factura.ts` | **Nuevo** hooks |
| `app/(dashboard)/admin/facturas-pendientes/page.tsx` | **Nuevo** página admin |
| `app/(dashboard)/clientes/page.tsx` | KPI "Facturas pendientes" |
| `components/layouts/sidebar.tsx` | Removido "Facturas Pendientes" del menú admin |
| `components/layouts/sidebar-floating.tsx` | Removido "Facturas Pendientes" del menú admin |
| `components/layouts/sidebar-compact.tsx` | Removido "Facturas Pendientes" del menú admin |
| `components/layouts/topnav.tsx` | Removido "Facturas Pendientes" del menú admin |
---
## Configuración requerida en MercadoPago Dashboard
- **Aplicación:** Horux360 (ID: `5319386258998241`)
- **Webhook URL:** `https://horuxfin.com/api/webhooks/mercadopago`
- **Tópicos:** `payment`, `subscription_preapproval`
---
## Datos de organizaciones Facturapi
| Org | RFC | Uso |
|---|---|---|
| `69f23a5a242e0af47a41fa0d` | HTS240708LJA | Horux 360 (emisor principal) — ✅ Activa |
| `69ff900f48058f06ef1234c0` | — | Org fantasma (eliminada de BD) — ❌ Obsoleta |
| `69ff8fabc2053c5568d799c5` | XIA190128J61 | Org creada accidentalmente durante diagnóstico — ❌ Obsoleta |
---
## Notas técnicas
- La encriptación de API keys usa AES-256-GCM con clave derivada de `FIEL_ENCRYPTION_KEY` (SHA-256)
- El endpoint `POST /emitir-factura-pago/:paymentId` requiere rol `platform_admin`
- La regla "primer pago no se factura automáticamente" sigue vigente; los subsecuentes sí son automáticos
- Los CFDIs importados por SAT sync ahora se asocian correctamente al `contribuyente_id` correspondiente