'use client'; import { useState, useEffect, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui'; import { useAuthStore } from '@/stores/auth-store'; import { useTenantViewStore } from '@/stores/tenant-view-store'; import { useContribuyenteStore } from '@/stores/contribuyente-store'; import { useFormasPago, useMetodosPago, useUsosCfdi, useMonedas, useClavesUnidad, useEmitirFactura, useTimbres, } from '@/lib/hooks/use-facturacion'; 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, Link2 } from 'lucide-react'; import { cn } from '@horux/shared-ui'; interface TaxLine { category: 'traslado' | 'retencion'; type: string; // IVA, ISR, IEPS rate: number; 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 { id: string; description: string; productKey: string; productKeyLabel: string; unitKey: string; quantity: number; price: number; discount: number; objetoImp: string; taxes: TaxLine[]; } const defaultTaxes: TaxLine[] = [ { category: 'traslado', type: 'IVA', rate: 0.16, factor: 'Tasa' }, ]; const emptyConcepto: ConceptoForm = { id: '', description: '', productKey: '', productKeyLabel: '', unitKey: 'E48', quantity: 1, price: 0, discount: 0, objetoImp: '02', taxes: [...defaultTaxes], }; const TAX_OPTIONS = { traslado: [ { type: 'IVA', label: 'IVA 16%', rate: 0.16, factor: 'Tasa' }, { type: 'IVA', label: 'IVA 8% (Frontera)', rate: 0.08, factor: 'Tasa' }, { type: 'IVA', label: 'IVA 0%', rate: 0, factor: 'Tasa' }, { type: 'IVA', label: 'IVA Exento', rate: 0, factor: 'Exento' }, { type: 'IEPS', label: 'IEPS 8%', rate: 0.08, factor: 'Tasa' }, { type: 'IEPS', label: 'IEPS 26.5%', rate: 0.265, factor: 'Tasa' }, { type: 'IEPS', label: 'IEPS 30%', rate: 0.30, factor: 'Tasa' }, { type: 'IEPS', label: 'IEPS 53%', rate: 0.53, factor: 'Tasa' }, { type: 'IEPS', label: 'IEPS 160%', rate: 1.60, factor: 'Tasa' }, { type: 'ISH', label: 'ISH (Hospedaje) 3%', rate: 0.03, factor: 'Tasa' }, { type: 'ISH', label: 'ISH (Hospedaje) 4%', rate: 0.04, factor: 'Tasa' }, { type: 'ISH', label: 'ISH (Hospedaje) 5%', rate: 0.05, factor: 'Tasa' }, { type: 'ImpLocal', label: 'Imp. Local Traslado 2%', rate: 0.02, factor: 'Tasa' }, { type: 'ImpLocal', label: 'Imp. Local Traslado 3%', rate: 0.03, factor: 'Tasa' }, { type: 'ImpLocal', label: 'Imp. Local Traslado 5%', rate: 0.05, factor: 'Tasa' }, ], retencion: [ { type: 'ISR', label: 'ISR 10%', rate: 0.10, factor: 'Tasa' }, { type: 'ISR', label: 'ISR 1.25%', rate: 0.0125, factor: 'Tasa' }, { type: 'ISR', label: 'ISR 20%', rate: 0.20, factor: 'Tasa' }, { type: 'ISR', label: 'ISR 25%', rate: 0.25, factor: 'Tasa' }, { type: 'IVA', label: 'IVA Retenido 10.6667%', rate: 0.106667, factor: 'Tasa' }, { type: 'IVA', label: 'IVA Retenido 4%', rate: 0.04, factor: 'Tasa' }, { type: 'IVA', label: 'IVA Retenido 16%', rate: 0.16, factor: 'Tasa' }, { type: 'IEPS', label: 'IEPS Retenido', rate: 0, factor: 'Tasa' }, { type: 'ImpLocal', label: 'Imp. Local Retenido 2%', rate: 0.02, factor: 'Tasa' }, { type: 'ImpLocal', label: 'Imp. Local Retenido 2.5%', rate: 0.025, factor: 'Tasa' }, { type: 'ImpLocal', label: 'Imp. Local Retenido 3%', rate: 0.03, factor: 'Tasa' }, { type: 'ImpLocal', label: 'Imp. Local Retenido 5%', rate: 0.05, factor: 'Tasa' }, ], }; // Regímenes PF que requieren retenciones al facturar a PM const REGIMENES_PF_RETENCION = ['612', '626', '606']; // Unidades consideradas "de servicio" const UNIDADES_SERVICIO = ['E48', 'ACT']; /** * Recomienda impuestos para tipo I según régimen emisor, receptor y unidad. * - Emisor PF (13 chars RFC) con régimen 612, 626 o 606 * - Receptor PM (no es 612, 626, 606) * - Unidad de servicio * Retorna taxes recomendados o null si no aplica */ function getRecommendedTaxes( emisorRfc: string, emisorRegimen: string | undefined, receptorTaxId: string, receptorTaxSystem: string, unitKey: string, ): TaxLine[] | null { // Solo para tipo I // Emisor debe ser PF (13 chars) con régimen 612, 626 o 606 if (emisorRfc.length !== 13) return null; if (!emisorRegimen || !REGIMENES_PF_RETENCION.includes(emisorRegimen)) return null; // Receptor debe ser PM (12 chars) y NO tener régimen 612, 626, 606 if (receptorTaxId.length !== 12) return null; if (REGIMENES_PF_RETENCION.includes(receptorTaxSystem)) return null; // Solo para unidades de servicio if (!UNIDADES_SERVICIO.includes(unitKey)) return null; const taxes: TaxLine[] = [ { category: 'traslado', type: 'IVA', rate: 0.16, factor: 'Tasa' }, { category: 'retencion', type: 'IVA', rate: 0.106667, factor: 'Tasa' }, // 2/3 del IVA ]; if (emisorRegimen === '626') { taxes.push({ category: 'retencion', type: 'ISR', rate: 0.0125, factor: 'Tasa' }); // 1.25% } else { taxes.push({ category: 'retencion', type: 'ISR', rate: 0.10, factor: 'Tasa' }); // 10% } return taxes; } const PAISES = [ { code: 'USA', name: 'Estados Unidos' }, { code: 'CAN', name: 'Canadá' }, { code: 'GBR', name: 'Reino Unido' }, { code: 'DEU', name: 'Alemania' }, { code: 'FRA', name: 'Francia' }, { code: 'ESP', name: 'España' }, { code: 'ITA', name: 'Italia' }, { code: 'BRA', name: 'Brasil' }, { code: 'ARG', name: 'Argentina' }, { code: 'COL', name: 'Colombia' }, { code: 'CHL', name: 'Chile' }, { code: 'PER', name: 'Perú' }, { code: 'CHN', name: 'China' }, { code: 'JPN', name: 'Japón' }, { code: 'KOR', name: 'Corea del Sur' }, { code: 'IND', name: 'India' }, { code: 'AUS', name: 'Australia' }, { code: 'NZL', name: 'Nueva Zelanda' }, { code: 'CHE', name: 'Suiza' }, { code: 'NLD', name: 'Países Bajos' }, { code: 'SWE', name: 'Suecia' }, { code: 'NOR', name: 'Noruega' }, { code: 'DNK', name: 'Dinamarca' }, { code: 'PRT', name: 'Portugal' }, { code: 'BEL', name: 'Bélgica' }, { code: 'AUT', name: 'Austria' }, { code: 'IRL', name: 'Irlanda' }, { code: 'POL', name: 'Polonia' }, { code: 'CRI', name: 'Costa Rica' }, { code: 'PAN', name: 'Panamá' }, { code: 'DOM', name: 'República Dominicana' }, { code: 'GTM', name: 'Guatemala' }, { code: 'HND', name: 'Honduras' }, { code: 'SLV', name: 'El Salvador' }, { code: 'ECU', name: 'Ecuador' }, { code: 'VEN', name: 'Venezuela' }, { code: 'URY', name: 'Uruguay' }, { code: 'PRY', name: 'Paraguay' }, { code: 'BOL', name: 'Bolivia' }, { code: 'CUB', name: 'Cuba' }, { code: 'ISR', name: 'Israel' }, { code: 'ARE', name: 'Emiratos Árabes Unidos' }, { code: 'SGP', name: 'Singapur' }, { code: 'HKG', name: 'Hong Kong' }, { code: 'TWN', name: 'Taiwán' }, { code: 'THA', name: 'Tailandia' }, { code: 'RUS', name: 'Rusia' }, { code: 'ZAF', name: 'Sudáfrica' }, { code: 'NGA', name: 'Nigeria' }, { code: 'EGY', name: 'Egipto' }, ]; const TIPO_CONFIG: Record = { 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' }, }; export default function FacturacionPage() { const { user } = useAuthStore(); const { viewingTenantRfc } = useTenantViewStore(); const { selectedContribuyenteRfc } = useContribuyenteStore(); const emisorRfc = selectedContribuyenteRfc || viewingTenantRfc || user?.tenantRfc || ''; const { data: formasPago } = useFormasPago(); const { data: metodosPago } = useMetodosPago(); const { data: usosCfdi } = useUsosCfdi(); const { data: monedas } = useMonedas(); const { data: clavesUnidad } = useClavesUnidad(); const { data: timbres } = useTimbres(); const emitir = useEmitirFactura(); // Régimen del emisor (para recomendaciones de retenciones) const { selectedContribuyenteId } = useContribuyenteStore(); // Banner "CSD recién tramitado": si el SAT rechazó una emisión en las // últimas 24h con el patrón LCO, mostrar advertencia. Poll discreto // (15min) por si el banner debe desaparecer tras pasar las 24h. const lcoStatusQuery = useQuery({ queryKey: ['lco-status', selectedContribuyenteId], queryFn: async () => { const params = new URLSearchParams(); if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId); const res = await apiClient.get<{ hasRecentLcoRejection: boolean; rejectedAt: string | null }>( `/facturacion/lco-status?${params}`, ); return res.data; }, enabled: !!selectedContribuyenteId, refetchInterval: 15 * 60 * 1000, }); const [emisorRegimenes, setEmisorRegimenes] = useState<{ clave: string; descripcion: string }[]>([]); const [emisorRegimen, setEmisorRegimen] = useState(); useEffect(() => { if (emisorRfc.length >= 12) { import('@/lib/api/dashboard').then(({ getRegimenesDelPeriodo }) => { const now = new Date(); const fi = `${now.getFullYear()}-01-01`; const ff = `${now.getFullYear()}-12-31`; getRegimenesDelPeriodo(fi, ff, undefined, selectedContribuyenteId).then(regs => { setEmisorRegimenes(regs); setEmisorRegimen(regs.length > 0 ? regs[0].clave : undefined); }).catch(() => { setEmisorRegimenes([]); setEmisorRegimen(undefined); }); }); } else { setEmisorRegimenes([]); setEmisorRegimen(undefined); } }, [emisorRfc, selectedContribuyenteId]); // Al cambiar el régimen del emisor, recalcular recomendaciones de retenciones const handleEmisorRegimenChange = (clave: string) => { setEmisorRegimen(clave); if (tipoComprobante === 'I') { setConceptos(prev => prev.map(c => { const recommended = getRecommendedTaxes( emisorRfc, clave, receptor.taxId, receptor.taxSystem, c.unitKey ); return recommended ? { ...c, taxes: recommended } : { ...c, taxes: [...defaultTaxes] }; })); } }; // Tipo de comprobante const [tipoComprobante, setTipoComprobante] = useState('I'); const config = TIPO_CONFIG[tipoComprobante]; // Receptor const [receptor, setReceptor] = useState({ legalName: '', taxId: '', taxSystem: '601', email: '', zip: '', }); const isExtranjero = receptor.taxId === 'XEXX010101000'; const [extranjeroTaxId, setExtranjeroTaxId] = useState(''); const [extranjeroCountry, setExtranjeroCountry] = useState('USA'); // Auto-llenar datos cuando se detecta RFC extranjero useEffect(() => { if (isExtranjero) { setReceptor(prev => ({ ...prev, taxSystem: '616' })); // Obtener CP del tenant apiClient.get('/facturacion/datos-fiscales').then(r => { if (r.data?.codigoPostal) setReceptor(prev => ({ ...prev, zip: r.data.codigoPostal })); }).catch(() => {}); } }, [isExtranjero]); // Factura global const [isGlobal, setIsGlobal] = useState(false); const PERIODICIDAD_LABELS: Record = { day: 'Diario', week: 'Semanal', fortnight: 'Quincenal', month: 'Mensual', two_months: 'Bimestral', }; const [globalPeriodicidad, setGlobalPeriodicidad] = useState('month'); const [globalMes, setGlobalMes] = useState(String(new Date().getMonth() + 1).padStart(2, '0')); const [globalAnio, setGlobalAnio] = useState(new Date().getFullYear()); // Campos CFDI const [usoCfdi, setUsoCfdi] = useState('G03'); const [formaPago, setFormaPago] = useState('99'); const [metodoPago, setMetodoPago] = useState('PUE'); const [moneda, setMoneda] = useState('MXN'); const [exportacion, setExportacion] = useState('01'); const [serie, setSerie] = useState(''); const [folio, setFolio] = useState(''); const [condiciones, setCondiciones] = useState(''); // Conceptos const [conceptos, setConceptos] = useState([{ ...emptyConcepto, id: crypto.randomUUID() }]); // CFDIs relacionados (Ingreso / Egreso) const [relatedDocs, setRelatedDocs] = useState([]); // Complemento de pago const [pagoFecha, setPagoFecha] = useState(new Date().toISOString().slice(0, 10)); const [pagoUuid, setPagoUuid] = useState(''); const [pagoMonto, setPagoMonto] = useState(0); const [pagoParcialidad, setPagoParcialidad] = useState(1); const [pagoSaldoAnterior, setPagoSaldoAnterior] = useState(0); const [pagoFormaPago, setPagoFormaPago] = useState('03'); const [pagoIvaBase, setPagoIvaBase] = useState(0); const [pagoIvaTasa, setPagoIvaTasa] = useState(0.16); // Reset del formulario al cambiar de contribuyente — evita que receptor/conceptos/retenciones // de un contribuyente anterior se mezclen con los del nuevo (ej: retenciones de PF→PM que ya // no aplican si el nuevo emisor es PM). No dispara en el primer render. const firstRenderRef = useRef(true); useEffect(() => { if (firstRenderRef.current) { firstRenderRef.current = false; return; } const cfg = TIPO_CONFIG[tipoComprobante]; const defaultUnit = tipoComprobante === 'T' ? 'H87' : 'E48'; setReceptor({ legalName: '', taxId: '', taxSystem: '601', email: '', zip: '' }); setIsGlobal(false); setExtranjeroTaxId(''); setExtranjeroCountry('USA'); setUsoCfdi(cfg.defaultUso); setFormaPago(cfg.defaultFormaPago); setMetodoPago(cfg.defaultMetodoPago); setMoneda('MXN'); setExportacion('01'); setSerie(''); setFolio(''); setCondiciones(''); setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]); setRelatedDocs([]); setPagoUuid(''); setPagoMonto(0); setPagoParcialidad(1); setPagoSaldoAnterior(0); setPagoFormaPago('03'); setPagoIvaBase(0); setPagoIvaTasa(0.16); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedContribuyenteId]); // Búsqueda RFC receptor const [rfcResults, setRfcResults] = useState([]); const [rfcSearchField, setRfcSearchField] = useState<'rfc' | 'name' | null>(null); const rfcDropdownRef = useRef(null); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (rfcDropdownRef.current && !rfcDropdownRef.current.contains(e.target as Node)) { setRfcSearchField(null); setRfcResults([]); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleRfcSearch = async (value: string, field: 'rfc' | 'name') => { if (field === 'rfc') setReceptor(r => ({ ...r, taxId: value.toUpperCase() })); else setReceptor(r => ({ ...r, legalName: value })); setRfcSearchField(field); if (value.length < 3) { setRfcResults([]); return; } const results = await searchRfcs(value); setRfcResults(results); }; // CFDIs PPD pendientes (para tipo P) const [cfdisPpd, setCfdisPpd] = useState([]); const [showPpdDropdown, setShowPpdDropdown] = useState(false); const selectRfc = async (item: RfcSearchResult) => { setReceptor({ ...receptor, taxId: item.rfc, legalName: item.razonSocial || '', taxSystem: item.regimenFiscal || receptor.taxSystem, zip: item.codigoPostal || receptor.zip, }); setRfcResults([]); setRfcSearchField(null); // Recalcular recomendaciones de impuestos en conceptos existentes (tipo I) if (tipoComprobante === 'I') { const newTaxSystem = item.regimenFiscal || receptor.taxSystem; setConceptos(prev => prev.map(c => { const recommended = getRecommendedTaxes( emisorRfc, emisorRegimen, item.rfc, newTaxSystem, c.unitKey ); return recommended ? { ...c, taxes: recommended } : { ...c, taxes: [...defaultTaxes] }; })); } // Si es tipo P, cargar CFDIs PPD pendientes para este RFC if (tipoComprobante === 'P' && item.rfc) { try { const ppd = await getCfdisPpd(item.rfc); setCfdisPpd(ppd); } catch { setCfdisPpd([]); } } }; const selectCfdiPpd = (cfdi: CfdiPpdPendiente) => { setPagoUuid(cfdi.uuid); setPagoSaldoAnterior(Number(cfdi.saldoPendiente)); setPagoMonto(Number(cfdi.saldoPendiente)); setPagoIvaBase(Number(cfdi.saldoPendiente) / 1.16); setShowPpdDropdown(false); }; // Búsqueda producto SAT const [prodSearch, setProdSearch] = useState(''); const [prodResults, setProdResults] = useState>([]); const [searchingIdx, setSearchingIdx] = useState(null); // Modal búsqueda de conceptos previos const [showConceptoSearch, setShowConceptoSearch] = useState(false); const [conceptoSearchIdx, setConceptoSearchIdx] = useState(0); const [conceptoSearchQuery, setConceptoSearchQuery] = useState(''); const [conceptoSearchTipo, setConceptoSearchTipo] = useState('todos'); const [conceptoSearchResults, setConceptoSearchResults] = useState([]); const [conceptoSearching, setConceptoSearching] = useState(false); const openConceptoSearch = (idx: number) => { setConceptoSearchIdx(idx); setConceptoSearchQuery(''); setConceptoSearchResults([]); setShowConceptoSearch(true); }; const handleConceptoSearch = async () => { setConceptoSearching(true); try { const results = await searchConceptos(conceptoSearchQuery, conceptoSearchTipo, selectedContribuyenteId); setConceptoSearchResults(results); } catch { setConceptoSearchResults([]); } finally { setConceptoSearching(false); } }; const selectConceptoPrevio = (cp: ConceptoPrevio) => { const updated = [...conceptos]; const precio = Number(cp.valorUnitario) || 0; const iva = Number(cp.ivaTraslado) || 0; const ivaRate = precio > 0 ? iva / precio : 0.16; const taxes: TaxLine[] = [ { category: 'traslado', type: 'IVA', rate: Math.round(ivaRate * 100) / 100 || 0.16, factor: 'Tasa' }, ]; if (Number(cp.ivaRetencion) > 0 && precio > 0) { taxes.push({ category: 'retencion', type: 'IVA', rate: Math.round((Number(cp.ivaRetencion) / precio) * 1000000) / 1000000, factor: 'Tasa' }); } if (Number(cp.isrRetencion) > 0 && precio > 0) { taxes.push({ category: 'retencion', type: 'ISR', rate: Math.round((Number(cp.isrRetencion) / precio) * 100) / 100, factor: 'Tasa' }); } updated[conceptoSearchIdx] = { ...updated[conceptoSearchIdx], description: cp.descripcion || '', productKey: cp.claveProdServ || '', productKeyLabel: cp.claveProdServ ? `${cp.claveProdServ} - ${cp.descripcion}` : '', unitKey: cp.claveUnidad || 'E48', price: precio + iva, taxes, objetoImp: '02', }; setConceptos(updated); setShowConceptoSearch(false); }; // Resultado const [result, setResult] = useState<{ uuid: string; total: number } | null>(null); const handleTipoChange = (tipo: string) => { setTipoComprobante(tipo); const c = TIPO_CONFIG[tipo]; setUsoCfdi(c.defaultUso); setFormaPago(c.defaultFormaPago); setMetodoPago(c.defaultMetodoPago); // Resetear conceptos con unidad default según tipo const defaultUnit = tipo === 'T' ? 'H87' : 'E48'; setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]); }; // Unidades de servicio que no aplican para Traslado const SERVICE_UNITS = ['E48', 'ACT']; const filteredUnidades = tipoComprobante === 'T' ? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave)) : clavesUnidad; const handleSearchProduct = async (q: string, idx: number) => { setProdSearch(q); setSearchingIdx(idx); if (q.length < 2) { setProdResults([]); return; } const results = await searchClaveProdServ(q); setProdResults(results); }; const selectProduct = (idx: number, clave: string, descripcion: string) => { setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, productKey: clave, productKeyLabel: `${clave} - ${descripcion}` } : c)); setProdResults([]); setSearchingIdx(null); }; const updateConcepto = (idx: number, field: keyof ConceptoForm, value: any) => { setConceptos(prev => prev.map((c, i) => { if (i !== idx) return c; const updated = { ...c, [field]: value } as ConceptoForm; // Si cambió la unidad, re-evaluar recomendación de impuestos if (field === 'unitKey' && tipoComprobante === 'I') { const recommended = getRecommendedTaxes( emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, value ); if (recommended) { updated.taxes = recommended; } else { updated.taxes = [...defaultTaxes]; } } return updated; })); }; // Aplica recomendación de impuestos a un concepto si corresponde (solo tipo I) const applyTaxRecommendation = (concepto: ConceptoForm): ConceptoForm => { if (tipoComprobante !== 'I') return concepto; const recommended = getRecommendedTaxes( emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, concepto.unitKey ); if (recommended) { return { ...concepto, taxes: recommended }; } return concepto; }; const addConcepto = () => { const newConcepto = applyTaxRecommendation({ ...emptyConcepto, id: crypto.randomUUID() }); setConceptos(prev => [...prev, newConcepto]); }; const removeConcepto = (idx: number) => { setConceptos(prev => { if (prev.length === 1) return prev; return prev.filter((_, i) => i !== idx); }); }; // Cálculos function calcConcepto(c: ConceptoForm) { const trasladoRates = c.taxes.filter(t => t.category === 'traslado' && t.factor === 'Tasa').reduce((s, t) => s + t.rate, 0); const unitPrice = trasladoRates > 0 ? c.price / (1 + trasladoRates) : c.price; const base = unitPrice * c.quantity - c.discount; const traslados = c.taxes.filter(t => t.category === 'traslado' && t.factor === 'Tasa').reduce((s, t) => s + base * t.rate, 0); const retenciones = c.taxes.filter(t => t.category === 'retencion').reduce((s, t) => s + base * t.rate, 0); return { base, traslados, retenciones }; } const subtotal = config.needsConceptos ? conceptos.reduce((s, c) => s + calcConcepto(c).base, 0) : 0; const totalTraslados = config.needsConceptos ? conceptos.reduce((s, c) => s + calcConcepto(c).traslados, 0) : 0; const totalRetenciones = config.needsConceptos ? conceptos.reduce((s, c) => s + calcConcepto(c).retenciones, 0) : 0; const totalDescuento = config.needsConceptos ? conceptos.reduce((s, c) => s + c.discount, 0) : 0; const total = config.needsPaymentComplement ? pagoMonto : subtotal + totalTraslados - totalRetenciones; const pagoIvaMonto = pagoIvaBase * pagoIvaTasa; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!isGlobal && (!receptor.taxId || !receptor.legalName || !receptor.zip)) { alert('Completa los datos del receptor'); return; } const data: any = { customer: { legalName: receptor.legalName, taxId: isExtranjero ? extranjeroTaxId : receptor.taxId.toUpperCase(), taxSystem: isExtranjero ? undefined : receptor.taxSystem, email: receptor.email || undefined, zip: receptor.zip, ...(isExtranjero ? { country: extranjeroCountry } : {}), } as any, use: usoCfdi, paymentForm: formaPago, paymentMethod: metodoPago, currency: moneda, type: tipoComprobante, export: exportacion, ...(emisorRegimen ? { issuerTaxSystem: emisorRegimen } : {}), ...(selectedContribuyenteId ? { contribuyenteId: selectedContribuyenteId } : {}), }; if (isGlobal && tipoComprobante === 'I') { data.global = { periodicity: globalPeriodicidad, months: globalMes, year: globalAnio, }; } if (serie) data.series = serie; if (folio) data.folioNumber = parseInt(folio) || undefined; if (condiciones) data.conditions = condiciones; if (config.needsConceptos) { if (conceptos.some(c => !c.description || !c.productKey)) { alert('Completa todos los conceptos'); return; } if (tipoComprobante !== 'T' && conceptos.some(c => c.price <= 0)) { alert('El precio debe ser mayor a 0'); return; } data.items = conceptos.map(c => ({ description: c.description, productKey: c.productKey, unitKey: c.unitKey, quantity: c.quantity, price: tipoComprobante === 'T' ? 0 : c.price, discount: c.discount || 0, taxIncluded: true, objetoImp: c.objetoImp, taxes: tipoComprobante === 'T' || c.objetoImp === '01' ? [] : c.taxes.map(t => ({ type: t.type, rate: t.rate, factor: t.factor, withholding: t.category === 'retencion', })), })); } 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) { if (!pagoUuid) { alert('UUID del documento a pagar es requerido'); return; } data.complements = [{ type: 'pago', data: [{ payment_form: pagoFormaPago, related_documents: [{ uuid: pagoUuid, amount: pagoMonto, installment: pagoParcialidad, last_balance: pagoSaldoAnterior, taxes: [{ base: pagoIvaBase, type: 'IVA', rate: pagoIvaTasa, }], }], }], }]; } try { const res = await emitir.mutateAsync(data); setResult({ uuid: res.uuid, total: res.total }); } catch (err: any) { alert(err.response?.data?.message || err.message || 'Error al emitir factura'); } }; if (result) { return ( <>

Factura Emitida

UUID

{result.uuid}

Total

${result.total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}

); } return ( <>
{/* Modal de búsqueda de conceptos previos */} {showConceptoSearch && (

Buscar Concepto en Facturas

setConceptoSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleConceptoSearch())} placeholder="Buscar por descripción o clave SAT..." className="flex-1" autoFocus />
{conceptoSearchResults.length === 0 && !conceptoSearching && (

{conceptoSearchQuery ? 'Sin resultados. Intenta con otro término.' : 'Escribe para buscar en tus facturas emitidas y recibidas.'}

)} {conceptoSearchResults.map((cp, i) => ( ))}
)}
{lcoStatusQuery.data?.hasRecentLcoRejection && (
CSD aún en proceso de validación — espera 24 horas antes de emitir tu factura.
)}
{/* Timbres */} {timbres && (
Timbres: {timbres.configured ? `${timbres.disponibles ?? 0} del plan${(timbres.adicionales?.disponibles ?? 0) > 0 ? ` + ${timbres.adicionales!.disponibles} adicionales` : ''}` : 'No configurado'} Comprar más →
)} {/* Datos del Comprobante */} Datos del Comprobante
{emisorRegimenes.length >= 2 && (
)} {tipoComprobante === 'I' && (
)} {isGlobal && tipoComprobante === 'I' && ( <>
setGlobalAnio(parseInt(e.target.value) || new Date().getFullYear())} min={2020} max={2030} />
)} {(tipoComprobante === 'I' || tipoComprobante === 'E') && ( <>
)} {tipoComprobante === 'P' && (

Uso CFDI: CP01 · Moneda: XXX · Forma/Método de pago se definen en el complemento

)} {tipoComprobante === 'T' && (

Uso CFDI: S01 · Moneda: XXX · Sin forma ni método de pago

)}
setSerie(e.target.value.toUpperCase())} placeholder="P" maxLength={10} />
setFolio(e.target.value.replace(/\D/g, ''))} placeholder="1001" maxLength={10} />
{tipoComprobante !== 'P' && metodoPago === 'PPD' && (
setCondiciones(e.target.value)} placeholder="Net 30 días" />
)}
{/* Receptor */} Receptor {isGlobal ? 'Público en general (factura global)' : 'Datos fiscales del cliente'} {isGlobal && (

RFC: XAXX010101000 · Nombre: PUBLICO EN GENERAL · Régimen: 616

)} {!isGlobal && (<>
handleRfcSearch(e.target.value, 'rfc')} onFocus={() => { if (receptor.taxId.length >= 3) handleRfcSearch(receptor.taxId, 'rfc'); }} placeholder="XAXX010101000" maxLength={14} required /> {rfcSearchField === 'rfc' && rfcResults.length > 0 && (
{rfcResults.map(r => ( ))}
)}
handleRfcSearch(e.target.value, 'name')} onFocus={() => { if (receptor.legalName.length >= 3) handleRfcSearch(receptor.legalName, 'name'); }} placeholder="Empresa SA de CV" required /> {rfcSearchField === 'name' && rfcResults.length > 0 && (
{rfcResults.map(r => ( ))}
)}
setReceptor({ ...receptor, zip: e.target.value.replace(/\D/g, '').slice(0, 5) })} placeholder="06600" maxLength={5} required />
setReceptor({ ...receptor, email: e.target.value })} placeholder="cliente@empresa.com" />
{isExtranjero && (

Cliente Extranjero

setExtranjeroTaxId(e.target.value)} placeholder="198912171234" required />
)} )}
{/* Complemento de Pago */} {config.needsPaymentComplement && ( Complemento de Pago Datos del pago recibido contra un CFDI con método PPD
setPagoUuid(e.target.value.toUpperCase())} onFocus={() => { if (cfdisPpd.length > 0) setShowPpdDropdown(true); }} placeholder={cfdisPpd.length > 0 ? 'Selecciona una factura PPD...' : 'Ingresa el RFC del receptor primero'} required /> {showPpdDropdown && cfdisPpd.length > 0 && (
{cfdisPpd.map(c => ( ))}
)} {cfdisPpd.length === 0 && receptor.taxId.length >= 3 && tipoComprobante === 'P' && (

No se encontraron facturas PPD pendientes para este RFC

)}
setPagoMonto(parseFloat(e.target.value) || 0)} placeholder="0.00" required />
setPagoParcialidad(parseInt(e.target.value) || 1)} required />
setPagoSaldoAnterior(parseFloat(e.target.value) || 0)} placeholder="0.00" required />
{/* Desglose de impuestos del pago */}

Desglose de Impuestos del Pago

setPagoIvaBase(parseFloat(e.target.value) || 0)} placeholder="0.00" />
)} {/* Conceptos (Ingreso, Egreso, Traslado) */} {config.needsConceptos && (
Conceptos {tipoComprobante === 'T' ? 'Mercancías a trasladar (precio en 0)' : 'Productos o servicios a facturar'}
{conceptos.map((c, idx) => (
Concepto {idx + 1}
{conceptos.length > 1 && ( )}
updateConcepto(idx, 'description', e.target.value)} placeholder="Servicio de consultoría" required />
handleSearchProduct(e.target.value, idx)} onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }} placeholder="Buscar clave SAT..." required />
{searchingIdx === idx && prodResults.length > 0 && (
{prodResults.map(p => ( ))}
)}
updateConcepto(idx, 'quantity', parseFloat(e.target.value) || 1)} required />
{tipoComprobante !== 'T' && ( <>
updateConcepto(idx, 'price', parseFloat(e.target.value) || 0)} placeholder="0.00" required />
updateConcepto(idx, 'discount', parseFloat(e.target.value) || 0)} placeholder="0.00" />
)}
{/* Impuestos del concepto */} {tipoComprobante !== 'T' && c.objetoImp === '02' && (
Impuestos
{c.taxes.length === 0 && (

Sin impuestos. Agrega traslados o retenciones.

)}
{c.taxes.map((tax, tIdx) => { const base = calcConcepto(c).base; const amount = base * tax.rate; return (
{tax.category === 'traslado' ? 'T' : 'R'} {tax.type} {tax.factor === 'Exento' ? 'Exento' : `${(tax.rate * 100).toFixed(4).replace(/\.?0+$/, '')}%`}
{tax.category === 'retencion' ? '-' : ''}${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
); })}
)} {tipoComprobante !== 'T' && (
Importe: ${((c.price * c.quantity) - c.discount).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
)}
))}
)} {/* 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 */}
{config.needsConceptos && tipoComprobante !== 'T' && (
Subtotal: ${subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
{totalDescuento > 0 && (
Descuento: -${totalDescuento.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
)} {totalTraslados > 0 && (
Traslados: ${totalTraslados.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
)} {totalRetenciones > 0 && (
Retenciones: -${totalRetenciones.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
)}
)} {config.needsPaymentComplement && pagoIvaBase > 0 && (
IVA del pago: ${pagoIvaMonto.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
)}
Total: ${total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
); }