Files
Horux360/apps/api/src/services/cfdi.service.ts
Consultoria AS c3ce7199af feat: bulk XML upload, period selector, and session persistence
- Add bulk XML CFDI upload support (up to 300MB)
- Add period selector component for month/year navigation
- Fix session persistence on page refresh (Zustand hydration)
- Fix income/expense classification based on tenant RFC
- Fix IVA calculation from XML (correct Impuestos element)
- Add error handling to reportes page
- Support multiple CORS origins
- Update reportes service with proper Decimal/BigInt handling
- Add RFC to tenant view store for proper CFDI classification
- Update README with changelog and new features

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 06:51:53 +00:00

270 lines
9.2 KiB
TypeScript

import { prisma } from '../config/database.js';
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
export async function getCfdis(schema: string, filters: CfdiFilters): Promise<CfdiListResponse> {
const page = filters.page || 1;
const limit = filters.limit || 20;
const offset = (page - 1) * limit;
let whereClause = 'WHERE 1=1';
const params: any[] = [];
let paramIndex = 1;
if (filters.tipo) {
whereClause += ` AND tipo = $${paramIndex++}`;
params.push(filters.tipo);
}
if (filters.estado) {
whereClause += ` AND estado = $${paramIndex++}`;
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
params.push(filters.fechaFin);
}
if (filters.rfc) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.rfc}%`);
}
if (filters.search) {
whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.search}%`);
}
const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(`
SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause}
`, ...params);
const total = Number(countResult[0]?.count || 0);
params.push(limit, offset);
const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
subtotal, descuento, iva, isr_retenido as "isrRetenido",
iva_retenido as "ivaRetenido", total, moneda,
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt"
FROM "${schema}".cfdis
${whereClause}
ORDER BY fecha_emision DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, ...params);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
subtotal, descuento, iva, isr_retenido as "isrRetenido",
iva_retenido as "ivaRetenido", total, moneda,
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt"
FROM "${schema}".cfdis
WHERE id = $1
`, id);
return result[0] || null;
}
export interface CreateCfdiData {
uuidFiscal: string;
tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
serie?: string;
folio?: string;
fechaEmision: string;
fechaTimbrado: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
subtotal: number;
descuento?: number;
iva?: number;
isrRetenido?: number;
ivaRetenido?: number;
total: number;
moneda?: string;
tipoCambio?: number;
metodoPago?: string;
formaPago?: string;
usoCfdi?: string;
estado?: string;
xmlUrl?: string;
pdfUrl?: string;
}
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
// Validate required fields
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
// Parse dates safely - handle YYYY-MM-DD format explicitly
let fechaEmision: Date;
let fechaTimbrado: Date;
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
? `${data.fechaEmision}T12:00:00`
: data.fechaEmision;
fechaEmision = new Date(dateStr);
if (isNaN(fechaEmision.getTime())) {
throw new Error(`Fecha de emisión inválida: ${data.fechaEmision}`);
}
const timbradoStr = data.fechaTimbrado
? (typeof data.fechaTimbrado === 'string' && data.fechaTimbrado.match(/^\d{4}-\d{2}-\d{2}$/)
? `${data.fechaTimbrado}T12:00:00`
: data.fechaTimbrado)
: null;
fechaTimbrado = timbradoStr ? new Date(timbradoStr) : fechaEmision;
if (isNaN(fechaTimbrado.getTime())) {
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
}
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
INSERT INTO "${schema}".cfdis (
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado, xml_url, pdf_url
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
RETURNING
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
subtotal, descuento, iva, isr_retenido as "isrRetenido",
iva_retenido as "ivaRetenido", total, moneda,
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt"
`,
data.uuidFiscal,
data.tipo || 'ingreso',
data.serie || null,
data.folio || null,
fechaEmision,
fechaTimbrado,
data.rfcEmisor,
data.nombreEmisor || 'Sin nombre',
data.rfcReceptor,
data.nombreReceptor || 'Sin nombre',
data.subtotal || 0,
data.descuento || 0,
data.iva || 0,
data.isrRetenido || 0,
data.ivaRetenido || 0,
data.total || 0,
data.moneda || 'MXN',
data.tipoCambio || 1,
data.metodoPago || null,
data.formaPago || null,
data.usoCfdi || null,
data.estado || 'vigente',
data.xmlUrl || null,
data.pdfUrl || null
);
return result[0];
}
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
let count = 0;
const errors: string[] = [];
for (let i = 0; i < cfdis.length; i++) {
const cfdi = cfdis[i];
try {
await createCfdi(schema, cfdi);
count++;
} catch (error: any) {
const errorMsg = error.message || 'Error desconocido';
// Skip duplicates (uuid_fiscal is unique)
if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) {
console.log(`[CFDI ${i + 1}] Duplicado: ${cfdi.uuidFiscal}`);
continue;
}
console.error(`[CFDI ${i + 1}] Error: ${errorMsg}`, { uuid: cfdi.uuidFiscal });
errors.push(`CFDI ${i + 1} (${cfdi.uuidFiscal?.substring(0, 8) || 'sin UUID'}): ${errorMsg}`);
}
}
if (errors.length > 0 && count === 0) {
throw new Error(`No se pudo crear ningun CFDI. Errores: ${errors.slice(0, 3).join('; ')}`);
}
return count;
}
export async function deleteCfdi(schema: string, id: string): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
}
export async function getResumenCfdis(schema: string, año: number, mes: number) {
const result = await prisma.$queryRawUnsafe<[{
total_ingresos: number;
total_egresos: number;
count_ingresos: number;
count_egresos: number;
iva_trasladado: number;
iva_acreditable: number;
}]>(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos,
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as count_ingresos,
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as count_egresos,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable
FROM "${schema}".cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
const r = result[0];
return {
totalIngresos: Number(r?.total_ingresos || 0),
totalEgresos: Number(r?.total_egresos || 0),
countIngresos: Number(r?.count_ingresos || 0),
countEgresos: Number(r?.count_egresos || 0),
ivaTrasladado: Number(r?.iva_trasladado || 0),
ivaAcreditable: Number(r?.iva_acreditable || 0),
};
}