Saltar al contenido principal

SIFEN — Implementación de Bajo Nivel (Referencia Maestra OnnixConnect OC)

Origen del análisis: proyecto sifen_laravel_muestra (Laravel 10/11, PHP 8.x) Propósito: documentar la lógica de bajo nivel que garantiza compatibilidad estricta con la DNIT/SET Restricción: este documento describe pasos técnicos y parámetros — no copia el código fuente completo


Índice

  1. Cliente SOAP Personalizado — cURL vs SoapClient nativo
  2. Construcción Manual de XML — DOMDocument
  3. Lógica Maestra del QR — Independencia Total
  4. Utilidades Matemáticas — CDC y Dígito Verificador (DV)
  5. Flujo de Envío Síncrono — Orden exacto de pasos
  6. Configuración de entorno requerida

1. Cliente SOAP Personalizado

Archivo analizado: app/Services/Sifen/SifenSoapClient.php

1.1 Por qué cURL en lugar de SoapClient nativo de PHP

El SoapClient nativo de PHP tiene limitaciones críticas para SIFEN:

  • No soporta correctamente SOAP 1.2 sin configuración manual del Content-Type.
  • No permite adjuntar un certificado .p12 (PKCS#12) para mTLS de forma sencilla.
  • El proxy F5 BIG-IP de la SET devuelve redirects 302 que el SoapClient no sigue correctamente.
  • El SoapClient intenta parsear el WSDL en cada instancia, introduciendo latencia innecesaria.

1.2 Headers de SOAP 1.2 — Configuración exacta

El header Content-Type es la diferencia clave entre SOAP 1.1 y 1.2:

Content-Type: application/soap+xml; charset=utf-8; action="rEnviDe"
Accept: application/soap+xml, text/xml, */*
Cache-Control: no-cache
Pragma: no-cache
Connection: keep-alive
Expect: (vacío — suprime el handshake 100-continue)

Puntos críticos:

  • action="rEnviDe" va dentro del Content-Type, no como header separado SOAPAction.
  • Expect: se pasa vacío para evitar que proxies intermedios rompan el handshake al recibir el cuerpo de una vez.
  • Connection: keep-alive mejora la compatibilidad con el BIG-IP de la SET.

1.3 Handshake mTLS con certificado .p12

El servidor de la SET (HTTPS) puede exigir autenticación mutua de cliente (mTLS). La cadena de configuración cURL es:

Opción cURLValorDescripción
CURLOPT_SSLCERTTYPE'P12'Indica que el certificado es PKCS#12 (no PEM)
CURLOPT_SSLCERTruta absoluta al .p12Archivo que contiene cert + clave privada + chain
CURLOPT_SSLCERTPASSWDcontraseña del .p12Passphrase del almacén PKCS#12

Nota: cURL con CURLOPT_SSLCERTTYPE='P12' extrae internamente tanto el certificado cliente como la clave privada del mismo archivo .p12. No se necesita exportar un .pem separado.

La flag $useMtls (booleano) permite desactivar mTLS en ambientes de TEST donde el WS no lo exija, sin cambiar código.

1.4 Opciones de conexión anti-F5 BIG-IP

Opción cURLValorRazón
CURLOPT_FOLLOWLOCATIONtrueSigue redirects 302 del proxy BIG-IP
CURLOPT_MAXREDIRS5Límite de redirects para evitar loops
CURLOPT_USERAGENT'Mozilla/5.0 ... SifenLaravelClient/1.0'El BIG-IP rechaza UA vacíos o de bot
CURLOPT_HTTP_VERSIONCURL_HTTP_VERSION_1_1HTTP/2 puede romper chunked con el F5
CURLOPT_CONNECTTIMEOUT30 segundosTimeout de establecimiento de conexión
CURLOPT_TIMEOUT180 segundosTimeout total (el WS de SIFEN puede demorar)
CURLOPT_SSL_VERIFYPEERfalse (TEST) / true + CA bundle (PROD)En Windows/XAMPP el CA chain falla sin bundle
CURLOPT_SSL_VERIFYHOSTfalse (TEST) / 2 (PROD)Idem anterior

1.5 Manejo de la respuesta y separación headers/body

cURL con CURLOPT_HEADER=true devuelve un string único con headers HTTP + body SOAP. La separación es:

headerSize = curl_getinfo($ch)['header_size']
rawHeaders = substr($raw, 0, headerSize)
body = substr($raw, headerSize)

El retorno del método incluye: endpoint, http_code, headers_raw, response (body XML) y curl_info para debugging.

1.6 Errores de conexión — Manejo

  • curl_exec() retorna false en error de red/timeout → se lanza \Exception con curl_error() + curl_errno() + curl_getinfo().
  • El http_code del retorno debe verificarse externamente (200 = éxito, otro = error del servidor).
  • El body del error de la SET viene en XML SOAP fault — se debe parsear con DOMDocument/SimpleXML para extraer el código y mensaje.

1.7 Resolución del endpoint (desde WSDL URL)

El config puede contener la URL del WSDL (?wsdl o .wsdl). El método resolveEndpoint() normaliza esto quitando ?wsdl (case-insensitive) y .wsdl del final, obteniendo la URL del endpoint real para POST.


2. Construcción Manual de XML

Archivo analizado: app/Services/Sifen/DocumentoElectronico.php

2.1 Estrategia general — DOMDocument vs SimpleXML/Heredoc

Se usa DOMDocument (no SimpleXML ni heredoc) porque:

  • Permite marcar el atributo Id como tipo ID real (setIdAttributeNode()), requerido para que XMLSecurityDSig resuelva la referencia #CDC en la firma.
  • Garantiza escape correcto de & en URLs del QR (createTextNode() escapa automáticamente).
  • Ofrece control total del orden de nodos (XSD de SIFEN es sequence-ordered).

Parámetros críticos del DOMDocument:

preserveWhiteSpace = false → Elimina nodos de texto con whitespace
formatOutput = false → Sin indentación (sin \n ni \t entre tags)

Crítico para la firma: cualquier espacio en blanco entre nodos altera el DigestValue. formatOutput=false es obligatorio.

2.2 Estructura del envelope SOAP

La estructura es SOAP 1.2 con namespace env: (no soapenv: de SOAP 1.1):

env:Envelope [xmlns:env="http://www.w3.org/2003/05/soap-envelope"]
env:Header (vacío)
env:Body
rEnviDe [xmlns="http://ekuatia.set.gov.py/sifen/xsd"]
dId (número de correlativo de envío)
xDE
rDE [xmlns:xsi + xsi:schemaLocation]
dVerFor
DE [Id="{CDC}"] ← atributo marcado como tipo ID

2.3 El atributo Id del nodo DE — Por qué es crítico

$DE->setAttribute('Id', $this->Id); // CDC completo (44 dígitos)
$attr = $DE->getAttributeNode('Id');
$DE->setIdAttributeNode($attr, true); // Marca como tipo XML ID

Sin este setIdAttributeNode(), la librería robrichards/xmlseclibs no puede localizar el nodo por referencia URI="#CDC" durante la firma, produciendo una firma inválida que SIFEN rechaza.

2.4 Construcción por grupos — Orden obligatorio

El XSD de SIFEN define los grupos como xs:sequence, por lo que el orden es mandatorio:

OrdenGrupoContenido principal
1gOpeDEiTipEmi (tipo emisión), dCodSeg (código seguridad 9 dígitos)
2gTimbiTiDE, dNumTim, dEst, dPunExp, dNumDoc, dFeIniT
3gDatGralOpedFeEmiDE, sub-grupos gOpeCom, gEmis, gDatRec
4gDtipDEgCamFE, gCamCond, gCamItem (uno por ítem)
5gTotSubTotales: dTotOpe, dTotGralOpe, dTotIVA, etc.
6SignatureInsertado por SignatureHelper (xmlseclibs)
7gCamFuFDdCarQRSIEMPRE al final, después de la firma

2.5 Helpers de nodos — Por qué no se crean nodos vacíos

El método appendText() verifica null y trim() === '' antes de crear el nodo. Esto es esencial porque:

  • Tags vacíos como <dNomFanEmi/> no pasan la validación XSD de la SET.
  • El campo es simplemente omitido si no tiene valor (comportamiento diferente a poner cadena vacía).

Los tipos de helpers y sus reglas de formato:

HelperFormato de salidaUso
appendTextstring literal (trim)campos alfanuméricos
appendIntcast a (int) → stringcampos numéricos enteros
appendDecnumber_format(n, scale, '.')montos — sin separador de miles, punto decimal
appendDateY-m-dfechas simples
appendDateTimeY-m-d\TH:i:sfecha+hora ISO 8601 sin offset TZ

Crítico: number_format($val, 0, '.', '') para montos en PYG garantiza que no aparezca separador de miles ni notación científica para números grandes. PHP puede usar notación científica (1.0E+6) si se hace cast directo a string.

2.6 Secuencia dentro de toSoapXmlEnviDe()

1. buildSoapEnvelopeEnv() → DOMDocument con env:Envelope/Header/Body
2. Crear rEnviDe con xmlns SIFEN
3. Crear xDE > rDE con xsi:schemaLocation
4. computeCdc() → calcula Id (CDC 44 dígitos) + dDVId
5. Crear nodo DE con Id=CDC, markIdAttribute()
6. appendGOpeDE, appendGTimb, appendGDatGralOpe, appendGDtipDE, appendGTotSub
7. SignatureHelper::signDocument() → firma el nodo rDE, referencia #CDC
8. SifenQrHelper::generateQrUrlFromSignedDocument() → extrae DigestValue de la firma
9. Crear gCamFuFD > dCarQR con createTextNode(enlaceQR)
10. $doc->saveXML()

3. Lógica Maestra del QR

Archivo analizado: app/Services/Sifen/SifenQrHelper.php

3.1 Por qué el QR se genera DESPUÉS de la firma

El parámetro DigestValue del QR es el hash SHA-256 del nodo DE canonicalizado, calculado durante la firma digital XML. Este valor solo existe en el documento después de que SignatureHelper::signDocument() inserté el nodo ds:Signature. Por lo tanto, el orden es:

firma → DigestValue disponible → generar URL QR → insertar en gCamFuFD

3.2 Extracción del DigestValue

Ruta XPath dentro del XML firmado:

//ds:Signature//ds:SignedInfo//ds:Reference[1]//ds:DigestValue

Namespace registrado: dshttp://www.w3.org/2000/09/xmldsig#

El valor en el XML es un string Base64 (resultado de SHA-256 del nodo canonicalizado). Este string Base64 se procesa en dos pasos para replicar la lógica de la implementación Java de referencia (Roshka):

Paso 1: base64_decode(digestB64) → $bytes (array de bytes del hash SHA-256)
Paso 2: base64_encode($bytes) → $b64Again (misma cadena base64 original)
Paso 3: bin2hex($b64Again) → $digestHex (representación hex del string base64)

Por qué este doble proceso: la implementación Java toma los bytes del digest, los re-codifica en Base64 y luego convierte ese string Base64 en hexadecimal. El resultado es el hex del string Base64, no el hex de los bytes crudos del hash.

3.3 Parámetros de la URL QR — Orden exacto

El orden de los parámetros es fijo y mandatorio (el hash final depende del orden):

ParámetroOrigenFormato
1nVersionconfig('sifen.version') (ej: 150)entero
2IdCDC completo (44 dígitos)string numérico
3dFeEmiDEfecha emisión del DEhex UTF-8 de Y-m-d\TH:i:s
4adRucRecRUC del receptor (si iNatRec = 1)string
4bdNumIDRecNro documento receptor (si iNatRec ≠ 1)string o '0'
5dTotGralOpeTotal general de la operaciónentero string
6dTotIVATotal IVAentero string
7cItemsCantidad de ítems (count(gCamItemList))entero string
8DigestValueHex del Base64 del DigestValue firmadohex string
9IdCSCconfig('sifen.id_csc')string (ej: 0001)

Nota: el parámetro 4 es mutuamente excluyente: si iNatRec=1 (persona jurídica/contribuyente) se usa dRucRec; de lo contrario dNumIDRec.

3.4 Construcción del hash cHashQR

queryString = "nVersion=150&Id=...&dFeEmiDE=...&dRucRec=...&...&IdCSC=0001"
cHashQR = sha256_hex( queryString + CSC )

Donde CSC es el Código de Seguridad del Contribuyente completo (no el IdCSC). El resultado es un hash SHA-256 en lowercase hexadecimal (64 caracteres).

3.5 Construcción final de la URL

URL = rtrim(url_consulta_qr, '?') + '?' + queryString + '&cHashQR=' + cHashQR

La función buildUrlParams() en el helper no aplica urlencode() a los valores. Esto es intencional: todos los valores son numéricos o hexadecimales, sin caracteres que requieran encoding. Aplicar urlencode alteraría el hash si algún valor contuviese + o = (que pueden aparecer en Base64).

3.6 Codificación de dFeEmiDE en hex

$feEmiStr = Carbon::parse($feEmi)->format('Y-m-d\TH:i:s');
// Ejemplo: "2025-01-15T10:30:00"
$feEmiHex = bin2hex($feEmiStr);
// Resultado: "323032352d30312d31355431303a33303a3030"

La cadena fecha se trata como UTF-8 (que para caracteres ASCII es idéntico a bytes), y se convierte directamente a hexadecimal. Es una codificación de bytes, no un hash.


4. Utilidades Matemáticas

Archivo analizado: app/Services/Sifen/SifenUtil.php

4.1 Algoritmo Módulo 11 para el Dígito Verificador

El mismo algoritmo aplica para el DV del RUC y para el DV del CDC (dDVId). El proceso es:

Pesos utilizados: [2, 3, 4, 5, 6, 7] (cíclico, de derecha a izquierda)

Pasos:

1. Recorrer el string de derecha a izquierda (i = len-1 → 0)
2. Multiplicar cada dígito por su peso cíclico (índice % 6)
3. Acumular la suma
4. mod = suma % 11
5. dv = 11 - mod
6. Si dv == 11 → dv = 0
7. Si dv == 10 → dv = 1

Sobre el manejo de números grandes (BigInt):

PHP no tiene tipo nativo BigInt, pero opera con strings carácter a carácter en el loop for. El código procesa $input[$i] (un carácter) con (int) cast, por lo que nunca hay desbordamiento aritmético. El CDC tiene 44 dígitos — trabajar carácter por carácter elimina el problema que tendría si se intentara hacer (int)$cdcCompleto (que desbordaría).

No se usa gmp_* ni bcmath porque el algoritmo procesa un dígito a la vez, nunca el número completo como entero.

4.2 Padding de campos del CDC

Antes de concatenar para el CDC, cada segmento se rellena con ceros a la izquierda:

CampoLongitudPadding
iTiDE2str_pad($v, 2, '0', LEFT)
dRucEm8str_pad($v, 8, '0', LEFT)
dDVEmi1sin padding
dEst3str_pad($v, 3, '0', LEFT)
dPunExp3str_pad($v, 3, '0', LEFT)
dNumDoc7str_pad($v, 7, '0', LEFT)
iTipCont1sin padding
dFeEmiDE8Ymd (Carbon::format)
iTipEmi1sin padding
dCodSeg9sin padding (exacto)

Total CDC base: 2+8+1+3+3+7+1+8+1+9 = 43 dígitos + 1 DV = 44 dígitos.

4.3 Otras utilidades

MétodoFunción
leftPad()str_pad($v, $len, '0', STR_PAD_LEFT) — alias legible
bytesToHex()bin2hex($str) — para encoding de campos en QR
sha256Hex()hash('sha256', $value) — lowercase hex sin prefijo
buildUrlParams()implode('&', $pairs) sin urlencode()

5. Flujo de Envío Síncrono

Archivo analizado: app/Console/Commands/SifenSendTest.php

5.1 Orden exacto de pasos (maestro)

[1] Cargar datos desde JSON / BD / request HTTP
└── Mapear a arrays gOpeDE, gTimb, gDatGralOpe, gDtipDE, gTotSub

[2] Instanciar DocumentoElectronico
└── Asignar arrays de grupos al objeto

[3] Llamar $de->toSoapXmlEnviDe($dId)

├── [3.1] computeCdc()
│ └── PRIMER paso interno: se calcula el CDC (Id 44 dígitos)
│ y se setea $this->Id y $this->dDVId
│ ↳ El CDC depende de: iTiDE, RUC+DV, Est, Pun, NumDoc,
│ TipCont, FechaEmision(Ymd), TipEmi, CodSeg

├── [3.2] Construcción del árbol DOM
│ └── Nodos en orden XSD: gOpeDE → gTimb → gDatGralOpe →
│ gDtipDE → gTotSub

├── [3.3] SignatureHelper::signDocument()
│ └── Firma el nodo rDE con referencia URI="#CDC"
│ (xmlseclibs + certificado .p12)
│ ↳ Inserta ds:Signature DENTRO de rDE, ANTES de gCamFuFD

├── [3.4] SifenQrHelper::generateQrUrlFromSignedDocument()
│ └── Lee DigestValue del ds:Signature recién insertado
│ Construye URL QR con params en orden fijo
│ ↳ $this->enlaceQR = URL completa con cHashQR

├── [3.5] Crear nodo gCamFuFD > dCarQR
│ └── createTextNode(enlaceQR) — escapa & automáticamente

└── [3.6] $doc->saveXML() → string XML SOAP completo

[4] SifenSoapClient::enviarDE($xmlSoap, useMtls=true, verbose=true)
└── POST por cURL con headers SOAP 1.2
└── Retorna array: endpoint, http_code, headers_raw, response, curl_info

[5] Persistir logs
├── storage/app/sifen/last_request.xml
├── storage/app/sifen/last_response.xml
└── storage/app/sifen/logs/{timestamp}_{CDC}_request.xml / response.xml

5.2 ¿El CDC se calcula antes o después del XML?

Antes. computeCdc() se llama dentro de toSoapXmlEnviDe() como primer paso, antes de crear cualquier nodo DOM. Esto es porque el CDC es el atributo Id del nodo DE, que debe existir antes de poder crear el nodo y marcarlo como tipo ID.

5.3 ¿Cuándo exactamente se genera el QR e inserta en el XML?

Después de la firma, antes del saveXML(). El nodo gCamFuFD (con dCarQR) es el último nodo que se agrega al árbol DOM. Este orden es mandatorio porque:

  1. La firma cubre el nodo rDE por referencia canónica.
  2. Si gCamFuFD existiera antes de firmar, el DigestValue cambiaría (incluiría ese nodo).
  3. Al agregarlo después de la firma, no altera el digest ya calculado, y la firma sigue siendo válida.

Implicación para OnnixConnect OC: el nodo QR debe siempre ser el último child de rDE. No insertarlo antes de la firma bajo ninguna circunstancia.

5.4 Manejo del dId (correlativo de envío)

El dId es un entero que identifica el lote de envío dentro de la sesión. En el WS Sync se acepta valor 1 para envíos unitarios. No debe confundirse con el CDC. Se pasa como parámetro al método toSoapXmlEnviDe(int $dId = 1).


6. Configuración de Entorno Requerida

Estas claves deben estar disponibles en config/sifen.php (o a través de .env):

Clave configVariable .envDescripción
sifen.ws_syncSIFEN_WS_SYNCURL del WS síncrono (rEnviDe). Puede ser URL con ?wsdl
sifen.cert_p12_pathSIFEN_CERT_P12_PATHRuta absoluta al certificado .p12 del contribuyente
sifen.cert_p12_passSIFEN_CERT_P12_PASSContraseña del .p12
sifen.ns_uriSIFEN_NS_URIhttp://ekuatia.set.gov.py/sifen/xsd
sifen.versionSIFEN_VERSION150 (versión del esquema XSD)
sifen.schema_locationSIFEN_SCHEMA_LOCATIONEj: http://...xsd siRecepDE_v150.xsd
sifen.url_consulta_qrSIFEN_URL_CONSULTA_QRURL base para el QR (sin ?)
sifen.id_cscSIFEN_ID_CSCID del CSC (ej: 0001)
sifen.cscSIFEN_CSCCódigo de Seguridad del Contribuyente (secreto)
sifen.sis_factSIFEN_SIS_FACT1 = sistema propio, 2 = SET

7. Puntos Críticos para OnnixConnect OC (Checklist)

  • SOAP 1.2: usar Content-Type: application/soap+xml con action= inline, nunca text/xml.
  • Namespace del envelope: http://www.w3.org/2003/05/soap-envelope (prefijo env:), no soapenv:.
  • mTLS: pasar .p12 con CURLOPT_SSLCERTTYPE='P12'. Un único archivo, no PEM separado.
  • F5 BIG-IP: activar FOLLOWLOCATION, HTTP_VERSION_1_1, USERAGENT no vacío, Expect: vacío.
  • DOMDocument: preserveWhiteSpace=false + formatOutput=false ANTES de construir el árbol.
  • Atributo Id: setIdAttributeNode($attr, true) sobre el nodo DE. Sin esto la firma falla.
  • Orden de grupos: gOpeDE → gTimb → gDatGralOpe → gDtipDE → gTotSub → Signature → gCamFuFD.
  • Firma ANTES del QR: SignatureHelper::signDocument() antes de crear gCamFuFD.
  • DigestValue → Hex: bin2hex(base64_encode(base64_decode($digestB64))) — triple transformación.
  • dFeEmiDE en QR: bin2hex(Carbon::parse($fecha)->format('Y-m-d\TH:i:s')) — hex de string UTF-8.
  • Parámetros QR en orden fijo: nVersion, Id, dFeEmiDE, [dRucRec|dNumIDRec], dTotGralOpe, dTotIVA, cItems, DigestValue, IdCSC — el hash depende del orden.
  • Sin urlencode en QR: buildUrlParams() usa concatenación directa, no http_build_query().
  • DV Módulo 11: dígito a dígito (no parsear el número completo como int). Pesos [2,3,4,5,6,7] cíclicos de derecha a izquierda. dv=11→0, dv=10→1.
  • CDC: 43 dígitos con padding + 1 DV = 44 dígitos. Calcular antes de construir el DOM.
  • gCamFuFD: último nodo del XML, creado con createTextNode() para que el & de la URL quede como &amp; en el XML sin romper el parser.
  • Montos decimales: number_format($val, $scale, '.', '') sin separador de miles, nunca cast directo.

Generado: 2026-03-03 | Proyecto fuente: sifen_laravel_muestra | Destino: OnnixConnect OC