Initial commit - Horux Despachos NL
This commit is contained in:
789
apps/api/src/controllers/facturacion.controller.ts
Normal file
789
apps/api/src/controllers/facturacion.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user