diff --git a/docs/plans/2026-02-17-cfdi-viewer-implementation.md b/docs/plans/2026-02-17-cfdi-viewer-implementation.md new file mode 100644 index 0000000..f656b12 --- /dev/null +++ b/docs/plans/2026-02-17-cfdi-viewer-implementation.md @@ -0,0 +1,816 @@ +# 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ónCant.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 |