# CFDI Viewer Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add PDF-like invoice visualization for CFDIs with PDF and XML download capabilities. **Architecture:** React modal component with invoice renderer. Backend returns XML via new endpoint. html2pdf.js generates PDF client-side from rendered HTML. **Tech Stack:** React, TypeScript, html2pdf.js, Tailwind CSS --- ## Task 1: Install html2pdf.js Dependency **Files:** - Modify: `apps/web/package.json` **Step 1: Install the dependency** Run: ```bash cd /root/Horux/apps/web && pnpm add html2pdf.js ``` **Step 2: Verify installation** Run: ```bash grep html2pdf apps/web/package.json ``` Expected: `"html2pdf.js": "^0.10.x"` **Step 3: Commit** ```bash git add apps/web/package.json apps/web/pnpm-lock.yaml git commit -m "chore: add html2pdf.js for PDF generation" ``` --- ## Task 2: Add xmlOriginal to Cfdi Type **Files:** - Modify: `packages/shared/src/types/cfdi.ts:4-31` **Step 1: Add xmlOriginal field to Cfdi interface** In `packages/shared/src/types/cfdi.ts`, add after line 29 (`pdfUrl: string | null;`): ```typescript xmlOriginal: string | null; ``` **Step 2: Verify types compile** Run: ```bash cd /root/Horux && pnpm build --filter=@horux/shared ``` Expected: Build succeeds **Step 3: Commit** ```bash git add packages/shared/src/types/cfdi.ts git commit -m "feat(types): add xmlOriginal field to Cfdi interface" ``` --- ## Task 3: Update Backend Service to Return XML **Files:** - Modify: `apps/api/src/services/cfdi.service.ts:77-95` **Step 1: Update getCfdiById to include xml_original** Replace the `getCfdiById` function: ```typescript export async function getCfdiById(schema: string, id: string): Promise { const result = await prisma.$queryRawUnsafe(` 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", xml_original as "xmlOriginal", created_at as "createdAt" FROM "${schema}".cfdis WHERE id = $1 `, id); return result[0] || null; } ``` **Step 2: Add getXmlById function** Add after `getCfdiById`: ```typescript export async function getXmlById(schema: string, id: string): Promise { const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(` SELECT xml_original FROM "${schema}".cfdis WHERE id = $1 `, id); return result[0]?.xml_original || null; } ``` **Step 3: Verify API compiles** Run: ```bash cd /root/Horux/apps/api && pnpm build ``` Expected: Build succeeds **Step 4: Commit** ```bash git add apps/api/src/services/cfdi.service.ts git commit -m "feat(api): add xmlOriginal to getCfdiById and add getXmlById" ``` --- ## Task 4: Add XML Download Endpoint **Files:** - Modify: `apps/api/src/controllers/cfdi.controller.ts` - Modify: `apps/api/src/routes/cfdi.routes.ts` **Step 1: Add getXml controller function** Add to `apps/api/src/controllers/cfdi.controller.ts` after `getCfdiById`: ```typescript export async function getXml(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantSchema) { return next(new AppError(400, 'Schema no configurado')); } const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id)); if (!xml) { return next(new AppError(404, 'XML no encontrado para este CFDI')); } res.set('Content-Type', 'application/xml'); res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`); res.send(xml); } catch (error) { next(error); } } ``` **Step 2: Add route for XML download** In `apps/api/src/routes/cfdi.routes.ts`, add after line 13 (`router.get('/:id', ...)`): ```typescript router.get('/:id/xml', cfdiController.getXml); ``` **Step 3: Verify API compiles** Run: ```bash cd /root/Horux/apps/api && pnpm build ``` Expected: Build succeeds **Step 4: Commit** ```bash git add apps/api/src/controllers/cfdi.controller.ts apps/api/src/routes/cfdi.routes.ts git commit -m "feat(api): add GET /cfdi/:id/xml endpoint" ``` --- ## Task 5: Add API Client Function for XML Download **Files:** - Modify: `apps/web/lib/api/cfdi.ts` **Step 1: Add getCfdiXml function** Add at the end of `apps/web/lib/api/cfdi.ts`: ```typescript export async function getCfdiXml(id: string): Promise { const response = await apiClient.get(`/cfdi/${id}/xml`, { responseType: 'text' }); return response.data; } ``` **Step 2: Commit** ```bash git add apps/web/lib/api/cfdi.ts git commit -m "feat(web): add getCfdiXml API function" ``` --- ## Task 6: Create CfdiInvoice Component **Files:** - Create: `apps/web/components/cfdi/cfdi-invoice.tsx` **Step 1: Create the component** Create `apps/web/components/cfdi/cfdi-invoice.tsx`: ```typescript 'use client'; import { forwardRef } from 'react'; import type { Cfdi } from '@horux/shared'; interface CfdiConcepto { descripcion: string; cantidad: number; valorUnitario: number; importe: number; claveUnidad?: string; claveProdServ?: string; } interface CfdiInvoiceProps { cfdi: Cfdi; conceptos?: CfdiConcepto[]; } const formatCurrency = (value: number) => new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', }).format(value); const formatDate = (dateString: string) => new Date(dateString).toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric', }); const tipoLabels: Record = { ingreso: 'Ingreso', egreso: 'Egreso', traslado: 'Traslado', pago: 'Pago', nomina: 'Nomina', }; const formaPagoLabels: Record = { '01': 'Efectivo', '02': 'Cheque nominativo', '03': 'Transferencia electrónica', '04': 'Tarjeta de crédito', '28': 'Tarjeta de débito', '99': 'Por definir', }; const metodoPagoLabels: Record = { PUE: 'Pago en una sola exhibición', PPD: 'Pago en parcialidades o diferido', }; export const CfdiInvoice = forwardRef( ({ cfdi, conceptos }, ref) => { return (
{/* Header */}
[LOGO]

FACTURA

{cfdi.serie && `Serie: ${cfdi.serie} `} {cfdi.folio && `Folio: ${cfdi.folio}`}

Fecha: {formatDate(cfdi.fechaEmision)}

{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
{/* Emisor / Receptor */}

EMISOR

{cfdi.nombreEmisor}

RFC: {cfdi.rfcEmisor}

RECEPTOR

{cfdi.nombreReceptor}

RFC: {cfdi.rfcReceptor}

{cfdi.usoCfdi && (

Uso CFDI: {cfdi.usoCfdi}

)}
{/* Datos del Comprobante */}

DATOS DEL COMPROBANTE

Tipo:

{tipoLabels[cfdi.tipo] || cfdi.tipo}

Método de Pago:

{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || cfdi.metodoPago : '-'}

Forma de Pago:

{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || cfdi.formaPago : '-'}

Moneda:

{cfdi.moneda} {cfdi.tipoCambio !== 1 && ` (TC: ${cfdi.tipoCambio})`}

{/* Conceptos */} {conceptos && conceptos.length > 0 && (

CONCEPTOS

{conceptos.map((concepto, idx) => ( ))}
Descripción Cant. P. Unit. Importe
{concepto.descripcion} {concepto.cantidad} {formatCurrency(concepto.valorUnitario)} {formatCurrency(concepto.importe)}
)} {/* Totales */}
Subtotal: {formatCurrency(cfdi.subtotal)}
{cfdi.descuento > 0 && (
Descuento: -{formatCurrency(cfdi.descuento)}
)} {cfdi.iva > 0 && (
IVA (16%): {formatCurrency(cfdi.iva)}
)} {cfdi.ivaRetenido > 0 && (
IVA Retenido: -{formatCurrency(cfdi.ivaRetenido)}
)} {cfdi.isrRetenido > 0 && (
ISR Retenido: -{formatCurrency(cfdi.isrRetenido)}
)}
TOTAL: {formatCurrency(cfdi.total)}
{/* Timbre Fiscal */}

TIMBRE FISCAL DIGITAL

UUID:

{cfdi.uuidFiscal}

Fecha de Timbrado:

{cfdi.fechaTimbrado}

); } ); CfdiInvoice.displayName = 'CfdiInvoice'; ``` **Step 2: Commit** ```bash git add apps/web/components/cfdi/cfdi-invoice.tsx git commit -m "feat(web): add CfdiInvoice component for PDF-like rendering" ``` --- ## Task 7: Create CfdiViewerModal Component **Files:** - Create: `apps/web/components/cfdi/cfdi-viewer-modal.tsx` **Step 1: Create the modal component** Create `apps/web/components/cfdi/cfdi-viewer-modal.tsx`: ```typescript 'use client'; import { useRef, useState, useEffect } from 'react'; import type { Cfdi } from '@horux/shared'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { CfdiInvoice } from './cfdi-invoice'; import { getCfdiXml } from '@/lib/api/cfdi'; import { Download, FileText, X, Loader2 } from 'lucide-react'; interface CfdiConcepto { descripcion: string; cantidad: number; valorUnitario: number; importe: number; } interface CfdiViewerModalProps { cfdi: Cfdi | null; open: boolean; onClose: () => void; } function parseConceptosFromXml(xmlString: string): CfdiConcepto[] { try { const parser = new DOMParser(); const doc = parser.parseFromString(xmlString, 'text/xml'); const conceptos: CfdiConcepto[] = []; // Find all Concepto elements const elements = doc.getElementsByTagName('*'); for (let i = 0; i < elements.length; i++) { if (elements[i].localName === 'Concepto') { const el = elements[i]; conceptos.push({ descripcion: el.getAttribute('Descripcion') || '', cantidad: parseFloat(el.getAttribute('Cantidad') || '1'), valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'), importe: parseFloat(el.getAttribute('Importe') || '0'), }); } } return conceptos; } catch { return []; } } export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) { const invoiceRef = useRef(null); const [conceptos, setConceptos] = useState([]); const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null); const [xmlContent, setXmlContent] = useState(null); useEffect(() => { if (cfdi?.xmlOriginal) { setXmlContent(cfdi.xmlOriginal); setConceptos(parseConceptosFromXml(cfdi.xmlOriginal)); } else { setXmlContent(null); setConceptos([]); } }, [cfdi]); const handleDownloadPdf = async () => { if (!invoiceRef.current || !cfdi) return; setDownloading('pdf'); try { const html2pdf = (await import('html2pdf.js')).default; const opt = { margin: 10, filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`, image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2, useCORS: true }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, }; await html2pdf().set(opt).from(invoiceRef.current).save(); } catch (error) { console.error('Error generating PDF:', error); alert('Error al generar el PDF'); } finally { setDownloading(null); } }; const handleDownloadXml = async () => { if (!cfdi) return; setDownloading('xml'); try { let xml = xmlContent; if (!xml) { xml = await getCfdiXml(cfdi.id); } if (!xml) { alert('No hay XML disponible para este CFDI'); return; } const blob = new Blob([xml], { type: 'application/xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Error downloading XML:', error); alert('Error al descargar el XML'); } finally { setDownloading(null); } }; if (!cfdi) return null; return ( !isOpen && onClose()}>
Vista de Factura
); } ``` **Step 2: Commit** ```bash git add apps/web/components/cfdi/cfdi-viewer-modal.tsx git commit -m "feat(web): add CfdiViewerModal with PDF and XML download" ``` --- ## Task 8: Integrate Viewer into CFDI Page **Files:** - Modify: `apps/web/app/(dashboard)/cfdi/page.tsx` **Step 1: Add imports at top of file** Add after the existing imports (around line 14): ```typescript import { Eye } from 'lucide-react'; import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; import { getCfdiById } from '@/lib/api/cfdi'; ``` **Step 2: Add state for viewer modal** Inside `CfdiPage` component, after line 255 (`const deleteCfdi = useDeleteCfdi();`), add: ```typescript const [viewingCfdi, setViewingCfdi] = useState(null); const [loadingCfdi, setLoadingCfdi] = useState(null); const handleViewCfdi = async (id: string) => { setLoadingCfdi(id); try { const cfdi = await getCfdiById(id); setViewingCfdi(cfdi); } catch (error) { console.error('Error loading CFDI:', error); alert('Error al cargar el CFDI'); } finally { setLoadingCfdi(null); } }; ``` **Step 3: Add import for Cfdi type** Update the import from `@horux/shared` at line 12 to include `Cfdi`: ```typescript import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared'; ``` **Step 4: Add View button in table** In the table header (around line 1070), add a new column header before the delete column: ```typescript ``` In the table body (inside the map, around line 1125), add before the delete button: ```typescript ``` **Step 5: Add modal component** At the end of the return statement, just before the closing ``, add: ```typescript setViewingCfdi(null)} /> ``` **Step 6: Verify build** Run: ```bash cd /root/Horux/apps/web && pnpm build ``` Expected: Build succeeds **Step 7: Commit** ```bash git add apps/web/app/\(dashboard\)/cfdi/page.tsx git commit -m "feat(web): integrate CFDI viewer modal into CFDI page" ``` --- ## Task 9: Build and Test **Step 1: Build all packages** Run: ```bash cd /root/Horux && pnpm build ``` Expected: All packages build successfully **Step 2: Restart services** Run: ```bash systemctl restart horux-api horux-web ``` **Step 3: Manual verification** 1. Open browser to CFDI page 2. Click Eye icon on any CFDI row 3. Verify modal opens with invoice preview 4. Click PDF button - verify PDF downloads 5. Click XML button (if XML exists) - verify XML downloads **Step 4: Final commit with all changes** ```bash git add -A git status # If any uncommitted changes: git commit -m "feat: complete CFDI viewer implementation" ``` --- ## Summary of Changes | File | Change | |------|--------| | `apps/web/package.json` | Added html2pdf.js dependency | | `packages/shared/src/types/cfdi.ts` | Added xmlOriginal field | | `apps/api/src/services/cfdi.service.ts` | Updated getCfdiById, added getXmlById | | `apps/api/src/controllers/cfdi.controller.ts` | Added getXml controller | | `apps/api/src/routes/cfdi.routes.ts` | Added GET /:id/xml route | | `apps/web/lib/api/cfdi.ts` | Added getCfdiXml function | | `apps/web/components/cfdi/cfdi-invoice.tsx` | NEW - Invoice renderer | | `apps/web/components/cfdi/cfdi-viewer-modal.tsx` | NEW - Modal wrapper | | `apps/web/app/(dashboard)/cfdi/page.tsx` | Integrated viewer |