# Análisis comparativo: TedBuilder y XmlSignatureService

Comparación línea-a-línea entre la implementación actual de `api-a-conta-fact` y la operativa de `DTEngine` (que ya pasa certificación SII). Documenta **gaps con riesgo de rechazo SII** y las correcciones recomendadas antes de la primera emisión real.

> ⚠️ Este documento es de lectura obligatoria antes de probar emisión real contra `maullin.sii.cl`. Cada gap marcado con 🔴 puede causar rechazo del DTE.

---

## Resumen de gaps detectados

| # | Tema | Gap | Severidad |
|---|---|---|---|
| 1 | TED: encoding de campos antes de firmar | UTF-8 vs ISO-8859-1 | 🔴 Alta |
| 2 | TED: truncamiento RSR/IT1 a 40 chars | No truncado | 🔴 Alta |
| 3 | TED: estructura del nodo CAF embebido | `da_xml` vs CAF completo | 🔴 Crítica |
| 4 | TED: carga RSASK con phpseclib | `RSA::load` vs `PublicKeyLoader::load` | 🟡 Media |
| 5 | Sobre: tag `EnvioBOLETA` vs `EnvioDTE` para tipos 39/41 | Solo `EnvioDTE` | 🔴 Crítica |
| 6 | Sobre: `xsi:schemaLocation` requerido | No incluido | 🔴 Crítica |
| 7 | Sobre: `RutEnvia` debe venir del cert | Hardcoded a RutEmisor | 🟡 Media |
| 8 | Sobre: `FchResol`/`NroResol` desde BD | Hardcoded a `2014-08-22` / `80` | 🔴 Alta |
| 9 | Sobre: DOM::saveXML reprocesa DTE firmado | Sí, puede romper firma | 🔴 Crítica |
| 10 | Firma: wrap base64 a 76 caracteres | No envuelto | 🟡 Media |
| 11 | Firma: ISO ↔ UTF-8 al firmar | Trabaja directo en ISO-8859-1 | 🟡 Media |

---

## Gap 1 🔴 — Encoding de campos del DD antes de firmar

**Síntoma esperado:** Firma `<FRMT>` distinta a la que el SII puede validar contra el `<DD>` que llega. Rechazo por firma TED inválida en documentos con caracteres especiales (ñ, acentos).

**Código actual (`TedBuilder.php` línea 24):**

```php
$nombreItem = htmlspecialchars($primerItem['nombre'] ?? 'Item', ENT_XML1, 'UTF-8');
// ...
. '<RR>' . htmlspecialchars($rutRecep, ENT_XML1, 'UTF-8') . '</RR>'
. '<RSR>' . htmlspecialchars($dteData['razon_social_receptor'] ?? 'SIN RAZON SOCIAL', ENT_XML1, 'UTF-8') . '</RSR>'
```

**DTEngine (`TedBuilder.php` línea 164):**

```php
private function escaparValor(mixed $value): string
{
    $string = mb_convert_encoding((string) $value, 'ISO-8859-1', 'UTF-8');
    return htmlspecialchars($string, ENT_XML1 | ENT_QUOTES, 'ISO-8859-1');
}
```

**Por qué importa:** El XML del DTE es ISO-8859-1 según especificación SII. Si los caracteres especiales se escapan suponiendo UTF-8 pero el documento final es ISO, los bytes firmados no coinciden con los bytes que recibe el validador.

**Corrección:**

```php
private function escaparCampo(string $value): string
{
    $iso = mb_convert_encoding($value, 'ISO-8859-1', 'UTF-8');
    return htmlspecialchars($iso, ENT_XML1 | ENT_QUOTES, 'ISO-8859-1');
}
```

Aplicar a RE, RR, RSR, IT1.

---

## Gap 2 🔴 — Truncamiento RSR / IT1 a 40 caracteres

**Síntoma esperado:** SII rechaza el DTE con error "longitud excedida en RSR/IT1".

**Código actual (`TedBuilder.php`):** No trunca.

**DTEngine (`TedBuilder.php` líneas 45-47):**

```php
'RSR' => mb_substr($encabezado['Receptor']['RznSocRecep'], 0, 40),
'IT1' => mb_substr($detalle[0]['NmbItem'] ?? '', 0, 40),
```

**Corrección:** En `TedBuilder::buildDD()`:

```php
$rsr = mb_substr($dteData['razon_social_receptor'] ?? 'SIN RAZON SOCIAL', 0, 40);
$it1 = mb_substr($primerItem['nombre'] ?? 'Item', 0, 40);
```

---

## Gap 3 🔴 CRÍTICA — Estructura del nodo CAF embebido en DD

**Síntoma esperado:** Rechazo del TED por estructura inválida. Es probablemente el **gap más crítico**.

**Código actual (`TedBuilder.php` línea 33-44):**

```php
$cafDaNodo = $cafData['da_xml'] ?? '';
// ...
. '<IT1>' . $nombreItem . '</IT1>'
. $cafDaNodo  // ← Asume que viene un nodo <DA> ya formado
. '<TSTED>' . date('Y-m-d\TH:i:s') . '</TSTED>'
```

El código actual **espera** un string XML `<DA>...</DA>` en `cafData['da_xml']`, pero el SII espera el nodo `<CAF>` completo con sus elementos: `<DA>`, `<FRMA>`, `<RSAPK>` opcional.

**DTEngine (`TedBuilder.php` línea 39-50):**

```php
$dd = [
    'RE'  => ...,
    'TD'  => ...,
    'F'   => ...,
    'FE'  => ...,
    'RR'  => ...,
    'RSR' => ...,
    'MNT' => ...,
    'IT1' => ...,
    'CAF' => $cafData,  // ← Array con CAF completo extraído del XML del CAF
    'TSTED' => ...,
];
```

Y `extraerDatosCaf()` devuelve `$decoded['AUTORIZACION']['CAF']` — toda la estructura, no solo DA.

**Estructura correcta del DD según SII:**

```xml
<DD>
  <RE>76543210-K</RE>
  <TD>33</TD>
  <F>1001</F>
  <FE>2026-05-19</FE>
  <RR>11111111-1</RR>
  <RSR>RAZON SOCIAL TRUNCADA A 40 CHARS</RSR>
  <MNT>119000</MNT>
  <IT1>NOMBRE ITEM TRUNCADO A 40 CHARS</IT1>
  <CAF version="1.0">
    <DA>
      <RE>76543210-K</RE>
      <RS>EMPRESA SPA</RS>
      <TD>33</TD>
      <RNG><D>1001</D><H>1500</H></RNG>
      <FA>2026-05-01</FA>
      <RSAPK><M>...</M><E>Aw==</E></RSAPK>
      <IDK>100</IDK>
    </DA>
    <FRMA algoritmo="SHA1withRSA">...</FRMA>
  </CAF>
  <TSTED>2026-05-19T15:30:45</TSTED>
</DD>
```

**Corrección:** `CafService::parsearCaf()` debe devolver el nodo `<CAF>` completo como string, no como array desestructurado. Y `TedBuilder` debe insertarlo tal cual.

**Validación obligatoria:** Tomar un CAF real, extraer manualmente el nodo `<CAF>` y compararlo contra el que produce la implementación actual. El XML byte-a-byte debe coincidir con el del CAF original (whitespace incluido).

---

## Gap 4 🟡 — Carga de RSASK con phpseclib

**Síntoma esperado:** Excepción al cargar llaves RSA antiguas (formato PKCS#1 1024-bit típico del SII).

**Código actual (`TedBuilder.php` línea 68):**

```php
$privateKey = RSA::load($rsask);
```

**DTEngine (`TedBuilder.php` línea 113):**

```php
$privKey = PublicKeyLoader::load($rsask);
```

`PublicKeyLoader::load()` detecta automáticamente el formato (PKCS#1 vs PKCS#8, PEM vs DER) y maneja casos edge. `RSA::load()` es más estricto.

**Corrección:**

```php
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\RSA;

$privKey = PublicKeyLoader::load($rsask);
$privKey = $privKey->withHash('sha1')->withPadding(RSA::SIGNATURE_PKCS1);
$firma = $privKey->sign($ddCanonical);
```

---

## Gap 5 🔴 CRÍTICA — Tag del sobre: EnvioBOLETA vs EnvioDTE

**Síntoma esperado:** SII rechaza boletas (tipos 39 y 41) si vienen dentro de `<EnvioDTE>` en lugar de `<EnvioBOLETA>`.

**Código actual (`DteEmissionService.php` línea 176):**

```php
return '<?xml version="1.0" encoding="ISO-8859-1"?>'
    . '<EnvioDTE version="1.0" xmlns="http://www.sii.cl/SiiDte">'
    // ... siempre EnvioDTE, sin importar tipo
```

**DTEngine (`EnvelopeService.php` línea 66):**

```php
$tagSobre = in_array($tipoDte, [39, 41]) ? 'EnvioBOLETA' : 'EnvioDTE';
```

**Corrección:**

```php
$tagSobre = in_array($dte->tipo_dte, [39, 41]) ? 'EnvioBOLETA' : 'EnvioDTE';

return '<?xml version="1.0" encoding="ISO-8859-1"?>'
    . '<' . $tagSobre . ' version="1.0" xmlns="http://www.sii.cl/SiiDte" ...>'
    . '<SetDTE ...>'
    // ...
    . '</' . $tagSobre . '>';
```

---

## Gap 6 🔴 CRÍTICA — xsi:schemaLocation requerido

**Síntoma esperado:** Error SII `SCH-00001` (esquema no identificado).

**Código actual (`DteEmissionService.php` línea 176):**

```php
. '<EnvioDTE version="1.0" xmlns="http://www.sii.cl/SiiDte">'
```

**DTEngine (`EnvelopeService.php` líneas 157-164):**

```php
$schemaFile = ($tagSobre === 'EnvioBOLETA') ? 'EnvioBOLETA.xsd' : 'EnvioDTE_v10.xsd';
// ...
. '<' . $tagSobre
. ' xmlns="http://www.sii.cl/SiiDte"'
. ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
. ' xsi:schemaLocation="http://www.sii.cl/SiiDte ' . $schemaFile . '"'
. ' version="1.0">'
```

**Importante:** Para DTEs comunes el archivo es `EnvioDTE_v10.xsd` (NO `EnvioDTE.xsd` — eso dispara `SCH-00001` en certificación).

**Corrección:** Agregar atributos `xmlns:xsi` y `xsi:schemaLocation` con el archivo correcto según tipo.

---

## Gap 7 🟡 — RutEnvia debe ser el RUT del firmante (del cert), no del emisor

**Síntoma esperado:** Rechazo cuando el certificado pertenece a una persona distinta al emisor (caso común: cert del representante legal).

**Código actual (`DteEmissionService.php` línea 180):**

```php
. '<RutEnvia>' . htmlspecialchars($rutEmisor, ENT_XML1, 'UTF-8') . '</RutEnvia>'
```

**DTEngine (`EnvelopeService.php` línea 222):**

```php
'RutEnvia' => $cert->getId(),  // RUT del titular del PFX
```

**Corrección:** `CertificateService::load()` debe extraer el RUT del subject del certificado y exponerlo en `$certData['rut_firmante']`. Usar ese valor para `RutEnvia`.

---

## Gap 8 🔴 — FchResol y NroResol hardcoded

**Síntoma esperado:** Rechazo si la resolución del SII no es la que está hardcoded.

**Código actual (`DteEmissionService.php` líneas 182-183):**

```php
. '<FchResol>2014-08-22</FchResol>'
. '<NroResol>80</NroResol>'
```

**DTEngine:** Lee `$emisor->fecha_resolucion` y `$emisor->nro_resolucion` de la BD.

**Corrección:** Agregar columnas a `par_empresas`:

```sql
ALTER TABLE par_empresas
  ADD COLUMN dte_fecha_resolucion DATE NULL,
  ADD COLUMN dte_nro_resolucion VARCHAR(10) NULL;
```

Y leerlos en lugar del hardcoded. Para certificación, en `maullin.sii.cl` el valor estándar suele ser `NroResol=0` con fecha de habilitación, pero **debe venir de la resolución real entregada por el SII al habilitar a la empresa**.

---

## Gap 9 🔴 CRÍTICA — DOM::saveXML reprocesa el DTE ya firmado e invalida la firma

**Síntoma esperado:** SII rechaza por firma del Documento inválida. La firma se generó sobre los bytes X pero el sobre lleva los bytes Y.

**Código actual (`DteEmissionService.php` líneas 171-173):**

```php
$doc = new \DOMDocument('1.0', 'ISO-8859-1');
$doc->loadXML($xmlDteFirmado);          // ← parsea con DOM
$dteSigned = $doc->saveXML($doc->documentElement);  // ← serializa de vuelta
```

Esta secuencia **modifica los bytes** del XML firmado:
- DOM puede reordenar atributos
- DOM elimina/agrega whitespace
- DOM puede hacer "namespace hoisting" del `<Signature>` xmldsig al nodo padre

Cualquiera de estos cambios invalida la firma XMLDSig.

**DTEngine (`EnvelopeService.php` líneas 147-149):**

```php
// IMPORTANTE: usa string concatenation para embeber el DTE (no DOM/importNode).
// DOM::importNode() hace "namespace hoisting" del namespace xmldsig del <Signature>
// al nivel del <DTE>, lo que cambia el C14N del <Documento> e invalida la firma del DTE.
// String concatenation preserva el DTE exactamente como fue firmado.

$dteContenido = collect($dtesFirmados)
    ->map(fn(string $dteSignado): string => ltrim(preg_replace('/^<\?xml[^?]*\?>\s*/s', '', $dteSignado)))
    ->implode('');
```

**Corrección:** Remover el roundtrip DOM. Solo despojar la declaración XML del DTE firmado y concatenar como string:

```php
$dteSigned = ltrim(preg_replace('/^<\?xml[^?]*\?>\s*/s', '', $xmlDteFirmado));

return '<?xml version="1.0" encoding="ISO-8859-1"?>'
    . '<' . $tagSobre . ' ...>'
    . '<SetDTE ID="SetDoc">'
    . '<Caratula ...>...</Caratula>'
    . $dteSigned
    . '</SetDTE>'
    . '</' . $tagSobre . '>';
```

---

## Gap 10 🟡 — Wrap base64 de SignatureValue/X509Certificate

**Síntoma:** XSD oficial espera los nodos base64 envueltos a 76 chars. xmlseclibs los deja en una línea. SII generalmente acepta ambos, pero algunos validadores externos no.

**Código actual (`XmlSignatureService.php`):** No envuelve.

**DTEngine (`EnvelopeService.php` líneas 254-277):** envuelve a 76 chars con `wordwrap()` después de firmar.

**Corrección (opcional pero recomendada):**

```php
private function wrapBase64Nodes(string $xml): string
{
    foreach (['SignatureValue', 'X509Certificate'] as $tag) {
        $xml = preg_replace_callback(
            '/<' . $tag . '(\b[^>]*)>(.*?)<\/' . $tag . '>/s',
            function (array $m) use ($tag): string {
                $clean = preg_replace('/\s+/', '', $m[2]);
                if ($clean === '') return $m[0];
                return '<' . $tag . $m[1] . '>' . "\n"
                    . wordwrap($clean, 76, "\n", true) . "\n"
                    . '</' . $tag . '>';
            },
            $xml
        );
    }
    return $xml;
}
```

Aplicar al output de `firmarDocumento` y `firmarEnvio`.

---

## Gap 11 🟡 — ISO/UTF-8 swap al firmar

**Síntoma:** xmlseclibs trabaja internamente con UTF-8. Firmar directamente sobre un `DOMDocument` declarado como ISO-8859-1 puede producir canonicalización inconsistente.

**Código actual (`XmlSignatureService.php` líneas 13-14):**

```php
$doc = new \DOMDocument('1.0', 'ISO-8859-1');
$doc->preserveWhiteSpace = false;
```

**DTEngine (`EnvelopeService.php` líneas 86-88):**

```php
$dteSignado = $this->utf8ToIso(
    $this->signatureService->signXml($this->isoToUtf8($dteConTed), $cert, $dteId)
);
```

DTEngine convierte el XML a UTF-8 para firmar, luego vuelve a ISO. Esto evita que la lib mezcle codificaciones.

**Corrección:** wrap a `firmarDocumento`/`firmarEnvio`:

```php
public function firmarDocumento(string $xmlDte, array $certData): string
{
    $utf8 = mb_convert_encoding($xmlDte, 'UTF-8', 'ISO-8859-1');
    $utf8 = preg_replace('/encoding="ISO-8859-1"/i', 'encoding="UTF-8"', $utf8);

    $firmadoUtf8 = $this->firmarInternal($utf8, $certData);  // ← lógica actual

    $iso = preg_replace('/encoding="UTF-8"/i', 'encoding="ISO-8859-1"', $firmadoUtf8);
    return mb_convert_encoding($iso, 'ISO-8859-1', 'UTF-8');
}
```

---

## Plan de cierre antes de probar

Orden recomendado (de más a menos crítico):

1. **Gap 3 (CAF en DD)** — sin esto el TED no se valida nunca
2. **Gap 9 (DOM::saveXML rompe firma)** — bug latente, garantiza rechazo
3. **Gap 6 (xsi:schemaLocation)** — error SCH-00001 garantizado en certificación
4. **Gap 5 (EnvioBOLETA para 39/41)** — solo afecta boletas pero es crítico
5. **Gap 8 (FchResol/NroResol)** — agregar a par_empresas + leer de BD
6. **Gap 1 (encoding ISO al escapar)** — afecta cualquier DTE con acentos/ñ
7. **Gap 2 (truncar RSR/IT1)** — defensivo, evita errores aleatorios
8. **Gap 7 (RutEnvia del cert)** — necesario si cert ≠ emisor
9. **Gap 4 (PublicKeyLoader)** — defensivo para CAF con llaves antiguas
10. **Gap 10 (wrap base64)** — cosmético/defensivo
11. **Gap 11 (ISO/UTF-8 swap firma)** — defensivo

Para validar cada corrección sin pegarle al SII:
- Comparar TED generado vs `tests/Fixtures/DteEngine/muestras/*.ted.xml`
- Comparar XML DTE completo vs `tests/Fixtures/DteEngine/simulacion/factura_dte_33_folio_52.xml`
- Verificar que el sobre EnvioDTE pase validación XSD local (descargar `EnvioDTE_v10.xsd` del SII)

---

## Estado al 2026-05-19

Ninguno de estos gaps está corregido todavía. Se decide priorizarlos cuando lleguen las credenciales de certificación SII, idealmente en este orden: 3 → 9 → 6 → 5 → 8 → 1 → 2.
