Saltar al contenido principal

SIFEN_OPERATIVE_LOGIC.md

Lógica Operativa Verificada con DNIT – Base para migración Laravel 12 / PHP 8.5

Fuente: Análisis de GeneradorXml.php, FirmadorParaguay.php y archivos en api-py/. Propósito: Documentar algoritmos y decisiones técnicas que producen documentos aceptados por SIFEN, sin reproducir código legado, para una reimplementación limpia y orientada a objetos.


1. Algoritmo del CDC y Dígito Verificador

1.1 Construcción de la cadena base del CDC (43 dígitos exactos)

El CDC es una cadena de 44 caracteres: 43 de datos + 1 dígito verificador (DV). Se forma concatenando los siguientes campos en este orden y con estos anchos fijos:

PosiciónCampoAnchoRellenoEjemplo
1–2Tipo de Documento (iTiDE)20 a la izquierda01
3–10RUC del emisor (sin DV)80 a la izquierda03782211
11DV del RUC emisor1ninguno0
12–14Código de establecimiento3ninguno (ya viene con 3 dígitos)001
15–17Punto de expedición3ninguno001
18–24Número del documento7ninguno (ya viene con 7 dígitos)0000001
25Tipo de contribuyente1ninguno1
26–33Fecha de emisión (YYYYMMDD)8se extrae del ISO datetime quitando guiones y la parte de hora20241029
34Tipo de emisión1ninguno1
35–43Código de seguridad aleatorio90 a la izquierda000123456

Reglas de transformación:

  • Tipo de Documento: Si es de 1 dígito, se antepone un 0.
  • RUC sin DV: Se separa el RUC en la parte numérica y el DV usando - como delimitador. La parte numérica se rellena con 0s a la izquierda hasta completar 8 caracteres.
  • Fecha: Del campo ISO YYYY-MM-DDTHH:mm:ss, se toma solo la parte antes de la T, se eliminan los guiones, resultando en YYYYMMDD.
  • Código de seguridad: Se rellena con 0s a la izquierda hasta completar 9 caracteres.

Nota implementación: El código de seguridad (codigoSeguridadAleatorio) en producción debe ser un número aleatorio. En el proyecto legado se usó substr(time(), -9) como aproximación de prueba.

1.2 Algoritmo Módulo 11 para el DV del CDC

Este es el algoritmo canónico que usa la DNIT. Se aplica sobre la cadena de 43 dígitos:

Paso 1 – Normalización alfanumérica Si cualquier carácter del CDC no es un dígito (0-9), se reemplaza por su valor ASCII decimal. En la práctica, el CDC solo contiene dígitos, pero la normalización protege contra edge cases con letras en el RUC o código de seguridad.

Paso 2 – Suma ponderada de derecha a izquierda

k = 2
total = 0

Para cada dígito, iterando de derecha a izquierda:
total += dígito × k
k += 1
Si k > 11:
k = 2 ← el factor se cicla entre 2 y 11

Paso 3 – Cálculo del DV

resto = total % 11

Si resto > 1:
DV = 11 - resto
Si resto <= 1:
DV = 0

Distinción importante: Existe una segunda variante del Módulo 11 en el proyecto (usada para validar RUCs), que itera de izquierda a derecha con factor iniciando en 7 y decreciendo hasta 2, y donde el resultado 11 mapea a 0 y 10 a 1. Esta variante NO se usa para el CDC; solo para validación de RUC del emisor/receptor.


2. Parámetros de Firma Digital (XMLDSig)

2.1 Constantes y URLs de Algoritmos (xmlseclibs)

ParámetroValor / URLDescripción
Canonicalizationhttp://www.w3.org/2001/10/xml-exc-c14n#Exclusive C14N (EXC_C14N)
SignatureMethodhttp://www.w3.org/2001/04/xmldsig-more#rsa-sha256RSA-SHA256
DigestMethodhttp://www.w3.org/2001/04/xmlenc#sha256SHA-256
Transform 1http://www.w3.org/2000/09/xmldsig#enveloped-signatureFirma enveloped (se auto-excluye)
Transform 2http://www.w3.org/2001/10/xml-exc-c14n#Canonicalization del nodo referenciado

2.2 Nodo de referencia y punto de inserción

La firma es de tipo Enveloped Signature (la firma va dentro del documento que firma):

Para Documentos Electrónicos (DE - facturas, notas de crédito, etc.):

  • Nodo firmado (Reference URI): El elemento <DE Id="{{cdc}}"> — es decir, la referencia apunta al elemento cuyo atributo Id coincide con el CDC.
  • Punto de inserción: La firma <Signature> se inserta como hijo directo del elemento raíz <rDE>.

Para Eventos (Cancelaciones, Inutilizaciones):

  • Nodo firmado (Reference URI): El elemento <rEve Id="1">.
  • Punto de inserción: La firma <Signature> se inserta como hijo del elemento <rGesEve>.

2.3 Prefijo de namespace en la firma

La instancia de XMLSecurityDSig se crea con prefijo vacío (''). Esto produce elementos sin prefijo: <Signature>, <SignedInfo>, <SignatureValue>, etc. Si se instanciara con 'ds', generaría <ds:Signature> — lo que rechaza la DNIT.

2.4 Certificado X.509 embebido en la firma

El certificado se agrega con los siguientes parámetros:

  • Formato PEM: true (el contenido del P12 se lee y se convierte internamente)
  • Cadena completa: false (solo el certificado del firmante, no la cadena CA)
  • IssuerSerial: Configurable (en producción: true; en testing puede ser false)

2.5 Manipulación del XML antes y después de firmar

Antes de firmar:

  • El XML es cargado en un DOMDocument vía loadXML().
  • Se establece $doc->encoding = 'utf-8'.
  • No se realiza ninguna limpieza manual de espacios ni namespaces.
  • La canonicalization (EXC_C14N) se encarga de normalizar el XML automáticamente.

Después de firmar:

  • Se agrega el bloque QR (<gCamFuFD><dCarQR>...</dCarQR></gCamFuFD>) como hijo del <rDE> raíz. Esto ocurre fuera del nodo firmado, por lo que no invalida la firma.
  • Se elimina la declaración XML via str_replace: se quitan <?xml version="1.0" encoding="utf-8"?> y <?xml version="1.0"?> del resultado de saveXML().

CRÍTICO: La eliminación de la declaración XML es obligatoria porque el XML firmado viaja embebido dentro de un SOAP Envelope que ya tiene su propia declaración.

2.6 QR Code – Construcción del hash

El QR se construye después de firmar (no afecta la firma):

Parámetros del QR (como query string):
nVersion=150
Id={cdc}
dFeEmiDE={fecha en hexadecimal bin2hex}
dRucRec={ruc del receptor} ← O dNumIDRec si no es contribuyente
dTotGralOpe={total general}
dTotIVA={total IVA}
cItems={cantidad de items}
DigestValue={DigestValue de la firma, en bin2hex}
IdCSC={id del código de seguridad del contribuyente}

Hash SHA-256:
secreto = queryString + csc (CSC = Código de Seguridad del Contribuyente)
cHashQR = hash('sha256', secreto)

URL final:
https://ekuatia.set.gov.py/consultas/qr?{queryString}&cHashQR={hash}

Nota: El proyecto legado tiene hardcodeada la URL de test (consultas-test). En producción usar consultas sin el -test.


3. Mapa de Endpoints y Sobres SOAP

3.1 Tabla de Endpoints

OperaciónModoURL
Envío Síncrono (DE)TESThttps://sifen-test.set.gov.py/de/ws/sync/recibe.wsdl
Envío Síncrono (DE)PRODhttps://sifen.set.gov.py/de/ws/sync/recibe.wsdl
Envío Asíncrono (Lote)TESThttps://sifen-test.set.gov.py/de/ws/async/recibe-lote.wsdl
Envío Asíncrono (Lote)PRODhttps://sifen.set.gov.py/de/ws/async/recibe-lote.wsdl
Consulta de LoteTESThttps://sifen-test.set.gov.py/de/ws/consultas/consulta-lote.wsdl
Consulta de LotePRODhttps://sifen.set.gov.py/de/ws/consultas/consulta-lote.wsdl
Eventos (Cancel./Inut.)TESThttps://sifen-test.set.gov.py/de/ws/eventos/evento.wsdl
Eventos (Cancel./Inut.)PRODhttps://sifen.set.gov.py/de/ws/eventos/evento.wsdl

3.2 Estructura de los SOAP Envelopes

Protocolo: SOAP 1.2 (namespace http://www.w3.org/2003/05/soap-envelope) HTTP: POST, Content-Type: application/xml; charset=utf-8 Declaración XML: <?xml version="1.0" encoding="UTF-8" standalone="no"?>

Envío Síncrono (un DE):

<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Header/>
<env:Body>
<rEnviDe xmlns="http://ekuatia.set.gov.py/sifen/xsd">
<dId>1</dId>
<xDE>{XML_FIRMADO_SIN_DECLARACION}</xDE>
</rEnviDe>
</env:Body>
</env:Envelope>

Respuesta → env:Body > hijo con prefijo ns2rRetEnviDe

Envío Asíncrono (lote ZIP en Base64):

<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Header/>
<env:Body>
<rEnvioLote xmlns="http://ekuatia.set.gov.py/sifen/xsd">
<dId>1</dId>
<xDE>{BASE64_DEL_ZIP}</xDE>
</rEnvioLote>
</env:Body>
</env:Envelope>

Respuesta → env:Body > hijo con prefijo ns2rResEnviLoteDe

Consulta de Lote:

<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Header/>
<env:Body>
<rEnviConsLoteDe xmlns="http://ekuatia.set.gov.py/sifen/xsd">
<dId>{ID_TRANSACCION}</dId>
<dProtConsLote>{NUMERO_DE_LOTE}</dProtConsLote>
</rEnviConsLoteDe>
</env:Body>
</env:Envelope>

Respuesta → env:Body > hijo con prefijo ns2rResEnviConsLoteDe

Eventos (Cancelación / Inutilización):

<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:xsd="http://ekuatia.set.gov.py/sifen/xsd">
<env:Header/>
<env:Body>
<xsd:rEnviEventoDe>
<xsd:dId>{ID}</xsd:dId>
<xsd:dEvReg>
{XML_DEL_EVENTO_FIRMADO_SIN_DECLARACION}
</xsd:dEvReg>
</xsd:rEnviEventoDe>
</env:Body>
</env:Envelope>

Respuesta → env:Body > hijo con prefijo ns2rRetEnviEventoDe

3.3 Lote ZIP – Estructura interna

El lote es un archivo ZIP creado en memoria con un único archivo 1.xml:

<rLoteDE>
{XML_FIRMADO_SIN_DECLARACION}
</rLoteDE>

El ZIP completo se convierte a Base64 y viaja dentro de <xDE>.

3.4 mTLS – Autenticación de cliente

Todas las llamadas a SIFEN requieren mTLS (certificado cliente en la conexión TLS). Parámetros cURL obligatorios:

  • CURLOPT_SSLCERT → ruta del archivo .p12
  • CURLOPT_SSLCERTPASSWD → contraseña del P12
  • CURLOPT_SSLCERTTYPE'P12'

En PHP 8.x+ la verificación SSL debe estar activa. El código legado la desactivaba solo para PHP < 8.0.


4. Lógica de Eventos: Cancelación vs. Inutilización

4.1 Diferencia técnica fundamental

Ambas operaciones usan el mismo endpoint (evento.wsdl) y el mismo sobre SOAP (rEnviEventoDe). La diferencia está en el nodo hijo dentro de <gGroupTiEvt>:

OperaciónNodo XML en gGroupTiEvtDescripción DNIT
Cancelación<rGeVeCan>Anula un DE ya aprobado. Requiere el CDC del documento a cancelar y el motivo.
Inutilización<rGesInut>Marca como inutilizados números de documentos que nunca llegaron a emitirse (rangos de numeración).

4.2 Estructura del XML de Cancelación

<gGroupGesEve 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 siRecepEvento_v150.xsd">
<rGesEve xsi:schemaLocation="http://ekuatia.set.gov.py/sifen/xsd siRecepEvento_v150.xsd">
<rEve Id="1">
<dFecFirma>{YYYY-MM-DDTHH:mm:ss}</dFecFirma>
<dVerFor>150</dVerFor>
<gGroupTiEvt>
<rGeVeCan>
<Id>{CDC_DEL_DOCUMENTO_A_CANCELAR}</Id>
<mOtEve>{MOTIVO_TEXTO_LIBRE}</mOtEve>
</rGeVeCan>
</gGroupTiEvt>
</rEve>
</rGesEve>
</gGroupGesEve>

Proceso de firma para eventos:

  1. Todo el XML de evento se construye como string completo (incluyendo el SOAP Envelope).
  2. Se firma el nodo <rEve Id="1">.
  3. La firma <Signature> se inserta como hijo de <rGesEve>.
  4. El XML firmado completo (Envelope + evento firmado) se envía tal cual como POST body.

A diferencia del DE, el evento incluye el SOAP Envelope dentro del string que se firma y envía (no se embebe el XML firmado en un wrapper SOAP posterior).

4.3 Inutilización (no implementada en el legado, estructura esperada)

La inutilización usa <rGesInut> en lugar de <rGeVeCan>, con campos para:

  • Tipo de Documento
  • Establecimiento y Punto de expedición
  • Rango de numeración (dNumIniFoli y dNumFinFoli)
  • Motivo

5. Lecciones Aprendidas – Fixes Técnicos Críticos

Fix #1 – Prefijo vacío en XMLSecurityDSig (BLOQUEANTE)

Síntoma: La DNIT rechaza con error de validación de firma. Causa: Si se instancia XMLSecurityDSig('ds'), genera <ds:Signature>. La DNIT espera la firma sin prefijo de namespace. Solución: Instanciar siempre con new XMLSecurityDSig('').

Fix #2 – Eliminación de la declaración XML (BLOQUEANTE)

Síntoma: El SOAP Envelope resultante es XML inválido o la DNIT lo rechaza. Causa: DOMDocument::saveXML() agrega <?xml version="1.0" encoding="utf-8"?> al inicio. Al embeber este string dentro del Envelope SOAP, hay dos declaraciones XML. Solución: Remover la declaración XML del resultado firmado antes de insertarlo en el Envelope. Hay dos variantes a remover:

  • <?xml version="1.0" encoding="utf-8"?> (con encoding)
  • <?xml version="1.0"?> (sin encoding)

Fix #3 – El QR se agrega fuera del nodo firmado (ARQUITECTURAL)

Síntoma: Si el QR se agrega antes de firmar, la validación del DigestValue falla. Causa: El nodo <gCamFuFD> con el QR debe estar fuera del <DE> firmado. Flujo correcto:

  1. Generar XML con <rDE> que contiene <DE>.
  2. Firmar: la referencia cubre solo el <DE>.
  3. Extraer el DigestValue de la firma recién generada.
  4. Construir la URL del QR usando ese DigestValue.
  5. Agregar <gCamFuFD> como hermano del <DE> dentro de <rDE>.

Fix #4 – mTLS es obligatorio en todas las llamadas (BLOQUEANTE)

Síntoma: HTTP 403 o rechazo de conexión sin mensaje de error útil. Causa: SIFEN valida la identidad del llamador por certificado de cliente en la capa TLS, independientemente del contenido firmado del XML. Solución: Siempre configurar el certificado P12 en el cliente HTTP (cURL o equivalente en Guzzle/Laravel HTTP Client). En Laravel/Guzzle:

'cert' => ['/ruta/al/certificado.p12', 'contraseña'],
'ssl_key' => [...],

o usar la opción CURLOPT_SSLCERTTYPE = 'P12' directamente.

Fix #5 – Namespace en el XML del evento (SUTIL)

Síntoma: El evento SOAP se envía pero la DNIT retorna error de estructura. Causa: El XML del evento tiene un patrón de namespaces mixto: el Envelope usa el prefijo env: y xsd:, pero el body del evento (gGroupGesEve) vuelve a declarar el namespace SIFEN sin prefijo con xmlns=. Solución: Respetar exactamente la estructura de namespace del legado:

  • env:Envelope con xmlns:env="http://www.w3.org/2003/05/soap-envelope" y xmlns:xsd="http://ekuatia.set.gov.py/sifen/xsd"
  • gGroupGesEve con xmlns="http://ekuatia.set.gov.py/sifen/xsd"

Fix #6 – Parseo de respuesta con namespace ns2 (IMPLEMENTACIÓN)

Síntoma: La respuesta SOAP no se puede parsear correctamente. Causa: SIFEN retorna el body con el namespace http://ekuatia.set.gov.py/sifen/xsd mapeado al prefijo ns2 en la respuesta. Solución en SimpleXML:

$xml = new SimpleXMLElement($response, 0, false, 'env', true);
$resultado = $xml->Body->children('ns2', true)->rRetEnviDe;

En Laravel/PHP moderno, usar DOMXPath con registerNamespace('ns2', 'http://ekuatia.set.gov.py/sifen/xsd').

Fix #7 – Opción overwrite => false en addReference (INTEGRIDAD)

Síntoma: El atributo URI en <Reference> queda vacío o incorrecto. Causa: Por defecto, xmlseclibs puede sobrescribir el atributo URI. Solución: Pasar ['overwrite' => false] como cuarto argumento de addReference() para preservar el URI original que apunta al ID del elemento firmado.

Fix #8 – Header HTTP exacto (SUTIL)

Content-Type obligatorio: application/xml; charset=utf-8 No usar text/xml ni application/soap+xml. La DNIT puede rechazar o no procesar correctamente con otros content-types.


6. Referencia Rápida – Namespace del Documento Electrónico

El elemento raíz del DE firmado debe declarar exactamente estos namespaces:

<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">
  • Versión del formato: <dVerFor>150</dVerFor> (corresponde a la v1.5.0 del schema)
  • Schema de eventos: siRecepEvento_v150.xsd
  • Schema de DE: siRecepDE_v150.xsd

7. Checklist de Implementación en Laravel 12

  • CdcService – Construcción de la cadena de 43 dígitos con validaciones de ancho fijo
  • Modulo11Service – Algoritmo DV para CDC (versión CDC) y para RUC (versión 7..2)
  • XmlDeBuilder – Construcción del XML usando DOMDocument (no heredocs concatenados)
  • XmlSignerService – Wrapper sobre xmlseclibs con los parámetros exactos documentados en §2
  • QrCodeBuilder – Construcción del QR después de obtener el DigestValue de la firma
  • SifenHttpClient – Cliente HTTP con mTLS configurado (Guzzle + cert P12)
  • SifenSoapEnvelopeBuilder – Constructores de los 4 tipos de envelope documentados en §3
  • EventoCancelacionBuilder – Construcción del XML de evento con rGeVeCan
  • EventoInutilizacionBuilder – Construcción del XML de evento con rGesInut
  • Variables de entorno para: rutas de certificados, contraseñas, IdCSC, CSC, modo TEST/PROD

Documento generado el 2026-03-03 a partir del análisis de sifen_phplegacy.