- searchConceptos: agrega AND c.contribuyente_id = cuando se recibe contribuyenteId
- searchRfcs: restringe el catálogo global de rfcs a aquellos que aparecen en CFDIs del contribuyente (como emisor o receptor)
- Usa parametrización dinámica (3800099{params.length}) para evitar errores de índice
960 lines
37 KiB
TypeScript
960 lines
37 KiB
TypeScript
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,
|
|
getCustomizationContribuyente,
|
|
uploadLogoContribuyente,
|
|
updateColorContribuyente,
|
|
} 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';
|
|
import { recomputarSaldoPendiente } from '../utils/saldo.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}.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Validar fecha de emisión (solo I, E, T) ──
|
|
const tipo = req.body.type || 'I';
|
|
if (tipo !== 'P' && req.body.fechaEmision) {
|
|
const fecha = new Date(req.body.fechaEmision);
|
|
const now = new Date();
|
|
const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000);
|
|
if (isNaN(fecha.getTime()) || fecha > now || fecha < minDate) {
|
|
throw new AppError(400, 'La fecha de emisión debe estar entre 72 horas en el pasado y el momento actual');
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
]);
|
|
|
|
// Inicializar saldo pendiente para I/PPD (igual que el flujo SAT)
|
|
if (parsed.tipoComprobante === 'I' && parsed.metodoPago === 'PPD' && parsed.uuid) {
|
|
await recomputarSaldoPendiente(pool, [parsed.uuid]);
|
|
}
|
|
|
|
// 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, contribuyenteId: bodyContribuyenteId } = 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;
|
|
|
|
// En modelo multi-contribuyente: si el caller envía un contribuyenteId,
|
|
// solo puede cancelar facturas de ESE contribuyente.
|
|
if (bodyContribuyenteId && cfdiContribuyenteId && bodyContribuyenteId !== cfdiContribuyenteId) {
|
|
return res.status(403).json({ message: 'No tienes permiso para cancelar esta factura' });
|
|
}
|
|
|
|
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); }
|
|
}
|
|
|
|
// ── Personalización per-contribuyente ──
|
|
|
|
export async function getCustomizationContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const contribuyenteId = String(req.params.id);
|
|
const data = await getCustomizationContribuyente(req.tenantPool!, contribuyenteId);
|
|
res.json(data || {});
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function uploadLogoContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const contribuyenteId = String(req.params.id);
|
|
const { logo } = req.body;
|
|
if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' });
|
|
const result = await uploadLogoContribuyente(req.tenantPool!, contribuyenteId, logo);
|
|
if (!result.success) return res.status(400).json({ message: result.message });
|
|
res.json(result);
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function updateColorContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const contribuyenteId = String(req.params.id);
|
|
const { color } = req.body;
|
|
if (!color) return res.status(400).json({ message: 'Color es requerido' });
|
|
const result = await updateColorContribuyente(req.tenantPool!, contribuyenteId, 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'))`;
|
|
}
|
|
|
|
let whereSearch = '';
|
|
const params: any[] = [];
|
|
if (q.length >= 2) {
|
|
params.push(`%${q}%`);
|
|
whereSearch = `AND (cc.descripcion ILIKE $${params.length} OR cc.clave_prod_serv ILIKE $${params.length})`;
|
|
}
|
|
|
|
let whereContribuyente = '';
|
|
if (contribuyenteId) {
|
|
params.push(contribuyenteId);
|
|
whereContribuyente = `AND c.contribuyente_id = $${params.length}`;
|
|
}
|
|
|
|
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}
|
|
${whereSearch}
|
|
${whereContribuyente}
|
|
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 || '').replace(/[^a-f0-9-]/gi, '');
|
|
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 || '';
|
|
|
|
const params: any[] = [tenantRfc, `%${q}%`];
|
|
let whereContribuyente = '';
|
|
if (contribuyenteId) {
|
|
params.push(contribuyenteId);
|
|
whereContribuyente = `AND id IN (
|
|
SELECT rfc_receptor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_receptor_id IS NOT NULL
|
|
UNION
|
|
SELECT rfc_emisor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_emisor_id IS NOT NULL
|
|
)`;
|
|
}
|
|
|
|
const { 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)
|
|
${whereContribuyente}
|
|
ORDER BY razon_social
|
|
LIMIT 10
|
|
`, params);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ── Admin global: pagos de suscripción sin factura ──
|
|
|
|
export async function getPagosSinFactura(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 consultar pagos sin factura' });
|
|
}
|
|
|
|
const payments = await prisma.payment.findMany({
|
|
where: {
|
|
status: 'approved',
|
|
facturapiInvoiceId: null,
|
|
kind: 'subscription',
|
|
amount: { gt: 0 },
|
|
},
|
|
include: {
|
|
subscription: { select: { plan: true, frequency: true } },
|
|
tenant: { select: { nombre: true, rfc: true } },
|
|
},
|
|
orderBy: { paidAt: 'desc' },
|
|
});
|
|
|
|
res.json(payments);
|
|
} catch (error) { next(error); }
|
|
}
|
|
|
|
export async function emitirFacturaPago(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 emitir facturas de pago' });
|
|
}
|
|
|
|
const paymentId = String(req.params.paymentId);
|
|
const payment = await prisma.payment.findUnique({
|
|
where: { id: paymentId },
|
|
include: { subscription: true },
|
|
});
|
|
|
|
if (!payment) {
|
|
return next(new AppError(404, 'Pago no encontrado'));
|
|
}
|
|
if (payment.status !== 'approved') {
|
|
return next(new AppError(400, 'Solo pagos aprobados pueden facturarse'));
|
|
}
|
|
if (payment.facturapiInvoiceId) {
|
|
return next(new AppError(400, 'Este pago ya tiene una factura emitida'));
|
|
}
|
|
|
|
// Reutilizar helpers del servicio de facturación
|
|
const { getEmitterTenant, getCustomerFromTenant } = await import('../services/payment/invoicing.service.js');
|
|
|
|
const emitter = await getEmitterTenant();
|
|
const amount = Number(payment.amount);
|
|
const plan = (payment as any).subscription?.plan || 'custom';
|
|
const frequency = (payment as any).subscription?.frequency || 'monthly';
|
|
const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual';
|
|
const description = `Suscripción ${plan} ${descFrecuencia} a Horux Despachos`;
|
|
|
|
const customer = await getCustomerFromTenant(payment.tenantId);
|
|
if (!customer) {
|
|
return next(new AppError(400, 'El tenant no tiene datos fiscales completos. No se puede facturar.'));
|
|
}
|
|
|
|
const tenantPref = await prisma.tenant.findUnique({
|
|
where: { id: payment.tenantId },
|
|
select: { factUsoCfdi: true },
|
|
});
|
|
const usoCfdi = customer ? (tenantPref?.factUsoCfdi || 'G03') : 'S01';
|
|
|
|
const formaPagoMap: Record<string, string> = {
|
|
master: '04', visa: '04', amex: '04',
|
|
debmaster: '28', debvisa: '28',
|
|
account_money: '03', bank_transfer: '03',
|
|
};
|
|
const normalizedMethod = (payment.paymentMethod || '').toLowerCase().replace(/^proration-/, '');
|
|
const formaPago = formaPagoMap[normalizedMethod] || '03';
|
|
|
|
const payload = {
|
|
customer: {
|
|
legalName: customer.legalName,
|
|
taxId: customer.taxId,
|
|
taxSystem: customer.taxSystem,
|
|
email: customer.email,
|
|
zip: customer.zip,
|
|
},
|
|
items: [
|
|
{
|
|
description,
|
|
productKey: '81112502',
|
|
unitKey: 'E48',
|
|
unitName: 'Servicio',
|
|
quantity: 1,
|
|
price: amount,
|
|
taxIncluded: true,
|
|
taxes: [{ type: 'IVA', rate: 0.16, factor: 'Tasa' }],
|
|
},
|
|
],
|
|
use: usoCfdi,
|
|
paymentForm: formaPago,
|
|
paymentMethod: 'PUE',
|
|
currency: 'MXN',
|
|
};
|
|
|
|
const invoice = await facturapiService.createInvoice(emitter.id, payload as any);
|
|
|
|
await prisma.payment.update({
|
|
where: { id: payment.id },
|
|
data: { facturapiInvoiceId: invoice.id },
|
|
});
|
|
|
|
auditFromReq(req, 'invoice.emitted_manual', {
|
|
entityType: 'Payment',
|
|
entityId: payment.id,
|
|
metadata: { facturapiInvoiceId: invoice.id, amount, plan, frequency },
|
|
});
|
|
|
|
res.json({ success: true, invoiceId: invoice.id, paymentId: payment.id });
|
|
} catch (error) { next(error); }
|
|
}
|