Saltar al contenido principal

SIFEN Logic Reference — Guía de Implementación PHP

Auditoría: Código fuente Java de los módulos rshk-jsifen-maven, onnix-sifen-lib y onnix-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

  1. Arquitectura de los módulos Java
  2. Flujo de transmisión end-to-end
  3. Receptor (gDatRec): Matriz B2B vs B2C
  4. Condición de Venta (gCamCond): Contado vs Crédito
  5. Ítem (gCamItem): secuencia exacta de tags
  6. IVA por ítem (gCamIVA): fórmulas y orden de emisión
  7. Totales (gTotSub): algoritmo y secuencia
  8. Capa de transporte SOAP
  9. Firma digital (SignatureHelper)
  10. CDC y Dígito Verificador (SifenUtil)
  11. Respuesta de la SET (xProtDE)
  12. Tabla de diferencias: factura_Java.json vs factura_v150.json

1. Arquitectura de los módulos Java

Roles de cada módulo

MóduloRol principalClase de entrada
rshk-jsifen-mavenMotor SIFEN: construye XML, firma, envía SOAPcom.roshka.sifen.Sifen
onnix-sifen-libEspejo de POJOs con soporte Jackson/JSON para APIs RESTpy.com.onnix.sifen.DocumentoElectronico
onnix-sifen-driverAPI Spring Boot que expone endpoints HTTP y orquesta el motorDocumentTransmissionService

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: TgDatReccom.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óniNatReciTiOpeCampos obligatoriosCampos ausentes
B2B — Empresa paraguaya contribuyente11iTiContRec, dRucRec, dDVReciTipIDRec, dNumIDRec
B2C — Persona física con CI22iTipIDRec=1, dNumIDReciTiContRec, dRucRec
B2C — Extranjero con pasaporte22iTipIDRec=3, dNumIDReciTiContRec, dRucRec
OtroCaso — Sin identificación24dNomReciTipIDRec, dRucRec

Nota crítica: iTiOpe pertenece a gDatRec (no a gOpeCom). El gOpeCom solo contiene iTImp, cMoneOpe y opcionalmente iTipTra. Ver factura_v150.json → _tipoOperacion_tag.


4. Condición de Venta (gCamCond): Contado vs Crédito

Clase Java: TgCamCondcom.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: TgPagCredcom.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=0 con iCondCred=PLAZO es válido cuando el pago es en un único plazo sin cuotas numeradas.


5. Ítem (gCamItem): secuencia exacta de tags

Clase Java: TgCamItemcom.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 (NO dDesProd). Verificado en TgCamItem.java línea 71: gCamItem.addChildElement("dDesProSer").


6. IVA por ítem (gCamIVA): fórmulas y orden de emisión

Clase Java: TgCamIVAcom.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=110000 y dPropIVA=100:

  • dBasGravIVA = 110000 / 1.1 = 100000
  • dLiqIVAItem = 110000 / 11 = 10000 Coincide exactamente con factura_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.json muestra dBasExe=50000 para el ítem exento (iAfecIVA=3). El Java original asigna dBasExe=0 para todos excepto GRAVADO_PARCIAL. La implementación PHP extiende la lógica NT013 estableciendo dBasExe = dTotOpeItem para í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: TgTotSubcom.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 si iTImp ∈ {1, 5} (IVA o ISC)
  • dTotalGs: solo se emite si cMoneOpe != PYG
  • dComi + dIVAComi: solo se emiten si dComi != null
  • Todo el grupo excepto dTotOpe se omite para iTiDE=4 (Autofactura)

8. Capa de transporte SOAP

Clase Java: SoapHelpercom.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: SignatureHelpercom.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: SifenUtilcom.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: RespuestaRecepcionDEcom.roshka.sifen.core.beans.response.RespuestaRecepcionDE Clase protocolo: TxProtDecom.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:

dEstResSignificado
0Aceptado
1Rechazado
2Cancelado
3Inutilizado

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/Campofactura_Java.jsonfactura_v150.jsonEstado PHP
1EscenarioB2C (NO_CONTRIBUYENTE)B2B (CONTRIBUYENTE=1)Ambos deben soportarse
2iNatRec"NO_CONTRIBUYENTE" (string Java)1 (int)✅ PHP emite int
3iTiOpe"B2C" (string Java)1 (int, B2B)✅ PHP emite int
4iTipIDRec / dNumIDRecPresentes (B2C)Ausentes (B2B)✅ Condicional
5dRucRec / dDVRecAusentes (B2C)Presentes (B2B)✅ Condicional
6gTotSub — valoresTodos 0 (ejemplo mínimo)Calculados correctamente✅ PHP calcula
7dTotDescGlotemPresente (nombre correcto)Presente (nombre correcto)✅ Nombre verificado
8dTBasGraIVAPresente (nombre correcto)Presente (nombre correcto)✅ NO dTotalbaseIVA
9iCondOpe"CREDITO" + gPagCred1 (CONTADO) + gPaConEIniAmbos escenarios
10dBasExe en gCamIVAAUSENTE0 (gravado), 50000 (exento)✅ Fix E737 PHP
11dDesProSerPresente (nombre correcto)Presente (nombre correcto)✅ NO dDesProd
12dParAranc0 (emitido — error!)OMITIDO (Fix #13)✅ PHP omite si 0
13dTotOpeGsAusente en gValorRestaItemPresente en gValorRestaItemPara moneda extranjera
14dDMoneTiPagNo aplica (crédito)"Guaraní" (Fix E608)✅ PHP genera nombre
15dNomFanEmiAusentePresente✅ 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 XMLClase JavaDescripciónRegla de emisión
dDesPaisReTgDatRecNombre del país del receptorSIEMPRE junto a cPaisRec
dDCondOpeTgCamCondDescripción de la condiciónSIEMPRE junto a iCondOpe
dDCondCredTgPagCredDescripción de la condición créditoSIEMPRE junto a iCondCred
dDesUniMedTgCamItemDescripción de unidad de medidaSIEMPRE junto a cUniMed
dDesAfecIVATgCamIVADescripción de afectaciónSIEMPRE junto a iAfecIVA
dDTipIDRecTgDatRecDescripción del tipo de IDSIEMPRE junto a iTipIDRec (B2C)
dTotBruOpeItemTgValorItemPrecio × cantidad (bruto)SIEMPRE en gValorItem
dTotOpeItemTgValorRestaItemTotal 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

ValorNombre¿Tiene gTotSub?
1FACTURA_ELECTRONICA
2FACTURA_EXPORTACION
3FACTURA_IMPORTACION
4AUTOFACTURA_ELECTRONICANo (lógica especial)
5NOTA_CREDITO
6NOTA_DEBITO
7NOTA_REMISIONNo gTotSub, No gValorItem
8COMPROBANTE_RETENCIONNo

TTImp — Tipo de Impuesto

ValorNombre¿Emite dSub5/dSub10/dIVA5/dIVA10?
1IVA
2RENTANo
3IVA_RENTA
5ISC

TiAfecIVA — Afectación IVA

ValorNombredBasGravIVAdLiqIVAItemdBasExe (NT013)Acumula en
1GRAVADOtotal/1.1 ó /1.05total/11 ó /210dSub10/dSub5
2EXONERADO000dSubExo
3EXENTO00total_linea (PHP)dSubExe
4GRAVADO_PARCIALfórmula propfórmula propfórmula NT013dSub10/dSub5

TiCondOpe — Condición de Operación

ValorNombreEstructura de pago
1CONTADOgPaConEIniList (1..n medios de pago)
2CREDITOgPagCred (condición + plazo + cuotas)

CMondT — Moneda

CódigoNombre para dDMoneTiPag
PYGGuaraní
USDDólar Americano
EUREuro
BRLReal
ARSPeso Argentino

Para PYG: todos los montos son enteros (scale=0, RoundingMode.HALF_UP). Para otras monedas: 2 decimales (scale=2). Para PYG: dTiCam y dCondTiCam se omiten (restricción XSD minExclusive).