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>
This commit is contained in:
@@ -94,6 +94,147 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user