Files
Horux360/docs/plans/2026-02-17-cfdi-viewer-implementation.md
Consultoria AS 6109294811 docs: add CFDI viewer implementation plan
Detailed step-by-step implementation plan for:
- PDF-like invoice visualization
- PDF download via html2pdf.js
- XML download endpoint
- Modal integration in CFDI page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:28:17 +00:00

22 KiB

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:

cd /root/Horux/apps/web && pnpm add html2pdf.js

Step 2: Verify installation

Run:

grep html2pdf apps/web/package.json

Expected: "html2pdf.js": "^0.10.x"

Step 3: Commit

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;):

  xmlOriginal: string | null;

Step 2: Verify types compile

Run:

cd /root/Horux && pnpm build --filter=@horux/shared

Expected: Build succeeds

Step 3: Commit

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:

export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
  const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
    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:

export async function getXmlById(schema: string, id: string): Promise<string | null> {
  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:

cd /root/Horux/apps/api && pnpm build

Expected: Build succeeds

Step 4: Commit

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:

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', ...)):

router.get('/:id/xml', cfdiController.getXml);

Step 3: Verify API compiles

Run:

cd /root/Horux/apps/api && pnpm build

Expected: Build succeeds

Step 4: Commit

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:

export async function getCfdiXml(id: string): Promise<string> {
  const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
    responseType: 'text'
  });
  return response.data;
}

Step 2: Commit

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:

'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<string, string> = {
  ingreso: 'Ingreso',
  egreso: 'Egreso',
  traslado: 'Traslado',
  pago: 'Pago',
  nomina: 'Nomina',
};

const formaPagoLabels: Record<string, string> = {
  '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<string, string> = {
  PUE: 'Pago en una sola exhibición',
  PPD: 'Pago en parcialidades o diferido',
};

export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
  ({ cfdi, conceptos }, ref) => {
    return (
      <div
        ref={ref}
        className="bg-white text-black p-8 max-w-[800px] mx-auto text-sm"
        style={{ fontFamily: 'Arial, sans-serif' }}
      >
        {/* Header */}
        <div className="flex justify-between items-start border-b-2 border-gray-800 pb-4 mb-4">
          <div className="w-32 h-20 bg-gray-200 flex items-center justify-center text-gray-500 text-xs">
            [LOGO]
          </div>
          <div className="text-right">
            <h1 className="text-2xl font-bold text-gray-800">FACTURA</h1>
            <p className="text-gray-600">
              {cfdi.serie && `Serie: ${cfdi.serie} `}
              {cfdi.folio && `Folio: ${cfdi.folio}`}
            </p>
            <p className="text-gray-600">Fecha: {formatDate(cfdi.fechaEmision)}</p>
            <span
              className={`inline-block px-2 py-1 text-xs font-semibold rounded mt-1 ${
                cfdi.estado === 'vigente'
                  ? 'bg-green-100 text-green-800'
                  : 'bg-red-100 text-red-800'
              }`}
            >
              {cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
            </span>
          </div>
        </div>

        {/* Emisor / Receptor */}
        <div className="grid grid-cols-2 gap-6 mb-6">
          <div className="border border-gray-300 p-4 rounded">
            <h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
              EMISOR
            </h3>
            <p className="font-semibold">{cfdi.nombreEmisor}</p>
            <p className="text-gray-600">RFC: {cfdi.rfcEmisor}</p>
          </div>
          <div className="border border-gray-300 p-4 rounded">
            <h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
              RECEPTOR
            </h3>
            <p className="font-semibold">{cfdi.nombreReceptor}</p>
            <p className="text-gray-600">RFC: {cfdi.rfcReceptor}</p>
            {cfdi.usoCfdi && (
              <p className="text-gray-600">Uso CFDI: {cfdi.usoCfdi}</p>
            )}
          </div>
        </div>

        {/* Datos del Comprobante */}
        <div className="border border-gray-300 p-4 rounded mb-6">
          <h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
            DATOS DEL COMPROBANTE
          </h3>
          <div className="grid grid-cols-4 gap-4 text-sm">
            <div>
              <span className="text-gray-500">Tipo:</span>
              <p className="font-medium">{tipoLabels[cfdi.tipo] || cfdi.tipo}</p>
            </div>
            <div>
              <span className="text-gray-500">Método de Pago:</span>
              <p className="font-medium">
                {cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || cfdi.metodoPago : '-'}
              </p>
            </div>
            <div>
              <span className="text-gray-500">Forma de Pago:</span>
              <p className="font-medium">
                {cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || cfdi.formaPago : '-'}
              </p>
            </div>
            <div>
              <span className="text-gray-500">Moneda:</span>
              <p className="font-medium">
                {cfdi.moneda}
                {cfdi.tipoCambio !== 1 && ` (TC: ${cfdi.tipoCambio})`}
              </p>
            </div>
          </div>
        </div>

        {/* Conceptos */}
        {conceptos && conceptos.length > 0 && (
          <div className="mb-6">
            <h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
              CONCEPTOS
            </h3>
            <table className="w-full text-sm">
              <thead>
                <tr className="bg-gray-100">
                  <th className="text-left p-2 border">Descripción</th>
                  <th className="text-center p-2 border w-20">Cant.</th>
                  <th className="text-right p-2 border w-28">P. Unit.</th>
                  <th className="text-right p-2 border w-28">Importe</th>
                </tr>
              </thead>
              <tbody>
                {conceptos.map((concepto, idx) => (
                  <tr key={idx} className="border-b">
                    <td className="p-2 border">{concepto.descripcion}</td>
                    <td className="text-center p-2 border">{concepto.cantidad}</td>
                    <td className="text-right p-2 border">
                      {formatCurrency(concepto.valorUnitario)}
                    </td>
                    <td className="text-right p-2 border">
                      {formatCurrency(concepto.importe)}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}

        {/* Totales */}
        <div className="flex justify-end mb-6">
          <div className="w-64">
            <div className="flex justify-between py-1 border-b">
              <span className="text-gray-600">Subtotal:</span>
              <span>{formatCurrency(cfdi.subtotal)}</span>
            </div>
            {cfdi.descuento > 0 && (
              <div className="flex justify-between py-1 border-b">
                <span className="text-gray-600">Descuento:</span>
                <span className="text-red-600">-{formatCurrency(cfdi.descuento)}</span>
              </div>
            )}
            {cfdi.iva > 0 && (
              <div className="flex justify-between py-1 border-b">
                <span className="text-gray-600">IVA (16%):</span>
                <span>{formatCurrency(cfdi.iva)}</span>
              </div>
            )}
            {cfdi.ivaRetenido > 0 && (
              <div className="flex justify-between py-1 border-b">
                <span className="text-gray-600">IVA Retenido:</span>
                <span className="text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
              </div>
            )}
            {cfdi.isrRetenido > 0 && (
              <div className="flex justify-between py-1 border-b">
                <span className="text-gray-600">ISR Retenido:</span>
                <span className="text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
              </div>
            )}
            <div className="flex justify-between py-2 font-bold text-lg border-t-2 border-gray-800 mt-1">
              <span>TOTAL:</span>
              <span>{formatCurrency(cfdi.total)}</span>
            </div>
          </div>
        </div>

        {/* Timbre Fiscal */}
        <div className="border-t-2 border-gray-800 pt-4">
          <h3 className="font-bold text-gray-700 mb-2">TIMBRE FISCAL DIGITAL</h3>
          <div className="grid grid-cols-2 gap-4 text-xs">
            <div>
              <p className="text-gray-500">UUID:</p>
              <p className="font-mono break-all">{cfdi.uuidFiscal}</p>
            </div>
            <div>
              <p className="text-gray-500">Fecha de Timbrado:</p>
              <p>{cfdi.fechaTimbrado}</p>
            </div>
          </div>
        </div>
      </div>
    );
  }
);

CfdiInvoice.displayName = 'CfdiInvoice';

Step 2: Commit

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:

'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<HTMLDivElement>(null);
  const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
  const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
  const [xmlContent, setXmlContent] = useState<string | null>(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 (
    <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
      <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
        <DialogHeader>
          <div className="flex items-center justify-between">
            <DialogTitle>Vista de Factura</DialogTitle>
            <div className="flex gap-2">
              <Button
                variant="outline"
                size="sm"
                onClick={handleDownloadPdf}
                disabled={downloading !== null}
              >
                {downloading === 'pdf' ? (
                  <Loader2 className="h-4 w-4 mr-1 animate-spin" />
                ) : (
                  <Download className="h-4 w-4 mr-1" />
                )}
                PDF
              </Button>
              <Button
                variant="outline"
                size="sm"
                onClick={handleDownloadXml}
                disabled={downloading !== null || !xmlContent}
                title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
              >
                {downloading === 'xml' ? (
                  <Loader2 className="h-4 w-4 mr-1 animate-spin" />
                ) : (
                  <FileText className="h-4 w-4 mr-1" />
                )}
                XML
              </Button>
            </div>
          </div>
        </DialogHeader>

        <div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
          <CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
        </div>
      </DialogContent>
    </Dialog>
  );
}

Step 2: Commit

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):

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:

const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(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:

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:

<th className="pb-3 font-medium"></th>

In the table body (inside the map, around line 1125), add before the delete button:

<td className="py-3">
  <Button
    variant="ghost"
    size="icon"
    onClick={() => handleViewCfdi(cfdi.id)}
    disabled={loadingCfdi === cfdi.id}
    title="Ver factura"
  >
    {loadingCfdi === cfdi.id ? (
      <Loader2 className="h-4 w-4 animate-spin" />
    ) : (
      <Eye className="h-4 w-4" />
    )}
  </Button>
</td>

Step 5: Add modal component

At the end of the return statement, just before the closing </>, add:

<CfdiViewerModal
  cfdi={viewingCfdi}
  open={viewingCfdi !== null}
  onClose={() => setViewingCfdi(null)}
/>

Step 6: Verify build

Run:

cd /root/Horux/apps/web && pnpm build

Expected: Build succeeds

Step 7: Commit

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:

cd /root/Horux && pnpm build

Expected: All packages build successfully

Step 2: Restart services

Run:

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

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