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
DOMDocumentnodo 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 odocs/camposformatosv150.md. Un error de orden produce0160 "XML Mal Formado"sin mensaje útil. - El bug
dCodInt(campo obligatorio omitido silenciosamente) existe porqueappendText()ignora nulls — su comportamiento "silencioso" es correcto para campos0-1pero peligroso para campos1-1. El root cause es que no hay forma visual de ver la diferencia. - Agregar una nueva sección (ej:
gCamAEpara autofacturas, ogCamNCDEpara 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 & → &, < → <, > → >. 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ío —SifenQrService::inyectarQr()lo rellena post-firma.- El orden
dVerFor → DE → gCamFuFDes el xs:sequence derDE(verdocs/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
| Check | Cómo verificar |
|---|---|
| 95 tests pasando | php artisan test tests/Unit/Domains/Sifen/Services/Xml/ |
| XML válido contra XSD | scripts/tinker_build_validate_dod.php |
| No hay tags vacíos | Test existente no genera etiquetas vacias... |
| Orden xs:sequence | Test existente los grupos hijos de DE siguen el orden mandatorio... |
| Sin declaración XML | Test existente build() no emite declaracion XML (Fix #6) |
| Pre-validador SIFEN | Subir 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
| Documento | Contenido relevante |
|---|---|
docs/camposformatosv150.md | Tabla completa de tags XML v150, tipos, longitudes, ocurrencias |
docs/DE_v150.xsd | XSD oficial SIFEN v150 — fuente de verdad del xs:sequence |
docs/SIFEN_JAVA_REFERENCE.md | Implementación Java de referencia (§3 receptor, §7 gTotSub) |
docs/SIFEN_LOGIC_REFERENCE.md | Lógica operativa SEDECO rounding, totales |
docs/SIFEN_OPERATIVE_LOGIC.md | Algoritmos CDC, firma XMLDSig, parámetros exactos |
docs/SESION_2026-03-18_sendSync.md | Bug dCodInt, análisis whitespace Signature, error 0160 |
docs/manual150cap7.md | Cap 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.php | Script DoD de validación local |