Saltar al contenido principal

Plan de Migración: SifenXmlBuilder DOM → Blade Templates

Fecha: 2026-03-18 Estado: PENDIENTE DE EJECUCIÓN Objetivo: Reemplazar la construcción de XML mediante DOMDocument nodo por nodo por plantillas Blade, manteniendo compatibilidad exacta con el XSD SIFEN v150 y los 95 tests existentes.


1. Por qué hacer este cambio

Problema actual

SifenXmlBuilder.php tiene 773+ líneas de código PHP construyendo XML nodo a nodo:

$gDatRec = $this->dom->createElementNS(self::NS, 'gDatRec');
$this->appendText($gDatRec, 'iNatRec', (string) $natRec);
$this->appendText($gDatRec, 'iTiOpe', (string) $iTiOpe);
$this->appendText($gDatRec, 'cPaisRec', 'PRY');
// ... 20 líneas más
$parent->appendChild($gDatRec);

Problemas concretos:

  • El orden xs:sequence es invisible. Para agregar un campo en la posición correcta hay que contar appendText() y comparar contra el XSD o docs/camposformatosv150.md. Un error de orden produce 0160 "XML Mal Formado" sin mensaje útil.
  • El bug dCodInt (campo obligatorio omitido silenciosamente) existe porque appendText() ignora nulls — su comportamiento "silencioso" es correcto para campos 0-1 pero peligroso para campos 1-1. El root cause es que no hay forma visual de ver la diferencia.
  • Agregar una nueva sección (ej: gCamAE para autofacturas, o gCamNCDE para notas de crédito) requiere rastrear la secuencia XML en el código PHP, lo cual es frágil y lento.

Solución propuesta

Separar la lógica PHP (cálculos, lookups, fallbacks) de la estructura XML (orden de tags):

SifenXmlBuilder
├── prepareData(SifenDte): array ← PHP puro: cálculos, formateos, fallbacks
└── build(SifenDte): string ← llama view('sifen.de', $data)->render()

resources/views/sifen/de.blade.php ← el XML "dibujado": el orden es visualmente obvio

2. Restricciones técnicas críticas (NO negociables)

Antes de implementar, entender por qué el actual DOM builder tiene sus configuraciones:

2.1 Whitespace y la firma XMLDSig

El SifenXmlDsigSigner carga el XML con LIBXML_NOBLANKS:

$dom->preserveWhiteSpace = false;
$dom->loadXML($xmlUnsigned, LIBXML_NOBLANKS);

Esto elimina todos los text nodes de whitespace (espacios, \n, \t entre tags) antes de computar el DigestValue. Consecuencia directa:

El whitespace que Blade genera entre tags es irrelevante — el Signer lo elimina antes de firmar.

La indentación visual del template NO afecta la firma. ✅

2.2 Namespace SIFEN

El namespace http://ekuatia.set.gov.py/sifen/xsd debe declararse en <rDE> como namespace default (xmlns=). Los elementos hijos lo heredan automáticamente — no es necesario repetirlo en cada tag.

En Blade:

<rDE xmlns="http://ekuatia.set.gov.py/sifen/xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://ekuatia.set.gov.py/sifen/xsd siRecepDE_v150.xsd">

El XPath //sifen:DE del Signer ($xpath->registerNamespace('sifen', self::SIFEN_NS)) resuelve correctamente porque todos los elementos pertenecen al namespace por herencia. ✅

2.3 Tags vacíos: la regla de oro de SIFEN

SIFEN rechaza tags vacíos de elementos opcionales (Guía Mejores Prácticas, Regla 5). El test no genera etiquetas vacias cuando campos opcionales son null lo verifica.

En Blade, la solución es @if:

@if($clienteEmail)
<dEmailRec>{{ $clienteEmail }}</dEmailRec>
@endif

Excepción: <dCarQR></dCarQR> DEBE emitirse vacío — es un placeholder para SifenQrService.

2.4 Escaping automático de {{ }}

Blade usa htmlspecialchars() en {{ }}, que convierte &&amp;, <&lt;, >&gt;. Estos son exactamente los escapes válidos en XML. No usar {!! !!} — todos los valores son texto plano.

2.5 Sin declaración XML en el output

El build() actual usa saveXML($root) que omite <?xml ...?>. En Blade, simplemente no escribir la declaración XML en la primera línea del template.

El resultado de view()->render() puede tener whitespace al inicio/final → hacer trim() en build().

2.6 SifenQrService lee desde el XML firmado (no desde el Builder)

SifenQrService extrae todos los valores del XML firmado con XPath (preserveWhiteSpace=true). El Builder solo necesita producir un XML con la estructura correcta — los valores del QR se extraen post-firma y no dependen del formato del Builder.


3. Arquitectura resultante

3.1 Estructura de archivos (después de la migración)

app/Domains/Sifen/Services/Xml/
SifenXmlBuilder.php ← reducido a: prepareData() + build()
(sin buildGTimb, buildGEmis, etc.)

resources/views/sifen/
de.blade.php ← raíz: rDE + dVerFor + DE + gCamFuFD

partials/
g_ope_de.blade.php ← gOpeDE: iTipEmi, dDesTipEmi, dCodSeg
g_timb.blade.php ← gTimb: iTiDE, dNumTim, dEst, dPunExp...
g_dat_gral_ope.blade.php ← wrapper: dFeEmiDE + @include gOpeCom + gEmis + gDatRec
g_opec_om.blade.php ← gOpeCom: iTipTra, iTImp, cMoneOpe...
g_emis.blade.php ← gEmis: dRucEm, dNomEmi, direccion, gActEco
g_dat_rec.blade.php ← gDatRec: iNatRec, iTiOpe, receptor B2B/B2C
g_dtip_de.blade.php ← gDtipDE: gCamFE + gCamCond + @foreach items
g_cam_cond.blade.php ← gCamCond: iCondOpe, gPaConEIni / gPagCred
g_cam_item.blade.php ← gCamItem: dCodInt, dDesProSer, gValorItem, gCamIVA
g_tot_sub.blade.php ← gTotSub: grupos 1-2-3-5 en orden Java §7

3.2 Interfaz pública: sin cambios

// Antes y después — misma firma:
$xml = (new SifenXmlBuilder())->build($dte); // retorna string XML sin <?xml?>

Los tests, Jobs, y el pipeline completo (Signer → QrService → SOAP) no cambian.


4. Paso a paso de implementación

PASO 1 — Crear prepareData() en SifenXmlBuilder

Extraer toda la lógica PHP del builder actual a un método que retorna un array plano. No cambiar ninguna lógica — solo mover el código de los buildGxxx() privados al array.

private function prepareData(SifenDte $dte): array
{
$dte->loadMissing(['emitter', 'items']);
$emitter = $dte->emitter;
$config = $emitter->config ?? [];
$cdc = $dte->cdc;
$moneda = $dte->moneda ?? 'PYG';

// ---- TIMB ----
$iTiDE = (int) substr($cdc, 0, 2);

// ---- FECHAS ----
$cdcDate = substr($cdc, 25, 8); // YYYYMMDD
$isoDate = substr($cdcDate, 0, 4).'-'.substr($cdcDate, 4, 2).'-'.substr($cdcDate, 6, 2);
$time = $dte->fecha_emision?->format('H:i:s') ?? date('H:i:s');
$dFeEmiDE = $isoDate . 'T' . $time;

// ---- RECEPTOR ----
$natRec = $this->resolveNaturalezaReceptor($dte);
// ... etc.

// ---- ITEMS ----
$items = $dte->items->map(fn($item) => $this->prepareItem($item, $config, $moneda))->all();

// ---- TOTALES (gTotSub) ----
// ... calcular dSubExe, dSub5, dSub10, dTotGralOpe, dRedon, dIVA5, dIVA10, etc.

return compact(
'cdc', 'moneda', 'iTiDE', 'dFeEmiDE',
'emitter', 'config', 'dte', 'items',
'natRec', /* ... todos los valores calculados */
);
}

Referencia: La lógica de cada sección está documentada en docs/camposformatosv150.md (tabla completa de tags) y en los comentarios del builder actual.


PASO 2 — Crear resources/views/sifen/de.blade.php

El template principal. Estructura base:

{-- resources/views/sifen/de.blade.php --}
<rDE xmlns="http://ekuatia.set.gov.py/sifen/xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://ekuatia.set.gov.py/sifen/xsd siRecepDE_v150.xsd">
<dVerFor>150</dVerFor>
<DE Id="{{ $cdc }}">
<dDVId>{{ substr($cdc, 43, 1) }}</dDVId>
<dFecFirma>{{ $dFecFirma }}</dFecFirma>
<dSisFact>1</dSisFact>
@include('sifen.partials.g_ope_de')
@include('sifen.partials.g_timb')
@include('sifen.partials.g_dat_gral_ope')
@include('sifen.partials.g_dtip_de')
@include('sifen.partials.g_tot_sub')
</DE>
<gCamFuFD><dCarQR></dCarQR></gCamFuFD>
</rDE>

Notas:

  • Sin indentación entre tags: el Signer la elimina con LIBXML_NOBLANKS, pero tener el XML compacto desde el inicio es más limpio.
  • <dCarQR></dCarQR> se emite siempre vacíoSifenQrService::inyectarQr() lo rellena post-firma.
  • El orden dVerFor → DE → gCamFuFD es el xs:sequence de rDE (ver docs/DE_v150.xsd).

PASO 3 — Crear partials en orden (uno a la vez, testeando después de cada uno)

Orden recomendado: empezar por las secciones más simples y terminar con las más complejas.

3a. g_ope_de.blade.php

<gOpeDE>
<iTipEmi>{{ $iTipEmi }}</iTipEmi>
<dDesTipEmi>{{ $dDesTipEmi }}</dDesTipEmi>
<dCodSeg>{{ $dCodSeg }}</dCodSeg>
@if($dInfoEmi)<dInfoEmi>{{ $dInfoEmi }}</dInfoEmi>@endif
@if($dInfoFisc)<dInfoFisc>{{ $dInfoFisc }}</dInfoFisc>@endif
</gOpeDE>

3b. g_timb.blade.php

Campos: iTiDE → dDesTiDE → dNumTim → dEst → dPunExp → dNumDoc → dSerieNum(0-1) → dFeIniT (ref: docs/camposformatosv150.md sección A — Datos del Timbrado)

3c. g_dat_gral_ope.blade.php

Wrapper que incluye g_opec_om, g_emis, y condicionalmente g_dat_rec.

3d. g_opec_om.blade.php

Campos: iTipTra → dDesTipTra → iTImp → dDesTImp → cMoneOpe → dDesMoneOpe → dCondTiCam(0-1) → dTiCam(0-1)

Caso especial:

@if($moneda !== 'PYG')
<dCondTiCam>{{ $dCondTiCam }}</dCondTiCam>
<dTiCam>{{ $dTiCam }}</dTiCam>
@endif

3e. g_emis.blade.php

Campos: dRucEm → dDVEmi → iTipCont → dNomEmi → dNomFanEmi → dDirEmi → dNumCas → cDepEmi → dDesDepEmi → cDisEmi(0-1) → dDesDisEmi(0-1) → cCiuEmi → dDesCiuEmi → dTelEmi(0-1) → dEmailE → gActEco(1-9)

<gEmis>
<dRucEm>{{ $emisorRuc }}</dRucEm>
...
@foreach($actividadesEconomicas as $act)
<gActEco>
<cActEco>{{ $act['codigo'] }}</cActEco>
<dDesActEco>{{ $act['descripcion'] }}</dDesActEco>
</gActEco>
@endforeach
</gEmis>

3f. g_dat_rec.blade.php

Caso contribuyente vs no contribuyente (ver docs/SIFEN_JAVA_REFERENCE.md §3):

<gDatRec>
<iNatRec>{{ $natRec }}</iNatRec>
<iTiOpe>{{ $iTiOpe }}</iTiOpe>
<cPaisRec>PRY</cPaisRec>
<dDesPaisRe>Paraguay</dDesPaisRe>
@if($natRec === 1)
<iTiContRec>{{ $iTiContRec }}</iTiContRec>
<dRucRec>{{ $dRucRec }}</dRucRec>
<dDVRec>{{ $dDVRec }}</dDVRec>
@else
<iTipIDRec>1</iTipIDRec>
<dDTipIDRec>Cédula de identidad</dDTipIDRec>
<dNumIDRec>{{ $dNumIDRec }}</dNumIDRec>
@endif
<dNomRec>{{ $dNomRec }}</dNomRec>
@if($dEmailRec)<dEmailRec>{{ $dEmailRec }}</dEmailRec>@endif
</gDatRec>

3g. g_cam_cond.blade.php

<gCamCond>
<iCondOpe>{{ $iCondOpe }}</iCondOpe>
<dDCondOpe>{{ $dDCondOpe }}</dDCondOpe>
@if($iCondOpe === 1)
@include('sifen.partials.g_pa_con_e_ini')
@elseif($iCondOpe === 2)
@include('sifen.partials.g_pag_cred')
@endif
</gCamCond>

3h. g_cam_item.blade.php (el más crítico)

Fix del bug dCodInt — usar '0' como fallback:

<gCamItem>
<dCodInt>{{ $item['codInt'] }}</dCodInt>
@if($item['dParAranc'])<dParAranc>{{ $item['dParAranc'] }}</dParAranc>@endif
<dDesProSer>{{ $item['descripcion'] }}</dDesProSer>
<cUniMed>{{ $item['cUniMed'] }}</cUniMed>
<dDesUniMed>{{ $item['dDesUniMed'] }}</dDesUniMed>
<dCantProSer>{{ $item['cantidad'] }}</dCantProSer>
<gValorItem>
<dPUniProSer>{{ $item['precioUnitario'] }}</dPUniProSer>
<dTotBruOpeItem>{{ $item['totalBruto'] }}</dTotBruOpeItem>
<gValorRestaItem>
<dDescItem>{{ $item['descuento'] }}</dDescItem>
<dPorcDesIt>0.00</dPorcDesIt>
<dAntPreUniIt>0</dAntPreUniIt>
<dAntGloPreUniIt>0</dAntGloPreUniIt>
<dTotOpeItem>{{ $item['totalLinea'] }}</dTotOpeItem>
@if($moneda !== 'PYG')<dTotOpeGs>{{ $item['totalLinea'] }}</dTotOpeGs>@endif
</gValorRestaItem>
</gValorItem>
<gCamIVA>
<iAfecIVA>{{ $item['iAfecIVA'] }}</iAfecIVA>
<dDesAfecIVA>{{ $item['dDesAfecIVA'] }}</dDesAfecIVA>
<dPropIVA>{{ $item['dPropIVA'] }}</dPropIVA>
<dTasaIVA>{{ $item['tasa'] }}</dTasaIVA>
<dBasGravIVA>{{ $item['basGravIVA'] }}</dBasGravIVA>
<dLiqIVAItem>{{ $item['liqIVA'] }}</dLiqIVAItem>
<dBasExe>{{ $item['basExe'] }}</dBasExe>
</gCamIVA>
</gCamItem>

Fix del bug dCodInt en prepareData():

'codInt' => ($item->codigo !== null && trim($item->codigo) !== '')
? $item->codigo
: '0',

3i. g_tot_sub.blade.php

El orden Java §7 (ver docs/SIFEN_LOGIC_REFERENCE.md y docs/SIFEN_JAVA_REFERENCE.md): GRUPO 1 → GRUPO 2 → GRUPO 3 → GRUPO 5

<gTotSub>
<dSubExe>{{ $dSubExe }}</dSubExe>
<dSubExo>{{ $dSubExo }}</dSubExo>
<dSub5>{{ $dSub5 }}</dSub5>
<dSub10>{{ $dSub10 }}</dSub10>
<dTotOpe>{{ $dTotOpe }}</dTotOpe>
<dTotDesc>{{ $dTotDesc }}</dTotDesc>
<dTotDescGlotem>0</dTotDescGlotem>
<dTotAntItem>0</dTotAntItem>
<dTotAnt>0</dTotAnt>
<dPorcDescTotal>0</dPorcDescTotal>
<dDescTotal>0</dDescTotal>
<dAnticipo>0</dAnticipo>
<dRedon>{{ $dRedon }}</dRedon>
<dComi>0</dComi>
<dTotGralOpe>{{ $dTotGralOpe }}</dTotGralOpe>
<dIVA5>{{ $dIVA5 }}</dIVA5>
<dIVA10>{{ $dIVA10 }}</dIVA10>
<dLiqTotIVA5>0</dLiqTotIVA5>
<dLiqTotIVA10>0</dLiqTotIVA10>
<dIVAComi>0</dIVAComi>
<dTotIVA>{{ $dTotIVA }}</dTotIVA>
<dBaseGrav5>{{ $dBaseGrav5 }}</dBaseGrav5>
<dBaseGrav10>{{ $dBaseGrav10 }}</dBaseGrav10>
<dTBasGraIVA>{{ $dTBasGraIVA }}</dTBasGraIVA>
</gTotSub>

PASO 4 — Modificar build() para usar view()->render()

public function build(SifenDte $dte): string
{
$data = $this->prepareData($dte);

// trim() elimina whitespace inicial/final que Blade puede agregar.
$xml = trim(view('sifen.de', $data)->render());

// Verificación de seguridad: el output no debe tener declaración XML.
// (Blade no la genera si no está en el template, pero doble check por si acaso.)
if (str_starts_with($xml, '<?xml')) {
$xml = (string) preg_replace('/<\?xml[^>]+\?>\s*/i', '', $xml);
}

return $xml;
}

PASO 5 — Correr los 95 tests

php artisan test tests/Unit/Domains/Sifen/Services/Xml/

Todos los tests existentes deben pasar sin modificación.

Si alguno falla, el error indicará exactamente qué tag falta o está en el orden incorrecto — la ventaja de tener tests de XPath y orden de grupos.


PASO 6 — Validación contra XSD local

Después de que los tests pasen, validar el XML generado contra el XSD real:

php artisan tinker
$dte = App\Domains\Sifen\Models\SifenDte::with(['emitter','items'])->find(1);
$xml = (new App\Domains\Sifen\Services\Xml\SifenXmlBuilder())->build($dte);

$dom = new DOMDocument();
$dom->loadXML('<root xmlns="http://ekuatia.set.gov.py/sifen/xsd">' . $xml . '</root>');
$result = $dom->schemaValidate(base_path('docs/siRecepDE_v150.xsd'));
dump($result); // true = válido

O usar el script existente en scripts/tinker_build_validate_dod.php.


5. Casos especiales y trampas conocidas

5.1 dCodInt — Fix del bug conocido

En el builder actual:

$this->appendText($gCamItem, 'dCodInt', $item->codigo); // BUG: omite si null

En prepareData() del nuevo builder:

'codInt' => ($item->codigo !== null && trim($item->codigo) !== '') ? $item->codigo : '0',

El tag <dCodInt>0</dCodInt> es válido según XSD (minLength=1). Ver docs/SESION_2026-03-18_sendSync.md §3 para el análisis completo.

5.2 dNomEmi literal de prueba

En ambiente test, el Manual Técnico v150 §D105 exige el literal exacto:

DE generado en ambiente de prueba - sin valor comercial ni fiscal

En prepareData():

'dNomEmi' => $isTest
? 'DE generado en ambiente de prueba - sin valor comercial ni fiscal'
: $emitter->razon_social,

5.3 gCamGen — nunca emitir vacío

gCamGen es minOccurs="0" con todos los hijos también opcionales. La Guía Mejores Prácticas SIFEN (Regla 5) prohíbe tags vacíos. No incluir gCamGen en ningún template hasta que haya datos reales para emitir.

5.4 SEDECO rounding en dRedon

El cálculo debe emitirse con 4 decimales:

'dRedon' => number_format(abs(fmod($dTotOpe, 50.0)), 4, '.', ''),

En el template: <dRedon>{{ $dRedon }}</dRedon> Ver docs/SIFEN_LOGIC_REFERENCE.md §gTotSub para la referencia Java de este cálculo.

5.5 preserveWhiteSpace en QrService

SifenQrService::inyectarQr() carga el XML firmado con preserveWhiteSpace=true para no invalidar la firma al re-serializar. El XML firmado viene del Signer (que usó LIBXML_NOBLANKS), por lo que es compacto. No hay impacto del whitespace de Blade en este punto.

5.6 Blade en contexto de Job (sin HTTP request)

view()->render() funciona en Jobs porque SifenXmlBuilder es instanciado dentro de BuildAndSignDteJob::handle(), que corre en un worker con la aplicación Laravel completamente booteada. La caché de Blade (en storage/framework/views/) es accesible desde workers. ✅

5.7 Tests que usan make() sin DB

Los helpers makeDte() y makeItem() en los tests crean modelos sin persistir. view()->render() no necesita DB — solo el array de datos preparado por prepareData(). ✅


6. Validación final antes de merge

CheckCómo verificar
95 tests pasandophp artisan test tests/Unit/Domains/Sifen/Services/Xml/
XML válido contra XSDscripts/tinker_build_validate_dod.php
No hay tags vacíosTest existente no genera etiquetas vacias...
Orden xs:sequenceTest existente los grupos hijos de DE siguen el orden mandatorio...
Sin declaración XMLTest existente build() no emite declaracion XML (Fix #6)
Pre-validador SIFENSubir XML a https://ekuatia.set.gov.py/prevalidador/

7. Rollback

El commit 0bf4612 (en origin/main) contiene el SifenXmlBuilder DOM funcional.

Para restaurar en caso de falla:

git checkout 0bf4612 -- app/Domains/Sifen/Services/Xml/SifenXmlBuilder.php
# Eliminar las views de Blade:
rm -rf resources/views/sifen/

8. Referencias

DocumentoContenido relevante
docs/camposformatosv150.mdTabla completa de tags XML v150, tipos, longitudes, ocurrencias
docs/DE_v150.xsdXSD oficial SIFEN v150 — fuente de verdad del xs:sequence
docs/SIFEN_JAVA_REFERENCE.mdImplementación Java de referencia (§3 receptor, §7 gTotSub)
docs/SIFEN_LOGIC_REFERENCE.mdLógica operativa SEDECO rounding, totales
docs/SIFEN_OPERATIVE_LOGIC.mdAlgoritmos CDC, firma XMLDSig, parámetros exactos
docs/SESION_2026-03-18_sendSync.mdBug dCodInt, análisis whitespace Signature, error 0160
docs/manual150cap7.mdCap 7 manual técnico: comunicación y estándares XML
tests/Unit/Domains/Sifen/Services/Xml/Suite de tests a mantener en verde
scripts/tinker_build_validate_dod.phpScript DoD de validación local