SIFEN Logic Reference — Guía de Implementación PHP
Auditoría: Código fuente Java de los módulos
rshk-jsifen-maven,onnix-sifen-libyonnix-sifen-driver. Referencias:factura_Java.json(DTE aprobado por SET) ·factura_v150.json(fixture PHP v150). Lector objetivo: Desarrollador PHP que debe replicar la lógica exacta en el Builder de Laravel 12. Fecha: 2026-03-13
Índice
- Arquitectura de los módulos Java
- Flujo de transmisión end-to-end
- Receptor (gDatRec): Matriz B2B vs B2C
- Condición de Venta (gCamCond): Contado vs Crédito
- Ítem (gCamItem): secuencia exacta de tags
- IVA por ítem (gCamIVA): fórmulas y orden de emisión
- Totales (gTotSub): algoritmo y secuencia
- Capa de transporte SOAP
- Firma digital (SignatureHelper)
- CDC y Dígito Verificador (SifenUtil)
- Respuesta de la SET (xProtDE)
- Tabla de diferencias: factura_Java.json vs factura_v150.json
1. Arquitectura de los módulos Java
Roles de cada módulo
| Módulo | Rol principal | Clase de entrada |
|---|---|---|
rshk-jsifen-maven | Motor SIFEN: construye XML, firma, envía SOAP | com.roshka.sifen.Sifen |
onnix-sifen-lib | Espejo de POJOs con soporte Jackson/JSON para APIs REST | py.com.onnix.sifen.DocumentoElectronico |
onnix-sifen-driver | API Spring Boot que expone endpoints HTTP y orquesta el motor | DocumentTransmissionService |
Jerarquía del documento electrónico
DocumentoElectronico (com.roshka.sifen.core.beans)
├── dFecFirma LocalDateTime — timestamp de firma ISO 8601
├── dSisFact short — 1=Cliente, 2=SET Gratuita
├── gOpeDE TgOpeDE — tipo emisión + código seguridad
├── gTimb TgTimb — timbrado + tipo DE + numeración
├── gDatGralOpe TdDatGralOpe ─┐
│ ├── gOpeCom TgOpeCom │ impuesto, moneda, tipo transacción
│ ├── gEmis TgEmis │ datos del emisor
│ └── gDatRec TgDatRec │ datos del receptor (B2B / B2C)
├── gDtipDE TgDtipDE ─┤
│ ├── gCamFE TgCamFE │ indicador de presencia
│ ├── gCamCond TgCamCond │ condición de venta (contado/crédito)
│ └── gCamItemList List<TgCamItem>│ líneas
└── gTotSub TgTotSub ─┘ totales y subtotales IVA
2. Flujo de transmisión end-to-end
Ruta real verificada en el código fuente:
HTTP POST /api/sifen/documento
│
▼
DocumentTransmissionController
│
▼
DocumentTransmissionService.execute(sentId, DocumentoElectronico)
│ Verifica éxito: response.getxProtDE() != null
▼
SifenOperationsApi.sendDocument(sentId, electronicDocument)
│ Crea: new Sifen(getSifenConfig())
│ Llama: sifen.recepcionDE(sentId, electronicDocument)
▼
ReqRecDe.setupSoapMessage(generationCtx)
│ Delega: documentoElectronico.setupSOAPElements(...)
▼
DocumentoElectronico.setupSOAPElements()
│ 1. Crea nodo <DE Id="[CDC+DV]">
│ 2. Serializa gOpeDE → gTimb → gDatGralOpe → gDtipDE → gTotSub
│ 3. gTotSub calcula todos los valores acumulando ítems
▼
SignatureHelper.signDocument(sifenConfig, soapElement, signedNodeId)
│ Firma RSA-SHA256 con Exclusive C14N dentro del nodo DE
▼
SoapHelper.makeSoapRequest(sifenConfig, url, soapMessage)
│ POST HTTPS con mTLS (PKCS12)
│ Content-Type: application/xml; charset=utf-8
▼
SET (ekuatia.set.gov.py)
│ Responde con <rRetEnviDe> que contiene <xProtDE>
▼
ReqRecDe.processResponse(soapResponse)
│ Parsea nodo rRetEnviDe → RespuestaRecepcionDE
│ Extrae xProtDE (protocolo de autorización)
▼
DocumentTransmissionService.payloadIsPresent(response)
└ return response.getxProtDE() != null;
Configuración del SifenConfig (onnix-sifen-driver)
// SifenOperationsApi.getSifenConfig()
SifenConfig sifenConfig = new SifenConfig(
isDevProfileActive ? SifenConfig.TipoAmbiente.DEV : SifenConfig.TipoAmbiente.PROD,
empresa.getIdCscSifen(), // ID del certificado de seguridad
empresa.getCscSifen(), // Código de seguridad
SifenConfig.TipoCertificadoCliente.PFX,
certificado.getContenido(), // bytes del .pfx
certificado.getClaveCertificado() // password del .pfx
);
Equivalente PHP: El SifenHttpClient debe cargar el .pfx desde la BD (tabla certificados) y configurar cURL con CURLOPT_SSLCERT + CURLOPT_SSLCERTPASSWD.
3. Receptor (gDatRec): Matriz B2B vs B2C
Clase Java: TgDatRec — com.roshka.sifen.core.fields.request.de.TgDatRec
Método de serialización: TgDatRec.setupSOAPElements(SOAPElement gDatGralOpe, TTiDE iTiDE)
Secuencia de emisión XML (orden exacto del código fuente)
// TgDatRec.setupSOAPElements() — secuencia física verificada
gDatRec.addChildElement("iNatRec") // int — 1=Contribuyente, 2=NoContribuyente
gDatRec.addChildElement("iTiOpe") // int — 1=B2B, 2=B2C, 4=OtroCaso
gDatRec.addChildElement("cPaisRec") // "PRY" (código alfa-3)
gDatRec.addChildElement("dDesPaisRe") // "Paraguay" (nombre del país)
// BLOQUE B2B — solo si iNatRec == 1
if (this.iNatRec.getVal() == 1) {
gDatRec.addChildElement("iTiContRec") // 1=PersonaJurídica, 2=PersonaFísica
gDatRec.addChildElement("dRucRec") // RUC sin DV
gDatRec.addChildElement("dDVRec") // DV del RUC
}
// BLOQUE B2C — solo si iNatRec == 2 Y iTiOpe != 4
if (this.iNatRec.getVal() == 2 && this.iTiOpe.getVal() != 4) {
gDatRec.addChildElement("iTipIDRec") // 1=CI, 2=CédExtr, 3=Pasaporte, 4=Otro
gDatRec.addChildElement("dDTipIDRec") // Descripción del tipo de ID
gDatRec.addChildElement("dNumIDRec") // Número del documento (fallback: "0")
}
gDatRec.addChildElement("dNomRec") // Nombre (fallback: "Sin Nombre")
// dNomFanRec — solo si no es null
// dDirRec, dNumCasRec, cDepRec, dDesDepRec — solo si dDirRec!=null OR iTiDE=7 OR iTiOpe=4
// cDisRec, dDesDisRec — solo si cDisRec != 0
// cCiuRec, dDesCiuRec — solo si dirección presente Y iTiOpe != 4
// dTelRec, dCelRec, dEmailRec, dCodCliente — solo si no son null
Tabla de decisión B2B vs B2C
| Condición | iNatRec | iTiOpe | Campos obligatorios | Campos ausentes |
|---|---|---|---|---|
| B2B — Empresa paraguaya contribuyente | 1 | 1 | iTiContRec, dRucRec, dDVRec | iTipIDRec, dNumIDRec |
| B2C — Persona física con CI | 2 | 2 | iTipIDRec=1, dNumIDRec | iTiContRec, dRucRec |
| B2C — Extranjero con pasaporte | 2 | 2 | iTipIDRec=3, dNumIDRec | iTiContRec, dRucRec |
| OtroCaso — Sin identificación | 2 | 4 | dNomRec | iTipIDRec, dRucRec |
Nota crítica:
iTiOpepertenece agDatRec(no agOpeCom). ElgOpeComsolo contieneiTImp,cMoneOpey opcionalmenteiTipTra. Verfactura_v150.json → _tipoOperacion_tag.
4. Condición de Venta (gCamCond): Contado vs Crédito
Clase Java: TgCamCond — com.roshka.sifen.core.fields.request.de.TgCamCond
Lógica de emisión (código fuente real)
// TgCamCond.setupSOAPElements()
gCamCond.addChildElement("iCondOpe") // 1=Contado, 2=Crédito
gCamCond.addChildElement("dDCondOpe") // Descripción
// CONTADO — si iCondOpe=1 O si gPagCred.dMonEnt!=null O si gPaConEIniList!=null
if (this.iCondOpe.getVal() == 1 || this.gPagCred.getdMonEnt() != null
|| this.gPaConEIniList != null) {
for (TgPaConEIni item : this.gPaConEIniList) {
item.setupSOAPElements(gCamCond); // emite <gPaConEIni>
}
}
// CRÉDITO — si iCondOpe=2
if (this.iCondOpe.getVal() == 2) {
this.gPagCred.setupSOAPElements(gCamCond); // emite <gPagCred>
}
Estructura de cada medio de pago en CONTADO (TgPaConEIni)
<gPaConEIni>
<iTiPago>1</iTiPago> <!-- 1=Efectivo, 2=Cheque, 3=TarjCrédito, 4=TarjDébito, 5=Transferencia, 10=Retención -->
<dDesTiPag>Efectivo</dDesTiPag> <!-- Descripción del tipo de pago -->
<dMonTiPag>160000</dMonTiPag> <!-- Monto para este medio, PYG=entero -->
<cMoneTiPag>PYG</cMoneTiPag> <!-- Moneda -->
<dDMoneTiPag>Guaraní</dDMoneTiPag><!-- Fix E608: nombre de moneda -->
<!-- Si iTiPago=3: agregar <gPagTarCD> con <iDenTarj>, <dRSProTar>, <dNomTarj>, <dNumCueTar>, <dVencTar> -->
<!-- Si iTiPago=2: agregar <gPagCheq> con <dNumCheq>, <dBcoEmi>, <dPMEmiCheq> -->
</gPaConEIni>
Fix E608 verificado: El nombre de la moneda (
dDMoneTiPag) debe ser"Guaraní"para PYG. El builder PHP debe derivarlo de la tabla de monedas, no hardcodearlo.
Estructura de crédito (TgPagCred)
Clase Java: TgPagCred — com.roshka.sifen.core.fields.request.de.TgPagCred
// TgPagCred.setupSOAPElements()
gPagCred.addChildElement("iCondCred") // 1=Plazo, 2=ContadoConDescuento
gPagCred.addChildElement("dDCondCred") // Descripción
// dPlazoCre — si no null O si iCondCred=1 (ej: "60 días")
if (this.dPlazoCre != null || this.iCondCred.getVal() == 1)
gPagCred.addChildElement("dPlazoCre").setTextContent(this.dPlazoCre);
// dCuotas — si != 0 O si iCondCred=2
if (this.dCuotas != 0 || this.iCondCred.getVal() == 2)
gPagCred.addChildElement("dCuotas").setTextContent(...)
// dMonEnt — entrega inicial (opcional)
// gCuotas — lista de cuotas (si aplica)
Ejemplo crédito (de factura_Java.json — DTE aprobado):
"gCamCond": {
"iCondOpe": "CREDITO",
"gPagCred": {
"iCondCred": "PLAZO",
"dPlazoCre": "60 días",
"dCuotas": 0
}
}
Nota:
dCuotas=0coniCondCred=PLAZOes válido cuando el pago es en un único plazo sin cuotas numeradas.
5. Ítem (gCamItem): secuencia exacta de tags
Clase Java: TgCamItem — com.roshka.sifen.core.fields.request.de.TgCamItem
La secuencia de addChildElement en TgCamItem.setupSOAPElements() determina el orden físico del XML:
<gCamItem>
<dCodInt>SRV-001</dCodInt> <!-- SIEMPRE -->
<!-- dParAranc: OMITIDO si dParAranc == 0 (Fix #13 verificado en fuente) -->
<!-- dNCM: OMITIDO si dNCM == 0 -->
<!-- dDncpG + dDncpE: solo si dDncpG!=null O iTiOpe==3 (compras públicas) -->
<!-- dGtin: OMITIDO si dGtin == 0 -->
<!-- dGtinPq: OMITIDO si dGtinPq == 0 -->
<dDesProSer>Descripción del servicio</dDesProSer> <!-- SIEMPRE, max 120 chars -->
<cUniMed>77</cUniMed> <!-- código numérico del enum -->
<dDesUniMed>UNI</dDesUniMed> <!-- abreviatura del enum (getAbreviatura()) -->
<dCantProSer>2</dCantProSer>
<!-- cPaisOrig + dDesPaisOrig: solo si cPaisOrig != null -->
<!-- dInfItem: solo si != null -->
<!-- cRelMerc + dDesRelMerc: solo si cRelMerc != null -->
<!-- dCanQuiMer, dPorQuiMer: solo si iTiDE=7 y cRelMerc!=null O valor != null -->
<!-- dCDCAnticipo: solo si iTipTra=9 (ANTICIPO) O valor != null -->
<!-- gValorItem: OMITIDO para iTiDE=7 (Nota de Remisión) -->
<gValorItem>
<dPUniProSer>55000</dPUniProSer>
<!-- dTiCamIt: solo si dCondTiCam==2 (tipo de cambio por ítem) -->
<dTotBruOpeItem>110000</dTotBruOpeItem> <!-- = dPUniProSer * dCantProSer, PYG=entero -->
<gValorRestaItem>
<dDescItem>0</dDescItem>
<!-- ... otros descuentos ... -->
<dTotOpeItem>110000</dTotOpeItem> <!-- total de línea -->
<!-- dTotOpeGs: solo si cMoneOpe!=PYG Y dCondTiCam==2 -->
</gValorRestaItem>
</gValorItem>
<!-- gCamIVA: OMITIDO para iTiDE=4 (Autofactura) y iTiDE=7 (Remisión) -->
<!-- gCamIVA: EMITIDO si iTImp=1,3,4,5 -->
<gCamIVA>...</gCamIVA>
<!-- gRasMerc, gVehNuevo: solo si != null -->
</gCamItem>
Fix #13 verificado en código fuente:
if (this.dParAranc != 0)— si el código arancelario es 0 o no fue seteado, el tag<dParAranc>NO se emite.
Fix E708: El campo se llama
dDesProSer(NOdDesProd). Verificado enTgCamItem.javalínea 71:gCamItem.addChildElement("dDesProSer").
6. IVA por ítem (gCamIVA): fórmulas y orden de emisión
Clase Java: TgCamIVA — com.roshka.sifen.core.fields.request.de.TgCamIVA
Secuencia de emisión XML (verificada línea por línea)
// TgCamIVA.setupSOAPElements() — orden exacto
gCamIVA.addChildElement("iAfecIVA") // 1, 2, 3 o 4 (numeric)
gCamIVA.addChildElement("dDesAfecIVA") // "Gravado IVA", "Exonerado", "Exento", "Gravado Parcial"
gCamIVA.addChildElement("dPropIVA") // 100 para totalmente gravado/exento
gCamIVA.addChildElement("dTasaIVA") // 10, 5 o 0
// Se calculan dBasGravIVA y dLiqIVAItem (ver fórmulas abajo)
gCamIVA.addChildElement("dBasGravIVA") // base imponible
gCamIVA.addChildElement("dLiqIVAItem") // IVA liquidado
// dBasExe — ÚLTIMO, solo si generationCtx.isHabilitarNotaTecnica13() == true
gCamIVA.addChildElement("dBasExe") // SIEMPRE ÚLTIMO en gCamIVA (Fix E737)
Fórmulas de cálculo (extractadas de TgCamIVA.java líneas 35-46)
scale = (cMoneOpe == PYG) ? 0 : 2
propIVA_decimal = dPropIVA / 100 // ej: 100/100 = 1.0
Para iAfecIVA = 1 (GRAVADO) o 4 (GRAVADO_PARCIAL):
Si dTasaIVA == 10:
dBasGravIVA = (dTotOpeItem × propIVA_decimal) / 1.1 [RoundingMode.HALF_UP]
dLiqIVAItem = (dTotOpeItem × propIVA_decimal) / 11 [RoundingMode.HALF_UP]
Si dTasaIVA == 5:
dBasGravIVA = (dTotOpeItem × propIVA_decimal) / 1.05 [RoundingMode.HALF_UP]
dLiqIVAItem = (dTotOpeItem × propIVA_decimal) / 21 [RoundingMode.HALF_UP]
Para iAfecIVA = 2 (EXONERADO) o 3 (EXENTO):
dBasGravIVA = 0
dLiqIVAItem = 0
Verificación: Para ítem gravado 10% con
dTotOpeItem=110000ydPropIVA=100:
dBasGravIVA = 110000 / 1.1 = 100000dLiqIVAItem = 110000 / 11 = 10000Coincide exactamente confactura_v150.json → _totalesXml.
Fix E737: campo dBasExe (Nota Técnica 013)
Solo se emite cuando generationCtx.isHabilitarNotaTecnica13() == true (habilitado en producción v150).
// TgCamIVA.setupSOAPElements() — lógica real del dBasExe
if (generationCtx.isHabilitarNotaTecnica13()) {
if (this.iAfecIVA.getVal() == 4) {
// GRAVADO_PARCIAL: fórmula NT013 E737
// dBasExe = [100 × total × (100 – propIVA)] / [10000 + (tasaIVA × propIVA)]
this.dBasExe = (dTotOpeItem × 100 × (100 - dPropIVA)) / (10000 + (dTasaIVA × dPropIVA));
} else {
// Gravado (1), Exonerado (2), Exento (3): dBasExe = 0
this.dBasExe = BigDecimal.valueOf(0);
}
gCamIVA.addChildElement("dBasExe").setTextContent(...);
}
Atención — divergencia PHP vs Java: El fixture
factura_v150.jsonmuestradBasExe=50000para el ítem exento (iAfecIVA=3). El Java original asignadBasExe=0para todos excepto GRAVADO_PARCIAL. La implementación PHP extiende la lógica NT013 estableciendodBasExe = dTotOpeItempara ítems EXENTOS. Esta extensión es necesaria para cumplir con la validación E737 de la SET en v150.Regla PHP definitiva:
- iAfecIVA=1 (GRAVADO):
dBasExe = 0- iAfecIVA=2 (EXONERADO):
dBasExe = 0- iAfecIVA=3 (EXENTO):
dBasExe = dTotOpeItem← extensión PHP sobre Java- iAfecIVA=4 (GRAVADO_PARCIAL): aplicar fórmula NT013
7. Totales (gTotSub): algoritmo y secuencia
Clase Java: TgTotSub — com.roshka.sifen.core.fields.request.de.TgTotSub
Algoritmo de acumulación (TgTotSub.setupSOAPElements — líneas 61-98)
// Equivalente PHP del loop de acumulación
foreach ($items as $item) {
$iva = $item->gCamIVA;
$total = round($item->gValorItem->gValorRestaItem->dTotOpeItem, $scale);
if ($iva->iAfecIVA === 1 || $iva->iAfecIVA === 4) {
if ($iva->dTasaIVA == 10) {
$dSub10 += $total;
$dIVA10 += $iva->dLiqIVAItem;
$dBaseGrav10 += $iva->dBasGravIVA;
// dLiqTotIVA10 = 0 (siempre, en este contexto)
} elseif ($iva->dTasaIVA == 5) {
$dSub5 += $total;
$dIVA5 += $iva->dLiqIVAItem;
$dBaseGrav5 += $iva->dBasGravIVA;
// dLiqTotIVA5 = 0 (siempre, en este contexto)
}
} elseif ($iva->iAfecIVA === 2) {
$dSubExo += $total;
} elseif ($iva->iAfecIVA === 3) {
$dSubExe += $total;
}
// Acumulación de descuentos y anticipaciones
$dTotDesc += $item->descItem * $item->cantidad;
$dTotDescGlotem += $item->descGloItem ?? 0;
$dTotAntItem += $item->antPreUniIt ?? 0;
$dTotAnt += $item->antGloPreUniIt ?? 0;
// dTotalGs: solo para moneda extranjera con dCondTiCam=2
if ($moneda !== 'PYG' && $dCondTiCam === 2) {
$dTotalGs += $item->dTotOpeGs;
}
}
// Post-loop
$dTotOpe = $dSub10 + $dSub5 + $dSubExo + $dSubExe; // para iTiDE != 4
$dDescTotal = $dTotDesc + $dTotDescGlotem;
$dPorcDescTotal = ($dDescTotal * 100) / ($dTotOpe + $dDescTotal); // CUIDADO: divisor puede ser 0
$dAnticipo = $dTotAntItem + $dTotAnt;
// REDONDEO OFICIAL SET (RedondeoUtil.redondeoOficialGuaranies)
// Resolución SEDECO 314/2014: redondear a múltiplos de 50 guaraníes
$dRedon = bcmod($dTotOpe, 50); // para PYG: valor % 50
// Para otras monedas: redondear a múltiplos de 0.50 (ver RedondeoUtil.redondeoOficialOtrasMonedas)
$dTotGralOpe = $dTotOpe - $dRedon + ($dComi ?? 0);
$dRedon = abs($dRedon); // se hace absoluto DESPUÉS del cálculo
$dIVAComi = ($dComi !== null) ? $dComi / 1.1 : 0;
$dTotIVA = $dIVA5 + $dIVA10 - $dLiqTotIVA5 - $dLiqTotIVA10 + $dIVAComi;
$dTBasGraIVA = $dBaseGrav5 + $dBaseGrav10;
// dTotalGs para moneda extranjera con dCondTiCam=1 (tipo de cambio global)
if ($moneda !== 'PYG' && $dCondTiCam === 1) {
$dTotalGs = $dTotGralOpe * $dTiCam;
}
Secuencia física de tags en el XML (orden exacto de addChildElement)
La secuencia se emite en este orden exacto (verificado en TgTotSub.setupSOAPElements líneas 122-176):
<gTotSub>
<!-- GRUPO 1: subtotales por afectación (solo si iTiDE != 4) -->
<dSubExe>50000</dSubExe> <!-- total ítems EXENTOS -->
<dSubExo>0</dSubExo> <!-- total ítems EXONERADOS -->
<!-- Solo si iTImp = 1 (IVA) o 5 (ISC): -->
<dSub5>0</dSub5> <!-- total gravado 5% — ANTES de dSub10 -->
<dSub10>110000</dSub10> <!-- total gravado 10% -->
<!-- GRUPO 2: operación global -->
<dTotOpe>160000</dTotOpe>
<dTotDesc>0</dTotDesc>
<dTotDescGlotem>0</dTotDescGlotem> <!-- ← nombre EXACTO, NO dTotDescGlobal -->
<dTotAntItem>0</dTotAntItem>
<dTotAnt>0</dTotAnt>
<dPorcDescTotal>0</dPorcDescTotal>
<dDescTotal>0</dDescTotal>
<dAnticipo>0</dAnticipo>
<dRedon>0.0000</dRedon> <!-- formateado con 4 decimales (FieldFormatUtil.formattdCRed) -->
<!-- dComi — solo si iTiDE!=4 Y dComi!=null -->
<dTotGralOpe>160000</dTotGralOpe>
<!-- GRUPO 3: IVA (solo si iTiDE!=4 Y iTImp=1 o 5) -->
<dIVA5>0</dIVA5> <!-- IVA 5% — SIEMPRE ANTES de dIVA10 -->
<dIVA10>10000</dIVA10> <!-- IVA 10% -->
<!-- GRUPO 4: liquidación (solo si iTImp=1 o 5) -->
<dLiqTotIVA5>0</dLiqTotIVA5>
<dLiqTotIVA10>0</dLiqTotIVA10>
<!-- dIVAComi — solo si iTiDE!=4 Y dComi!=null -->
<!-- GRUPO 5: bases y total IVA (solo si iTiDE!=4 Y iTImp=1 o 5) -->
<dTotIVA>10000</dTotIVA>
<dBaseGrav5>0</dBaseGrav5> <!-- base 5% — SIEMPRE ANTES de dBaseGrav10 -->
<dBaseGrav10>100000</dBaseGrav10>
<dTBasGraIVA>100000</dTBasGraIVA> <!-- ← nombre EXACTO, NO dTotalbaseIVA -->
<!-- dTotalGs — solo si iTiDE!=4 Y cMoneOpe!=PYG (moneda extranjera) -->
</gTotSub>
Reglas de condicionalidad extraídas del código fuente:
dSub5,dSub10,dIVA5,dIVA10,dBaseGrav5,dBaseGrav10,dTBasGraIVA: solo se emiten siiTImp ∈ {1, 5}(IVA o ISC)dTotalGs: solo se emite sicMoneOpe != PYGdComi+dIVAComi: solo se emiten sidComi != null- Todo el grupo excepto
dTotOpese omite paraiTiDE=4(Autofactura)
8. Capa de transporte SOAP
Clase Java: SoapHelper — com.roshka.sifen.internal.helpers.SoapHelper
Headers HTTP verificados en SoapHelper.java
// setupHttpURLConnectionHeaders()
httpsConnection.setRequestProperty("User-Agent", sifenConfig.getUserAgent());
httpsConnection.setRequestProperty("Content-Type", "application/xml; charset=utf-8");
// Método
httpsConnection.setRequestMethod("POST");
// Versión SOAP
MessageFactory mf12 = MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL);
Equivalente cURL en PHP:
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/xml; charset=utf-8',
'User-Agent: ' . $userAgent,
]);
curl_setopt($ch, CURLOPT_POST, true);
mTLS — SSLContextHelper
// SSLContextHelper.getContextFromConfig()
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(pfxInputStream, passcode.toCharArray());
String alias = keyStore.aliases().nextElement();
KeyManagerFactory kmf = KeyManagerFactory.getInstance(
KeyManagerFactory.getDefaultAlgorithm() // "SunX509"
);
kmf.init(keyStore, passcode.toCharArray());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), trustManagers, null);
Equivalente PHP cURL:
curl_setopt($ch, CURLOPT_SSLCERTTYPE, 'P12');
curl_setopt($ch, CURLOPT_SSLCERT, $pfxFilePath); // o archivo temporal
curl_setopt($ch, CURLOPT_SSLCERTPASSWD, $password);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
Manejo de errores de conexión
// SoapHelper.makeSoapRequest() — lógica de error
if (soapResponse.isRequestSuccessful()) { // HTTP 200-299
inputStream = httpsConnection.getInputStream();
} else {
inputStream = httpsConnection.getErrorStream(); // leer cuerpo del error
}
// SocketTimeoutException con "Read timed out" → readException
// SocketTimeoutException otro → connectException
// IOException → invalidSOAPRequest
9. Firma digital (SignatureHelper)
Clase Java: SignatureHelper — com.roshka.sifen.internal.helpers.SignatureHelper
Parámetros de firma (verificados en SignatureHelper.java líneas 48-86)
// Transforms — inicializados en bloque estático
transforms.add(_xmlSignatureFactory.newTransform(
Transform.ENVELOPED, // paso 1: remover el nodo Signature antes de hashear
(TransformParameterSpec) null
));
transforms.add(_xmlSignatureFactory.newTransform(
CanonicalizationMethod.EXCLUSIVE, // paso 2: Exclusive C14N (NOT Inclusive)
(TransformParameterSpec) null
));
// Reference URI → apunta al nodo DE por su atributo Id
Reference ref = _xmlSignatureFactory.newReference(
"#" + signedNodeId, // ej: "#01800501721001001..."
_xmlSignatureFactory.newDigestMethod(DigestMethod.SHA256, null),
transforms, null, null
);
// SignedInfo
SignedInfo signedInfo = _xmlSignatureFactory.newSignedInfo(
_xmlSignatureFactory.newCanonicalizationMethod(
CanonicalizationMethod.EXCLUSIVE, // Exclusive C14N para SignedInfo
(C14NMethodParameterSpec) null
),
_xmlSignatureFactory.newSignatureMethod(
Constants.RSA_SHA256, // "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
null
),
Collections.singletonList(ref)
);
// KeyInfo — incluye el certificado X.509 completo
X509Data x509Data = keyInfoFactory.newX509Data(Collections.singletonList(certificate));
KeyInfo keyInfo = keyInfoFactory.newKeyInfo(Collections.singletonList(x509Data));
Estructura XML resultante de la firma
<DE Id="01800501721001001000000122026031210000000159">
<!-- ... contenido del DE ... -->
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<Reference URI="#01800501721001001000000122026031210000000159">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<DigestValue>BASE64_SHA256_HASH</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>BASE64_RSA_SIGNATURE</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>BASE64_DER_CERTIFICATE</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</DE>
Validación de firma (SignatureHelper.validateSignature)
El validador verifica que el RUC emisor en <dRucEm> coincida con el SERIALNUMBER del certificado (formato RUC-DV).
Si el campo <DE> no existe o no tiene atributo Id, la validación falla.
10. CDC y Dígito Verificador (SifenUtil)
Clase Java: SifenUtil — com.roshka.sifen.internal.util.SifenUtil
Algoritmo de Dígito Verificador — Módulo 11 (SifenUtil.generateDv)
// SifenUtil.generateDv(String ruc) — código fuente exacto
public static String generateDv(String ruc) {
int baseMax = 11, k = 2, total = 0;
if (ruc.equals("88888801")) {
return "5"; // ← caso especial documentado
}
for (int i = ruc.length() - 1; i >= 0; i--) {
k = k > baseMax ? 2 : k;
int n = Integer.parseInt(ruc.substring(i, i + 1));
total += n * k;
k++;
}
return String.valueOf((total % 11) > 1 ? 11 - (total % 11) : 0);
}
Equivalente PHP:
function generateDv(string $ruc): string {
if ($ruc === '88888801') return '5';
$baseMax = 11; $k = 2; $total = 0;
for ($i = strlen($ruc) - 1; $i >= 0; $i--) {
$k = $k > $baseMax ? 2 : $k;
$total += (int)$ruc[$i] * $k;
$k++;
}
return (string)(($total % 11) > 1 ? 11 - ($total % 11) : 0);
}
SHA-256 para URLs de consulta
// SifenUtil.sha256Hex(String input)
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
// Convierte a hex lowercase
Generación de número de seguridad aleatorio
// SifenUtil.generateRandomNumber() — 9 dígitos con padding de cero
leftPad(String.valueOf(new Random().ints(1, 1, 999999999)...), '0', 9)
Compresión para lotes
// SifenUtil.compressXmlToZip(String str) — para RecepcionLoteDE
// Crea archivo ZIP con nombre: "DE_ddMMyyyy.xml"
// Codificación UTF-8
11. Respuesta de la SET (xProtDE)
Clase Java: RespuestaRecepcionDE — com.roshka.sifen.core.beans.response.RespuestaRecepcionDE
Clase protocolo: TxProtDe — com.roshka.sifen.core.fields.response.TxProtDe
Estructura del SOAP response exitoso
<rRetEnviDe>
<dId>1</dId>
<dEstRes>0</dEstRes> <!-- 0 = procesado OK -->
<xProtDE> <!-- PRESENTE solo si documento fue aceptado -->
<dFecProc>2023-09-13T18:40:00</dFecProc>
<dDigVal>abc123...</dDigVal> <!-- Hash de autorización -->
<dEstRes>0</dEstRes>
<dProtAut>20230913000001</dProtAut> <!-- Número de protocolo/autorización -->
<gResProc>
<iRes>0</iRes>
<dDescription>Aceptado</dDescription>
</gResProc>
</xProtDE>
</rRetEnviDe>
Lógica de verificación de éxito
// DocumentTransmissionService.payloadIsPresent()
return response.getxProtDE() != null;
Si xProtDE == null, el documento fue rechazado. Los errores de validación se encuentran en gResProc del response principal. El dProtAut es el número de autorización que debe almacenarse en la BD.
Tabla de estados:
dEstRes | Significado |
|---|---|
0 | Aceptado |
1 | Rechazado |
2 | Cancelado |
3 | Inutilizado |
12. Tabla de diferencias: factura_Java.json vs factura_v150.json
factura_Java.json es el DTE aprobado por la SET (estándar de éxito).
factura_v150.json es el fixture PHP con fixes aplicados.
Diferencias estructurales críticas
| # | Nodo/Campo | factura_Java.json | factura_v150.json | Estado PHP |
|---|---|---|---|---|
| 1 | Escenario | B2C (NO_CONTRIBUYENTE) | B2B (CONTRIBUYENTE=1) | Ambos deben soportarse |
| 2 | iNatRec | "NO_CONTRIBUYENTE" (string Java) | 1 (int) | ✅ PHP emite int |
| 3 | iTiOpe | "B2C" (string Java) | 1 (int, B2B) | ✅ PHP emite int |
| 4 | iTipIDRec / dNumIDRec | Presentes (B2C) | Ausentes (B2B) | ✅ Condicional |
| 5 | dRucRec / dDVRec | Ausentes (B2C) | Presentes (B2B) | ✅ Condicional |
| 6 | gTotSub — valores | Todos 0 (ejemplo mínimo) | Calculados correctamente | ✅ PHP calcula |
| 7 | dTotDescGlotem | Presente (nombre correcto) | Presente (nombre correcto) | ✅ Nombre verificado |
| 8 | dTBasGraIVA | Presente (nombre correcto) | Presente (nombre correcto) | ✅ NO dTotalbaseIVA |
| 9 | iCondOpe | "CREDITO" + gPagCred | 1 (CONTADO) + gPaConEIni | Ambos escenarios |
| 10 | dBasExe en gCamIVA | AUSENTE | 0 (gravado), 50000 (exento) | ✅ Fix E737 PHP |
| 11 | dDesProSer | Presente (nombre correcto) | Presente (nombre correcto) | ✅ NO dDesProd |
| 12 | dParAranc | 0 (emitido — error!) | OMITIDO (Fix #13) | ✅ PHP omite si 0 |
| 13 | dTotOpeGs | Ausente en gValorRestaItem | Presente en gValorRestaItem | Para moneda extranjera |
| 14 | dDMoneTiPag | No aplica (crédito) | "Guaraní" (Fix E608) | ✅ PHP genera nombre |
| 15 | dNomFanEmi | Ausente | Presente | ✅ Fallback a razonSocial |
Campos en factura_Java.json emitidos que el PHP debe replicar
Campos presentes en el DTE aprobado que no son obvios:
| Campo XML | Clase Java | Descripción | Regla de emisión |
|---|---|---|---|
dDesPaisRe | TgDatRec | Nombre del país del receptor | SIEMPRE junto a cPaisRec |
dDCondOpe | TgCamCond | Descripción de la condición | SIEMPRE junto a iCondOpe |
dDCondCred | TgPagCred | Descripción de la condición crédito | SIEMPRE junto a iCondCred |
dDesUniMed | TgCamItem | Descripción de unidad de medida | SIEMPRE junto a cUniMed |
dDesAfecIVA | TgCamIVA | Descripción de afectación | SIEMPRE junto a iAfecIVA |
dDTipIDRec | TgDatRec | Descripción del tipo de ID | SIEMPRE junto a iTipIDRec (B2C) |
dTotBruOpeItem | TgValorItem | Precio × cantidad (bruto) | SIEMPRE en gValorItem |
dTotOpeItem | TgValorRestaItem | Total de línea (neto) | SIEMPRE en gValorRestaItem |
Bug conocido en el código Java fuente
// TiTipCom.java — valores duplicados (NO implementar igual en PHP)
DIESEL(2, "Diésel")
ETANOL(2, "Etanol") // ← mismo val=2 que DIESEL
GNV(2, "GNV") // ← mismo val=2
FLEX(2, "Flex") // ← mismo val=2
En PHP, estos deben tener valores únicos según el XSD oficial v150.
Apéndice: Enums clave para el Builder PHP
TTiDE — Tipo de Documento Electrónico
| Valor | Nombre | ¿Tiene gTotSub? |
|---|---|---|
| 1 | FACTURA_ELECTRONICA | Sí |
| 2 | FACTURA_EXPORTACION | Sí |
| 3 | FACTURA_IMPORTACION | Sí |
| 4 | AUTOFACTURA_ELECTRONICA | No (lógica especial) |
| 5 | NOTA_CREDITO | Sí |
| 6 | NOTA_DEBITO | Sí |
| 7 | NOTA_REMISION | No gTotSub, No gValorItem |
| 8 | COMPROBANTE_RETENCION | No |
TTImp — Tipo de Impuesto
| Valor | Nombre | ¿Emite dSub5/dSub10/dIVA5/dIVA10? |
|---|---|---|
| 1 | IVA | Sí |
| 2 | RENTA | No |
| 3 | IVA_RENTA | Sí |
| 5 | ISC | Sí |
TiAfecIVA — Afectación IVA
| Valor | Nombre | dBasGravIVA | dLiqIVAItem | dBasExe (NT013) | Acumula en |
|---|---|---|---|---|---|
| 1 | GRAVADO | total/1.1 ó /1.05 | total/11 ó /21 | 0 | dSub10/dSub5 |
| 2 | EXONERADO | 0 | 0 | 0 | dSubExo |
| 3 | EXENTO | 0 | 0 | total_linea (PHP) | dSubExe |
| 4 | GRAVADO_PARCIAL | fórmula prop | fórmula prop | fórmula NT013 | dSub10/dSub5 |
TiCondOpe — Condición de Operación
| Valor | Nombre | Estructura de pago |
|---|---|---|
| 1 | CONTADO | gPaConEIniList (1..n medios de pago) |
| 2 | CREDITO | gPagCred (condición + plazo + cuotas) |
CMondT — Moneda
| Código | Nombre para dDMoneTiPag |
|---|---|
| PYG | Guaraní |
| USD | Dólar Americano |
| EUR | Euro |
| BRL | Real |
| ARS | Peso Argentino |
Para PYG: todos los montos son enteros (
scale=0,RoundingMode.HALF_UP). Para otras monedas: 2 decimales (scale=2). Para PYG:dTiCamydCondTiCamse omiten (restricción XSDminExclusive).