Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,789 @@
import type { Request, Response, NextFunction } from 'express';
import type { Pool } from 'pg';
import { z } from 'zod';
import * as facturapiService from '../services/facturapi.service.js';
import {
createInvoiceContribuyente,
cancelInvoiceContribuyente,
downloadPdfContribuyente,
downloadXmlContribuyente,
sendInvoiceByEmailContribuyente,
} from '../services/contribuyente-facturapi.service.js';
import { parseXml } from '../services/sat/sat-parser.service.js';
import * as tenantsService from '../services/tenants.service.js';
import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js';
import { hasPlatformRole } from '../utils/platform-admin.js';
import { auditFromReq } from '../utils/audit.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
/**
* Detecta si un mensaje de error del SAT (propagado por Facturapi) indica
* que el CSD aún no está en la Lista de Contribuyentes Obligados (LCO).
* El SAT tarda 24-72h en propagar un CSD nuevo; durante esa ventana todo
* intento de emisión falla. Cuando se detecta este patrón se marca la
* org con `last_lco_rejection_at` para que el frontend muestre un banner.
*/
function isLcoRejection(errorMessage: string): boolean {
if (!errorMessage) return false;
const msg = errorMessage.toLowerCase();
return (
/no se encontr.*rfc.*lco/.test(msg) ||
/rfc.*no.*registrado.*lco/.test(msg) ||
/lista.*contribuyentes.*obligados/.test(msg) ||
/csd.*no.*registrad/.test(msg) ||
msg.includes('lco')
);
}
/**
* Registra el timestamp del rechazo LCO en la fila correspondiente de
* `facturapi_orgs`. Fire-and-forget: un fallo aquí no bloquea la
* propagación del error al frontend.
*/
async function markLcoRejection(
pool: import('pg').Pool,
contribuyenteId: string | undefined,
): Promise<void> {
try {
if (contribuyenteId) {
await pool.query(
`UPDATE facturapi_orgs SET last_lco_rejection_at = NOW() WHERE contribuyente_id = $1`,
[contribuyenteId],
);
}
// Nota: Horux360 single-tenant usaría `tenants.facturapi_org_id` en
// BD central; en el fork multi-contribuyente solo marcamos la fila
// por-contribuyente. Si el user emite desde el org del tenant (sin
// contribuyenteId), el banner no aplicaría aquí.
} catch (e: any) {
console.error('[facturacion.markLcoRejection] falló UPDATE:', e?.message || e);
}
}
// ── Organización ──
export async function getOrgStatus(req: Request, res: Response, next: NextFunction) {
try {
const status = await facturapiService.getOrganizationStatus(effectiveTenantId(req));
res.json(status);
} catch (error) { next(error); }
}
export async function createOrg(req: Request, res: Response, next: NextFunction) {
try {
const result = await facturapiService.createOrganization(effectiveTenantId(req));
res.status(201).json(result);
} catch (error) { next(error); }
}
// ── CSD ──
export async function uploadCsd(req: Request, res: Response, next: NextFunction) {
try {
const { cerFile, keyFile, password } = req.body;
if (!cerFile || !keyFile || !password) {
return res.status(400).json({ message: 'cerFile, keyFile y password son requeridos' });
}
const result = await facturapiService.uploadCsd(effectiveTenantId(req), cerFile, keyFile, password);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
// ── Emisión ──
export async function emitir(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = effectiveTenantId(req);
const contribuyenteId = req.body.contribuyenteId as string | undefined;
// ── Validar CFDIs relacionados antes de consumir timbre ──
// En Live, SAT rechaza si el UUID relacionado no existe, está cancelado,
// o el rfc_receptor no coincide con el customer.taxId del CFDI nuevo.
// Catch temprano con error legible en vez de un 500 oscuro de Facturapi.
const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || [];
const customerRfc = req.body.customer?.taxId?.toUpperCase()?.trim();
if (relatedDocs.length > 0 && customerRfc && req.tenantPool) {
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 rel = rows[0];
if (rel.status === 'Cancelado' || rel.status === '0') {
throw new AppError(400, `El CFDI relacionado con UUID ${uuid} está cancelado.`);
}
const rfcReceptorRel = (rel.rfc_receptor || '').toUpperCase().trim();
if (rfcReceptorRel !== 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: ${rfcReceptorRel}.`,
);
}
}
}
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
// Emitir factura en Facturapi
// Si hay contribuyenteId, usar la org Facturapi del contribuyente (tenant BD).
// Si no, usar la org del tenant (BD central).
let invoice;
try {
if (contribuyenteId) {
invoice = await createInvoiceContribuyente(req.tenantPool!, contribuyenteId, req.body);
} else {
invoice = await facturapiService.createInvoice(tenantId, req.body);
}
} catch (err: any) {
// SAT nunca selló → revertir el timbre reservado (fire-and-forget; no bloquear la respuesta
// de error si el refund falla, solo loggear la inconsistencia)
facturapiService.refundTimbre(tenantId, consumedTimbre).catch(refundErr => {
console.error('[facturacion.emitir] Falló refund de timbre tras rechazo Facturapi:', {
tenantId,
consumedTimbre,
refundError: refundErr?.message || String(refundErr),
});
});
// Loggea el payload que causó el rechazo para diagnóstico server-side
console.error('[facturacion.emitir] Rechazo al crear factura:', {
tenantId,
contribuyenteId: contribuyenteId || null,
type: req.body?.type,
items: req.body?.items?.map((it: any) => ({
description: it.description,
taxes: it.taxes,
})),
error: err?.message || String(err),
});
// Detectar rechazo por CSD aún no propagado a la LCO y marcar la org
// para que el frontend muestre banner informativo durante 24h.
if (isLcoRejection(err?.message || '')) {
await markLcoRejection(req.tenantPool!, contribuyenteId);
}
// Propaga el mensaje real (Facturapi suele explicar la validación)
throw new AppError(400, err?.message || 'Error al emitir factura');
}
// Guardar en tabla cfdis del tenant.
// El response de `invoices.create` de Facturapi NO incluye `issuer`/`subtotal`/`taxes`
// como campos top-level (usa `issuer_info` y los impuestos viven dentro de `items[*].product.taxes`).
// La forma más fiable y consistente con el sync SAT es descargar el XML timbrado y
// reutilizar el mismo parser que ya procesa los CFDIs descargados del SAT.
const pool = req.tenantPool!;
const xmlBuffer = contribuyenteId
? await downloadXmlContribuyente(pool, contribuyenteId, invoice.id)
: await facturapiService.downloadXml(tenantId, invoice.id);
const xmlString = xmlBuffer.toString('utf-8');
const parsed = parseXml(xmlString, 'emitidos');
if (!parsed) {
throw new AppError(500, `Factura ${invoice.uuid} emitida en Facturapi pero el XML no pudo parsearse`);
}
const fecha = parsed.fechaEmision;
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
// Upsert RFCs desde datos del XML (fuente autoritativa — igual al sync SAT)
const { rows: [emisorRow] } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
RETURNING id`,
[parsed.rfcEmisor, parsed.nombreEmisor || null, parsed.regimenFiscalEmisor || null],
);
const { rows: [receptorRow] } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal, codigo_postal) VALUES ($1, $2, $3, $4)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END,
codigo_postal = CASE WHEN $4 IS NOT NULL AND $4 != '' THEN $4 ELSE rfcs.codigo_postal END
RETURNING id`,
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null],
);
// Para CFDIs tipo P (complemento de pago) parseamos `fechaPagoP`. SAT
// permite múltiples pagos por complemento — el parser concatena las fechas
// con '|'; aquí tomamos la primera (suficiente para el cálculo fiscal,
// donde fecha_pago_p drives el período de devengo).
const fechaPagoP = parsed.fechaPagoP
? new Date(String(parsed.fechaPagoP).split('|')[0])
: null;
await pool.query(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat,
rfc_emisor_id, rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
subtotal, subtotal_mxn, total, total_mxn,
moneda, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
iva_traslado, iva_traslado_mxn,
iva_retencion, iva_retencion_mxn,
monto_pago, monto_pago_mxn,
fecha_pago_p,
iva_traslado_pago, iva_traslado_pago_mxn,
iva_retencion_pago, iva_retencion_pago_mxn,
ieps_traslado_pago, ieps_traslado_pago_mxn,
source, facturapi_id,
contribuyente_id, xml_original
) VALUES (
$1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7,
$8, $9, $10, $11,
$12, $13, $14, $15,
$16, $16, $17, $17,
$18, $19, $20, $21, $22,
$23, $23,
$24, $24,
$25, $25,
$26,
$27, $27,
$28, $28,
$29, $29,
'facturapi', $30,
$31, $32
)
`, [
year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat,
emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor, parsed.regimenFiscalEmisor,
receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor, parsed.regimenFiscalReceptor,
parsed.subtotal, parsed.total,
parsed.moneda, parsed.tipoComprobante, parsed.metodoPago, parsed.formaPago, parsed.usoCfdi,
parsed.ivaTraslado,
parsed.ivaRetencion,
parsed.montoPago,
fechaPagoP,
parsed.ivaTrasladoPago,
parsed.ivaRetencionPago,
parsed.iepsTrasladoPago,
invoice.id,
contribuyenteId ?? null, xmlString,
]);
// Enviar por email si el receptor tiene email — ruteado a la org correcta
const customerEmail = req.body.customer?.email;
if (customerEmail) {
const sendPromise = contribuyenteId
? sendInvoiceByEmailContribuyente(req.tenantPool!, contribuyenteId, invoice.id, customerEmail)
: facturapiService.sendInvoiceByEmail(tenantId, invoice.id, customerEmail);
sendPromise.catch(err => console.error('[Facturapi] Error enviando email:', err.message));
}
res.status(201).json({
id: invoice.id,
uuid: invoice.uuid,
total: invoice.total,
status: invoice.status,
});
} catch (error: any) {
// Los errores de emisión ya hacen refund dentro del inner catch.
// Aquí solo propagamos — incluye errores del INSERT post-emisión (CFDI ya sellado,
// no refund) y errores de validación de timbre (ocurrieron antes del consume).
next(error);
}
}
// Estado LCO: si hubo un rechazo del SAT por CSD no propagado en las últimas 24h,
// el frontend muestra un banner informativo en la pantalla de emisión.
export async function getLcoStatus(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) {
return res.json({ hasRecentLcoRejection: false, rejectedAt: null });
}
const { rows } = await req.tenantPool!.query<{ last_lco_rejection_at: Date | null }>(
`SELECT last_lco_rejection_at FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
[contribuyenteId],
);
const rejectedAt = rows[0]?.last_lco_rejection_at || null;
const hasRecentLcoRejection =
rejectedAt !== null && Date.now() - new Date(rejectedAt).getTime() < 24 * 60 * 60 * 1000;
res.json({ hasRecentLcoRejection, rejectedAt });
} catch (error) {
next(error);
}
}
// ── Cancelación ──
export async function cancelar(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = effectiveTenantId(req);
const { uuid } = req.params;
const { motive, substitution } = req.body;
const pool = req.tenantPool!;
const { rows } = await pool.query(
`SELECT facturapi_id, contribuyente_id FROM cfdis WHERE uuid = $1 AND source = 'facturapi'`,
[uuid]
);
if (rows.length === 0 || !rows[0].facturapi_id) {
return res.status(404).json({ message: 'CFDI no encontrado o no fue emitido por Facturapi' });
}
const facturapiId = rows[0].facturapi_id;
const cfdiContribuyenteId = rows[0].contribuyente_id as string | null;
const result = cfdiContribuyenteId
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
// Capturamos la fecha del CFDI antes del UPDATE para saber qué mes marcar
// como invalidado (la cancelación afecta las métricas del mes del CFDI,
// no del mes actual).
const { rows: fechas } = await pool.query<{ fecha_emision: Date; fecha_pago_p: Date | null; tipo_comprobante: string }>(
`SELECT fecha_emision, fecha_pago_p, tipo_comprobante FROM cfdis WHERE uuid = $1`,
[uuid],
);
await pool.query(
`UPDATE cfdis SET status = 'Cancelado', fecha_cancelacion = NOW(), actualizado_en = NOW() WHERE uuid = $1`,
[uuid]
);
// Invalidar métricas del mes afectado (usa fecha_pago_p para P, fecha_emision para el resto)
if (cfdiContribuyenteId && fechas[0]) {
const f = fechas[0];
const fechaContable = f.tipo_comprobante === 'P' && f.fecha_pago_p ? f.fecha_pago_p : f.fecha_emision;
const { markForInvalidation } = await import('../services/metricas.service.js');
await markForInvalidation(
pool,
cfdiContribuyenteId,
fechaContable.getFullYear(),
fechaContable.getMonth() + 1,
'CFDI_CANCEL',
).catch(err => console.warn('[Cancelar] markForInvalidation falló:', err?.message || err));
}
res.json({ message: 'CFDI cancelado', result });
} catch (error) { next(error); }
}
// ── Descargas ──
async function resolveCfdiContribuyenteId(
pool: Pool,
facturapiId: string,
): Promise<string | null> {
const { rows } = await pool.query<{ contribuyente_id: string | null }>(
`SELECT contribuyente_id FROM cfdis WHERE facturapi_id = $1 LIMIT 1`,
[facturapiId],
);
return rows[0]?.contribuyente_id ?? null;
}
export async function downloadPdf(req: Request, res: Response, next: NextFunction) {
try {
const id = String(req.params.id);
const pool = req.tenantPool!;
const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id);
const buffer = cfdiContribuyenteId
? await downloadPdfContribuyente(pool, cfdiContribuyenteId, id)
: await facturapiService.downloadPdf(effectiveTenantId(req), id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.pdf`);
res.send(buffer);
} catch (error) { next(error); }
}
export async function downloadXml(req: Request, res: Response, next: NextFunction) {
try {
const id = String(req.params.id);
const pool = req.tenantPool!;
const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id);
const buffer = cfdiContribuyenteId
? await downloadXmlContribuyente(pool, cfdiContribuyenteId, id)
: await facturapiService.downloadXml(effectiveTenantId(req), id);
res.setHeader('Content-Type', 'application/xml');
res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.xml`);
res.send(buffer);
} catch (error) { next(error); }
}
// ── Timbres ──
export async function getTimbres(req: Request, res: Response, next: NextFunction) {
try {
const status = await facturapiService.getTimbreStatus(effectiveTenantId(req));
res.json(status);
} catch (error) { next(error); }
}
// ── Personalización (logo, color) ──
export async function getCustomization(req: Request, res: Response, next: NextFunction) {
try {
const data = await facturapiService.getCustomization(effectiveTenantId(req));
res.json(data || {});
} catch (error) { next(error); }
}
export async function uploadLogo(req: Request, res: Response, next: NextFunction) {
try {
const { logo } = req.body; // base64
if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' });
const result = await facturapiService.uploadLogo(effectiveTenantId(req), logo);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
export async function updateColor(req: Request, res: Response, next: NextFunction) {
try {
const { color } = req.body;
if (!color) return res.status(400).json({ message: 'Color es requerido' });
const result = await facturapiService.updateColor(effectiveTenantId(req), color);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
// ── Datos fiscales del tenant ──
// Schema Zod para preferencias de auto-facturación
const PreferenciasFacturacionSchema = z.object({
factPreferencia: z.enum(['publico_general', 'mis_datos']).optional(),
factUsoCfdi: z.string().min(2).max(5).optional(),
factRegimenPreferido: z.string().max(3).nullable().optional(),
});
export async function getPreferenciasFacturacion(req: Request, res: Response, next: NextFunction) {
try {
const data = await tenantsService.getPreferenciasFacturacion(effectiveTenantId(req));
res.json(data);
} catch (error) { next(error); }
}
export async function updatePreferenciasFacturacion(req: Request, res: Response, next: NextFunction) {
try {
const parsed = PreferenciasFacturacionSchema.parse(req.body);
const data = await tenantsService.updatePreferenciasFacturacion(effectiveTenantId(req), parsed);
res.json(data);
} catch (error: any) {
if (error?.name === 'ZodError') {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
export async function getDatosFiscales(req: Request, res: Response, next: NextFunction) {
try {
const data = await tenantsService.getDatosFiscales(effectiveTenantId(req));
res.json(data || {});
} catch (error) { next(error); }
}
export async function updateDatosFiscales(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
return res.status(403).json({ message: 'Solo el dueño puede actualizar datos fiscales' });
}
const data = await tenantsService.updateDatosFiscales(effectiveTenantId(req), req.body);
res.json(data);
} catch (error) { next(error); }
}
// ── Búsqueda de conceptos previos ──
export async function searchConceptos(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.query.q as string || '').trim();
const tipo = (req.query.tipo as string || 'todos'); // emitidos, recibidos, todos
const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
const pool = req.tenantPool!;
let whereType = '';
if (tipo === 'emitidos') {
whereType = `AND c.type = 'EMITIDO'`;
} else if (tipo === 'recibidos') {
whereType = `AND c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'`;
} else {
whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`;
}
const whereContrib = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId}'` : '';
let whereSearch = '';
const params: any[] = [];
if (q.length >= 2) {
params.push(`%${q}%`);
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`;
}
const { rows } = await pool.query(`
SELECT DISTINCT ON (cc.clave_prod_serv, cc.descripcion)
cc.clave_prod_serv as "claveProdServ",
cc.descripcion,
cc.clave_unidad as "claveUnidad",
cc.unidad,
cc.valor_unitario_mxn as "valorUnitario",
cc.importe_mxn as "importe",
cc.iva_traslado_mxn as "ivaTraslado",
cc.isr_retencion_mxn as "isrRetencion",
cc.iva_retencion_mxn as "ivaRetencion",
c.type as "tipoCfdi",
c.rfc_emisor as "rfcEmisor",
c.nombre_emisor as "nombreEmisor",
c.rfc_receptor as "rfcReceptor",
c.nombre_receptor as "nombreReceptor",
c.fecha_emision as "fechaEmision"
FROM cfdi_conceptos cc
JOIN cfdis c ON cc.cfdi_id = c.id
WHERE c.status NOT IN ('Cancelado', '0')
${whereType}
${whereContrib}
${whereSearch}
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
LIMIT 30
`, params);
res.json(rows);
} catch (error) { next(error); }
}
// ── CFDIs PPD pendientes ──
export async function getCfdisPpdPendientes(req: Request, res: Response, next: NextFunction) {
try {
const rfc = (req.query.rfc as string || '').trim().toUpperCase();
if (rfc.length < 3) return res.json([]);
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
const pool = req.tenantPool!;
// Buscar CFDIs emitidos PPD vigentes para este RFC receptor con saldo > 0.
// Usamos `saldo_pendiente_mxn` denormalizado (utils/saldo.ts §13) que ya
// considera pagos P + NCs no-07 + anticipos aplicados. Es la fuente de
// verdad del sistema — recalcular con subquery solo sobre pagos P
// sobreestima el saldo cuando hay NCs/anticipos.
// En multi-RFC con contribuyente activo, filtra por contribuyente_id —
// solo los PPDs emitidos por el contribuyente activo. Sin contribuyenteId,
// retorna todos los del tenant (compat con flujos sin contribuyente activo).
const params: any[] = [rfc];
let contribFilter = '';
if (contribuyenteId) {
params.push(contribuyenteId);
contribFilter = ` AND c.contribuyente_id = $${params.length}`;
}
const { rows } = await pool.query(`
SELECT
c.uuid, c.serie, c.folio, c.total_mxn as "totalMxn",
c.fecha_emision as "fechaEmision",
c.rfc_receptor as "rfcReceptor",
c.nombre_receptor as "nombreReceptor",
c.iva_traslado_mxn as "ivaTrasladoMxn",
c.saldo_pendiente_mxn as "saldoPendiente"
FROM cfdis c
WHERE c.type = 'EMITIDO'
AND c.metodo_pago = 'PPD'
AND c.tipo_comprobante = 'I'
AND c.status NOT IN ('Cancelado', '0')
AND c.rfc_receptor = $1${contribFilter}
AND COALESCE(c.saldo_pendiente_mxn, 0) > 0
ORDER BY c.fecha_emision DESC
LIMIT 20
`, params);
res.json(rows);
} catch (error) { next(error); }
}
// ── CFDIs relacionables ──
// Devuelve CFDIs emitidos por el contribuyente activo cuyo rfc_receptor
// coincide con el de la nueva factura. Usado por el dropdown de la sección
// "CFDIs Relacionados" en facturación tipo I y E.
//
// Filtros aplicados:
// - contribuyente_id = caller (multi-RFC: solo CFDIs del contribuyente activo)
// - rfc_receptor = rfc del receptor de la factura nueva
// - tipo_comprobante IN ('I','E') — los relacionables habituales
// - status NOT IN ('Cancelado','0') — solo vigentes (SAT rechaza relacionar cancelados)
export async function getCfdisRelacionables(req: Request, res: Response, next: NextFunction) {
try {
const rfcReceptor = (req.query.rfcReceptor as string || '').trim().toUpperCase();
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
if (rfcReceptor.length < 12) return res.json([]);
if (!contribuyenteId) return res.json([]);
const pool = req.tenantPool!;
const { rows } = await pool.query(`
SELECT
uuid,
serie,
folio,
total_mxn AS "totalMxn",
fecha_emision AS "fechaEmision",
tipo_comprobante AS "tipoComprobante",
metodo_pago AS "metodoPago"
FROM cfdis
WHERE contribuyente_id = $1
AND rfc_receptor = $2
AND tipo_comprobante IN ('I', 'E')
AND status NOT IN ('Cancelado', '0')
ORDER BY fecha_emision DESC
LIMIT 50
`, [contribuyenteId, rfcReceptor]);
res.json(rows);
} catch (error) { next(error); }
}
// ── Búsqueda de RFCs ──
export async function searchRfcs(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.query.q as string || '').trim();
if (q.length < 3) return res.json([]);
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
const pool = req.tenantPool!;
// RFC del tenant despacho para excluirlo (no se factura a sí mismo)
const tenantId = effectiveTenantId(req);
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true },
});
const tenantRfc = tenant?.rfc || '';
// En multi-RFC con contribuyente activo, filtrar a contrapartes con las
// que ese contribuyente ha tenido CFDIs (emisor o receptor). Sin
// contribuyenteId, retornar el catálogo completo (compat con flujos
// legacy / admin global sin contribuyente seleccionado).
let rows;
if (contribuyenteId) {
({ rows } = await pool.query(`
SELECT DISTINCT r.id, r.rfc,
r.razon_social as "razonSocial",
r.regimen_fiscal as "regimenFiscal",
r.codigo_postal as "codigoPostal"
FROM rfcs r
WHERE r.rfc != $1
AND (r.rfc ILIKE $2 OR r.razon_social ILIKE $2)
AND EXISTS (
SELECT 1 FROM cfdis c
WHERE c.contribuyente_id = $3
AND (c.rfc_emisor_id = r.id OR c.rfc_receptor_id = r.id)
)
ORDER BY r.razon_social
LIMIT 10
`, [tenantRfc, `%${q}%`, contribuyenteId]));
} else {
({ rows } = await pool.query(`
SELECT id, rfc, razon_social as "razonSocial",
regimen_fiscal as "regimenFiscal",
codigo_postal as "codigoPostal"
FROM rfcs
WHERE rfc != $1
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
ORDER BY razon_social
LIMIT 10
`, [tenantRfc, `%${q}%`]));
}
res.json(rows);
} catch (error) { next(error); }
}
// ── Timbres adicionales: catálogo + compra ──
export async function getPaquetesCatalogo(req: Request, res: Response, next: NextFunction) {
try {
const catalogo = await facturapiService.listPaquetesCatalogo();
res.json(catalogo);
} catch (error) { next(error); }
}
const comprarPaqueteSchema = z.object({
catalogoId: z.number().int().positive(),
});
// Admin global: catálogo completo incluyendo inactivos + edit
export async function getPaquetesCatalogoAdmin(req: Request, res: Response, next: NextFunction) {
try {
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
return res.status(403).json({ message: 'Solo admin global puede ver el catálogo completo' });
}
const catalogo = await facturapiService.listAllPaquetesCatalogo();
res.json(catalogo);
} catch (error) { next(error); }
}
const updatePaqueteSchema = z.object({
precio: z.number().positive().optional(),
active: z.boolean().optional(),
});
export async function updatePaqueteCatalogo(req: Request, res: Response, next: NextFunction) {
try {
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
return res.status(403).json({ message: 'Solo admin global puede editar el catálogo' });
}
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const data = updatePaqueteSchema.parse(req.body);
const before = await facturapiService.listAllPaquetesCatalogo().then(r => r.find(p => p.id === id));
const updated = await facturapiService.updatePaqueteCatalogo({ id, ...data });
auditFromReq(req, 'timbres.catalogo_updated', {
entityType: 'TimbrePaqueteCatalogo',
entityId: String(id),
metadata: {
cantidad: updated.cantidad,
from: { precio: before?.precio, active: before?.active },
to: { precio: updated.precio, active: updated.active },
},
});
res.json(updated);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.includes('precio') || error?.message?.includes('actualizar')) {
return next(new AppError(400, error.message));
}
next(error);
}
}
export async function comprarPaquete(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo'].includes(req.user!.role)) {
return res.status(403).json({ message: 'Solo owner/cfo pueden comprar timbres adicionales' });
}
const { catalogoId } = comprarPaqueteSchema.parse(req.body);
const result = await facturapiService.iniciarCompraPaquete({
tenantId: effectiveTenantId(req),
catalogoId,
callerEmail: req.user!.email,
});
res.json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
// Errores de negocio esperados → 400 con mensaje para el usuario
const msg = error?.message || '';
if (msg.includes('no disponible') || msg.includes('dueño') || msg.includes('email') || msg.includes('MercadoPago')) {
return next(new AppError(400, msg));
}
console.error('[comprarPaquete] Error no esperado:', error);
next(error);
}
}