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
This commit is contained in:
Horux Dev
2026-04-29 21:03:41 +00:00
parent 066ba7deda
commit e7dbae1ab7
18 changed files with 1076 additions and 111 deletions

View File

@@ -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,
};

View File

@@ -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(),

View File

@@ -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);
}
}

View File

@@ -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('|')

View File

@@ -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 };

View File

@@ -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<any[]> {
return rows;
}
export async function getAllConceptos(pool: Pool, filters: CfdiConceptoFilters): Promise<CfdiConceptoListResponse> {
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<string | null> {
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<void> {
`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),
};
}

View File

@@ -8,33 +8,58 @@ function getUserClient(): Facturapi {
return new Facturapi(env.FACTURAPI_USER_KEY);
}
async function getOrgApiKey(orgId: string): Promise<string> {
async function getOrgApiKey(pool: Pool, orgId: string): Promise<string> {
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);
}

View File

@@ -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;

View File

@@ -20,7 +20,7 @@ function getUserClient(): Facturapi {
async function getOrgClient(tenantId: string): Promise<Facturapi> {
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<Facturapi> {
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);
}

View File

@@ -353,11 +353,12 @@ export async function getIvaMensual(
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<IvaMensual[]> {
// 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,

View File

@@ -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;