From ee9c76612e2257417d5be634285b820cb2b4418b Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Tue, 28 Apr 2026 15:22:24 +0000 Subject: [PATCH] feat(facturacion): seccion CFDIs Relacionados para tipos I y E --- .../src/controllers/facturacion.controller.ts | 14 +- .../contribuyente-facturapi.service.ts | 3 +- apps/api/src/services/facturapi.service.ts | 6 +- apps/web/app/(dashboard)/facturacion/page.tsx | 181 ++++++++++++++---- apps/web/lib/api/facturacion.ts | 1 + 5 files changed, 160 insertions(+), 45 deletions(-) diff --git a/apps/api/src/controllers/facturacion.controller.ts b/apps/api/src/controllers/facturacion.controller.ts index 4c3220a..a694379 100644 --- a/apps/api/src/controllers/facturacion.controller.ts +++ b/apps/api/src/controllers/facturacion.controller.ts @@ -182,6 +182,13 @@ export async function emitir(req: Request, res: Response, next: NextFunction) { [parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null], ); + // Extraer relaciones para persistencia en BD + const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || []; + const cfdiTipoRelacion = relatedDocs.length > 0 ? relatedDocs[0].relationship : null; + const cfdisRelacionados = relatedDocs.length > 0 + ? relatedDocs.flatMap(r => r.uuids).join('|') + : null; + await pool.query(` INSERT INTO cfdis ( year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat, @@ -192,7 +199,8 @@ export async function emitir(req: Request, res: Response, next: NextFunction) { iva_traslado, iva_traslado_mxn, iva_retencion, iva_retencion_mxn, source, facturapi_id, - contribuyente_id, xml_original + contribuyente_id, xml_original, + cfdi_tipo_relacion, cfdis_relacionados ) VALUES ( $1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7, $8, $9, $10, $11, @@ -202,7 +210,8 @@ export async function emitir(req: Request, res: Response, next: NextFunction) { $23, $23, $24, $24, 'facturapi', $25, - $26, $27 + $26, $27, + $28, $29 ) `, [ year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat, @@ -214,6 +223,7 @@ export async function emitir(req: Request, res: Response, next: NextFunction) { parsed.ivaRetencion, invoice.id, contribuyenteId ?? null, xmlString, + cfdiTipoRelacion, cfdisRelacionados, ]); // Enviar por email si el receptor tiene email — ruteado a la org correcta diff --git a/apps/api/src/services/contribuyente-facturapi.service.ts b/apps/api/src/services/contribuyente-facturapi.service.ts index f79ad6f..61cdb16 100644 --- a/apps/api/src/services/contribuyente-facturapi.service.ts +++ b/apps/api/src/services/contribuyente-facturapi.service.ts @@ -308,10 +308,11 @@ export async function createInvoiceContribuyente( if (data.series) invoicePayload.series = data.series; if (data.folioNumber) invoicePayload.folio_number = data.folioNumber; + // Documentos relacionados (Ingreso / Egreso) if (data.relatedDocuments?.length) { invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({ relationship: r.relationship, - documents: [r.uuid], + documents: r.uuids || [r.uuid], })); } diff --git a/apps/api/src/services/facturapi.service.ts b/apps/api/src/services/facturapi.service.ts index 0e71c36..6151c92 100644 --- a/apps/api/src/services/facturapi.service.ts +++ b/apps/api/src/services/facturapi.service.ts @@ -247,7 +247,7 @@ export interface FacturapiInvoiceData { series?: string; folioNumber?: number; conditions?: string; - relatedDocuments?: Array<{ uuid: string; relationship: string }>; + relatedDocuments?: Array<{ relationship: string; uuids: string[] }>; /** * Régimen fiscal del emisor (override del default de la organización). * Requerido cuando el contribuyente tiene múltiples régimenes y Facturapi @@ -308,11 +308,11 @@ export async function createInvoice( if (data.series) invoiceData.series = data.series; if (data.folioNumber) invoiceData.folio_number = data.folioNumber; - // Documentos relacionados (Egreso) + // Documentos relacionados (Ingreso / Egreso) if (data.relatedDocuments?.length) { invoiceData.related_documents = data.relatedDocuments.map(r => ({ relationship: r.relationship, - documents: [r.uuid], + documents: r.uuids, })); } diff --git a/apps/web/app/(dashboard)/facturacion/page.tsx b/apps/web/app/(dashboard)/facturacion/page.tsx index c65511a..04dfb4e 100644 --- a/apps/web/app/(dashboard)/facturacion/page.tsx +++ b/apps/web/app/(dashboard)/facturacion/page.tsx @@ -15,7 +15,7 @@ import { searchClaveProdServ } from '@/lib/api/catalogos'; import { apiClient } from '@/lib/api/client'; import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, ConceptoPrevio } from '@/lib/api/facturacion'; import { searchRfcs, getCfdisPpd, searchConceptos } from '@/lib/api/facturacion'; -import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle } from 'lucide-react'; +import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle, Link2 } from 'lucide-react'; import { cn } from '@horux/shared-ui'; interface TaxLine { @@ -25,6 +25,29 @@ interface TaxLine { factor: string; // Tasa, Cuota, Exento } +interface RelatedDocForm { + relationship: string; + uuids: string[]; +} + +const RELACION_OPTIONS: Record = { + I: [ + { value: '04', label: '04 - Sustitución de CFDI previos' }, + { value: '05', label: '05 - Traslados de mercancias facturados previamente' }, + { value: '06', label: '06 - Factura generada por traslados previos' }, + { value: '07', label: '07 - CFDI por aplicación de anticipo' }, + { value: '08', label: '08 - Factura generada por pagos en parcialidades' }, + { value: '09', label: '09 - Factura generada por pagos diferidos' }, + ], + E: [ + { value: '01', label: '01 - Nota de crédito de documentos relacionados' }, + { value: '02', label: '02 - Nota de débito de documentos relacionados' }, + { value: '03', label: '03 - Devolución de mercancía sobre facturas o traslados previos' }, + { value: '04', label: '04 - Sustitución de los CFDI previos' }, + { value: '07', label: '07 - CFDI por aplicación de anticipo' }, + ], +}; + interface ConceptoForm { description: string; productKey: string; @@ -188,7 +211,7 @@ const TIPO_CONFIG: Record = { - I: { label: 'Ingreso', needsConceptos: true, needsPaymentComplement: false, needsRelated: false, defaultUso: 'G03', defaultFormaPago: '99', defaultMetodoPago: 'PUE' }, + I: { label: 'Ingreso', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G03', defaultFormaPago: '99', defaultMetodoPago: 'PUE' }, E: { label: 'Egreso (Nota de Crédito)', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G02', defaultFormaPago: '99', defaultMetodoPago: 'PUE' }, P: { label: 'Pago', needsConceptos: false, needsPaymentComplement: true, needsRelated: false, defaultUso: 'CP01', defaultFormaPago: '99', defaultMetodoPago: 'PPD' }, T: { label: 'Traslado', needsConceptos: true, needsPaymentComplement: false, needsRelated: false, defaultUso: 'S01', defaultFormaPago: '99', defaultMetodoPago: 'PUE' }, @@ -306,9 +329,8 @@ export default function FacturacionPage() { // Conceptos const [conceptos, setConceptos] = useState([{ ...emptyConcepto }]); - // Documento relacionado (Egreso) - const [relatedUuid, setRelatedUuid] = useState(''); - const [relatedRelationship, setRelatedRelationship] = useState('01'); + // CFDIs relacionados (Ingreso / Egreso) + const [relatedDocs, setRelatedDocs] = useState([]); // Complemento de pago const [pagoFecha, setPagoFecha] = useState(new Date().toISOString().slice(0, 10)); @@ -344,8 +366,7 @@ export default function FacturacionPage() { setFolio(''); setCondiciones(''); setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]); - setRelatedUuid(''); - setRelatedRelationship('01'); + setRelatedDocs([]); setPagoUuid(''); setPagoMonto(0); setPagoParcialidad(1); @@ -635,8 +656,10 @@ export default function FacturacionPage() { })); } - if (config.needsRelated && relatedUuid) { - data.relatedDocuments = [{ uuid: relatedUuid, relationship: relatedRelationship }]; + if (config.needsRelated && relatedDocs.length > 0) { + data.relatedDocuments = relatedDocs + .filter(r => r.uuids.length > 0) + .map(r => ({ relationship: r.relationship, uuids: r.uuids.filter(u => u.trim() !== '') })); } if (config.needsPaymentComplement) { @@ -1118,36 +1141,6 @@ export default function FacturacionPage() { - {/* Documento Relacionado (Egreso) */} - {config.needsRelated && ( - - - Documento Relacionado - UUID del CFDI de ingreso al que aplica esta nota de crédito - - -
-
- - setRelatedUuid(e.target.value.toUpperCase())} placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" required /> -
-
- - -
-
-
-
- )} - {/* Complemento de Pago */} {config.needsPaymentComplement && ( @@ -1438,6 +1431,116 @@ export default function FacturacionPage() { )} + {/* CFDIs Relacionados */} + {config.needsRelated && ( + + +
+
+ CFDIs Relacionados + Relaciona esta factura con otros comprobantes previos +
+ +
+
+ + {relatedDocs.length === 0 && ( +

Sin relaciones. Agrega una si esta factura está vinculada a otros CFDI.

+ )} + {relatedDocs.map((rel, rIdx) => ( +
+
+
+ + Relación {rIdx + 1} +
+ +
+
+
+ + +
+
+
+ + {rel.uuids.map((uuid, uIdx) => ( +
+ { + const updated = [...relatedDocs]; + updated[rIdx].uuids[uIdx] = e.target.value.toUpperCase(); + setRelatedDocs(updated); + }} + placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + className="font-mono text-sm" + /> + +
+ ))} + +
+
+ ))} +
+
+ )} + {/* Resumen y Emitir */} diff --git a/apps/web/lib/api/facturacion.ts b/apps/web/lib/api/facturacion.ts index 0333722..206932f 100644 --- a/apps/web/lib/api/facturacion.ts +++ b/apps/web/lib/api/facturacion.ts @@ -69,6 +69,7 @@ export interface InvoiceData { series?: string; folioNumber?: number; conditions?: string; + relatedDocuments?: Array<{ relationship: string; uuids: string[] }>; } export interface InvoiceResult {