# Primeros pasos: emitir una boleta

> Configurar ConfigToken, cargar el CAF, armar un Documento tipo 39 con todos sus campos obligatorios, emitir con emitir, leer el resultado y consultar el estado.

Fuente: https://sii.devlas.cl/docs/primeros-pasos

# Primeros pasos: emitir una boleta electrónica (tipo 39)

Objetivo: generar una boleta 39, enviarla al SII y saber si la aceptó. `emitir` de `@devlas-cl/sii` hace todo el camino en una llamada: reserva el folio en `AlmacenCaf`, valida, calcula montos, arma el XML, lo timbra, lo firma, lo valida contra el XSD, arma y firma el sobre y lo sube (boletas 39 y 41 por la API REST; el resto por `DTEUpload`). Nivel: Libre. Una boleta 39 emitida con `emitir` fue aceptada por el SII en el ambiente de certificación, con `TSTED`, `TmstFirma` y `TmstFirmaEnv` en hora de Chile.

## Qué necesitas

| Qué | Cómo se obtiene | Dónde se usa |
|---|---|---|
| Certificado digital (`.pfx` y su clave) del titular autorizado | Lo tramita el contribuyente. `abrirCertificado(pfx, clave)` lo lee; la clave nunca se registra. | Firmar el DTE, el sobre y la semilla |
| Folios (un CAF) del tipo 39 | Un CAF por tipo, ambiente y RUT emisor. Lo descargas del portal del SII o lo pide `autoCaf` (nivel AutoCAF). | `AlmacenCaf` |
| La resolución del ambiente (`fchResol`, `nroResol`) | De la ficha de la empresa (`consultarEmpresa` devuelve `fechaResolucion` y `numeroResolucion`). En certificación `nroResol` es 0. Nunca un valor por defecto: mezclar ambientes da `RCT` (rechazo en carátula). | `caratula` |
| Los puertos `Http`, `AlmacenCaf`, `Registro` y, opcional, `AlmacenSesion` para tokens | Los implementa tu sistema | `ConfigToken` y `emitir` |

## 1. Configuración (`ConfigToken`)

Las llamadas con token (envío, estado, reclamos) reciben `ConfigToken = ConfigBase & { rutTitular, firmador, almacenTokens?, guardaDeAmbiente? }`, donde `ConfigBase = { http, reloj, ambiente, registro?, esperar?, reintentos?, conservarCrudo? }`.

```ts
import { readFile } from 'node:fs/promises'
import type { AlmacenCaf, AlmacenSesion, Ambiente, Http, Registro } from '@devlas-cl/comun'
import { type ConfigToken, crearFirmadorSemilla } from '@devlas-cl/sii'
import { abrirCertificado } from '@devlas-cl/xml'

// Implementaciones de los puertos en tu sistema:
declare const http: Http
declare const almacenCaf: AlmacenCaf       // reservarFolio atómico entre réplicas
declare const almacenTokens: AlmacenSesion // opcional: reutiliza el token entre llamadas y réplicas
declare const registro: Registro           // opcional: eventos y errores

const ambiente: Ambiente = 'certificacion'

// El .pfx del titular y su clave. La librería nunca registra la clave.
const cert = abrirCertificado(await readFile('certificado.pfx'), process.env.CLAVE_PFX ?? '')
if (!cert.ok) throw new Error(cert.error.codigo)
const certificado = cert.valor // certificado.rutTitular: RUT de la persona titular

const configToken: ConfigToken = {
  http,
  reloj: { ahora: () => new Date() },
  ambiente,
  rutTitular: certificado.rutTitular,          // quien firma y envía
  firmador: crearFirmadorSemilla(certificado), // firma la semilla para obtener el token
  almacenTokens,
  registro,
}
```

## 2. Cargar el CAF en el almacén

Si no usas AutoCAF, guardas tú el archivo que descargaste del SII. Guarda el **texto original completo** (con la llave privada), no `caf.valor.xml` (que es solo el bloque `<CAF>` y no sirve para timbrar). Lee el archivo como ISO-8859-1.

```ts
import { readFile } from 'node:fs/promises'
import type { AlmacenCaf, ClaveCaf } from '@devlas-cl/comun'
import { leerCaf } from '@devlas-cl/xml'

declare const almacenCaf: AlmacenCaf

const clave: ClaveCaf = { rutEmisor: '7654321-6', tipoDte: 39, ambiente: 'certificacion' }

const archivoCaf = await readFile('caf-39.xml', 'latin1')
const caf = leerCaf(archivoCaf)
if (!caf.ok) throw new Error(caf.error.codigo)
const { folioDesde, folioHasta, vencimiento } = caf.valor // vencimiento: null en 39 y 41 (no vencen)

const ranura = await almacenCaf.guardarCaf(clave, {
  xml: archivoCaf, // texto COMPLETO: cífralo en reposo
  desde: folioDesde,
  hasta: folioHasta,
  ...(vencimiento === null ? {} : { venceEn: vencimiento.getTime() }),
}) // 'activa' | 'siguiente'
```

Si ya emitiste folios de ese rango con otro sistema, pasa `{ ultimoFolioUsado: n }` como tercer argumento de `guardarCaf`.

## 3. El documento

`Documento` de `@devlas-cl/dominio` usa el vocabulario del SII. Campos obligatorios por tipo: `tipo`, `fechaEmision` (AAAA-MM-DD), `emisor`, `base`, `lineas`. En la boleta, `indServicio` es obligatorio por validación aunque el tipo lo declare opcional. `Emisor` exige `rut`, `razonSocial`, `giro`, `direccion` y `comuna`. `Linea` exige `nombre` y `cantidad`; `precioUnitario` es el precio por unidad.

```ts
import { type Documento, fechaTributaria } from '@devlas-cl/dominio'

const boleta: Documento = {
  tipo: 39,
  fechaEmision: fechaTributaria(new Date()), // AAAA-MM-DD en hora de Chile, nunca toISOString()
  emisor: {
    rut: '7654321-6',
    razonSocial: 'Comercial de Prueba SpA',
    giro: 'Venta al por menor',
    direccion: 'Calle Uno 100',
    comuna: 'Santiago',
  },
  base: 'bruto',  // los precios incluyen IVA (la boleta va siempre en bruto)
  indServicio: 3, // 1 y 2 servicios periódicos, 3 venta y servicios, 4 espectáculos
  lineas: [
    { nombre: 'Café de grano 250 g', cantidad: 2, precioUnitario: 4990 },
    { nombre: 'Galleta de avena', cantidad: 1, precioUnitario: 1500 },
  ],
}
```

Reglas de la boleta que la librería aplica por ti:

- Sin `receptor`, se declara al consumidor final `66666666-6`. Un RUT real es obligatorio si el total supera 135 UF, y entonces también `medioPago`.
- La boleta lleva el detalle en bruto y no declara `TasaIVA`, `MntBruto` ni `DscRcgGlobal`; un descuento global se reparte en las líneas.
- Largos del XSD sin recortar: un `nombre` (`NmbItem`) de más de 80 caracteres es un error `VALIDACION` con campo, límite y largo recibido. El excedente va en `descripcion` (`DscItem`, hasta 1000).
- En la boleta exenta (41), cada línea lleva `exento: true`; una línea sin esa marca es el error local `LINEA_AFECTA_EN_BOLETA_EXENTA`.
- Una boleta de total cero es advertencia: el SII la acepta con reparo y el folio se gasta.

## 4. Revisar antes de cobrar (opcional)

`prevalidarEmision` es pura (sin red). Devuelve hallazgos con `codigo`, `nivel` (`error`, `advertencia`, `info`), `campo`, `mensaje`, `accion` y `fuente`, sin gastar folio:

```ts
import type { Documento } from '@devlas-cl/dominio'
import { prevalidarEmision } from '@devlas-cl/xml'

declare const boleta: Documento

const hallazgos = prevalidarEmision(boleta, { ahora: new Date() })
const bloqueantes = hallazgos.filter((h) => h.nivel === 'error')
if (bloqueantes.length > 0) {
  // no cobrar todavía: mostrar h.mensaje y h.accion
}
```

`ContextoPrevalidacion` acepta además `folio`, `caf` (un `CafPublico`), `valorUf` (para el umbral de 135 UF), `rutEnvia` y `rutTitular`.

## 5. Emitir

Firma: `emitir(cfg: ConfigToken, entrada: EntradaEmitir): Promise<Resultado<DteEmitido>>`, con `EntradaEmitir = { documento, certificado, caratula: { fchResol, nroResol, rutReceptor?, tmstFirmaEnv? }, almacenCaf, declaraImpuestoAdicional? }` y `DteEmitido = { trackId: string, envio, dte: DtePreparado, sobre: Sobre }`.

```ts
import { type ErrorSii, mensajeParaUsuario } from '@devlas-cl/comun'
import type { AlmacenCaf } from '@devlas-cl/comun'
import type { Documento } from '@devlas-cl/dominio'
import { type ConfigToken, emitir } from '@devlas-cl/sii'
import type { Certificado } from '@devlas-cl/xml'

declare const configToken: ConfigToken
declare const certificado: Certificado
declare const almacenCaf: AlmacenCaf
declare const boleta: Documento

const sinFolios = (e: ErrorSii): boolean =>
  e.codigo === 'VALIDACION' && (e.causa as { regla?: string } | undefined)?.regla === 'sin_folios'

const r = await emitir(configToken, {
  documento: boleta,                                 // sin folio: lo entrega el almacén
  certificado,
  caratula: { fchResol: '2026-10-07', nroResol: 0 }, // resolución del ambiente (en certificación, 0)
  almacenCaf,
})

if (!r.ok) {
  if (sinFolios(r.error)) {
    // no quedan folios vigentes: llamar a autoCaf o encolar el documento
  }
  const m = mensajeParaUsuario(r.error) // { titulo, queHacer, detalle, nivel, reintentable }
  console.error(m.titulo, m.queHacer, m.detalle) // detalle: texto literal del SII o null
} else {
  const { trackId, dte } = r.valor
  // dte.folio, dte.xml (DTE firmado), dte.ted (para el PDF417), dte.plan.Totales.MntTotal, dte.advertencias
  // Tu sistema guarda dte.xml (6 años) y trackId COMO TEXTO (hasta 15 dígitos en boletas).
}
```

Qué pasa con el folio:

- Si algo falla **antes** de que el sobre pueda llegar al SII (documento inválido, CAF, XSD, token, red sin que nada llegara), el folio vuelve al almacén.
- Si el envío **pudo** llegar (tiempo agotado, corte, 5xx o rechazo del SII), el folio queda consumido, el error se devuelve tal cual y **no se reenvía**. Consulta el estado antes de decidir. Ese caso deja el evento `emitir.envio_incierto` en el `Registro`, con tipo y folio.
- Sin folios devuelve `VALIDACION` con `causa.regla === 'sin_folios'`.

## 6. Consultar el estado

El track id solo dice que el sobre **subió**. La boleta puede quedar rechazada después: consulta siempre. Una boleta se consulta por el canal `rest`; facturas, notas, guías y libros por `soap`.

```ts
import { siguienteConsultaDeEstado, veredictoFinalDeEnvio } from '@devlas-cl/protocolo'
import { type ConfigToken, consultarEstadoEnvio } from '@devlas-cl/sii'

declare const configToken: ConfigToken
declare const trackId: string
declare const enviadoEn: Date
declare const consultasHechas: number

const estado = await consultarEstadoEnvio(configToken, { canal: 'rest', trackId, rutEmisor: '7654321-6' })
if (estado.ok) {
  // estado.valor: { canal, trackId, codigo, glosa, veredicto, porTipo, numeroAtencion, fechaRecepcion, detalleReparosRechazos }
  const final = veredictoFinalDeEnvio(estado.valor)
  // final.veredicto: 'en_proceso' | 'aceptado' | 'aceptado_con_reparos' | 'parcial' | 'rechazado' | 'desconocido'
}

const siguiente = siguienteConsultaDeEstado(
  { enviadoEn, consultasHechas, ultimoCodigo: estado.ok ? estado.valor.codigo : null },
  new Date(),
)
// { accion: 'consultar', en, esperaMs } | { accion: 'cerrar', veredicto } | { accion: 'dejar_de_consultar' }
```

- `EPR` (veredicto `procesado`) es del sobre, no del documento: usa `veredictoFinalDeEnvio` para sumar los conteos de `porTipo`.
- Aceptado con reparos vale como aceptado. Rechazado nunca fue válido: corrige la causa y emite con folio nuevo.
- Tiempos medidos en certificación: la boleta tuvo veredicto en menos de 2 minutos. Consulta desde los 60 segundos.

Detalle de códigos, canales y reenvío tras un corte: `docs/estado`.

## Lo que pones tú

`http`, `almacenCaf`, `almacenTokens` y `registro` son tus implementaciones de los puertos. El contrato de `Http` es una función `solicitar(solicitud)` que no sigue redirecciones, no guarda cookies ni reintenta, envía las cabeceras en el orden dado y entrega el cuerpo en bytes tal como llegó. `AlmacenCaf` debe reservar folios de forma atómica entre réplicas (por ejemplo con Postgres).

> Pendiente en esta documentación: la guía completa de los puertos (con adaptadores de referencia para `node:https`, Redis y Postgres y sus pruebas de contrato) existe en la documentación del producto y aún no está publicada aquí. Pídela al crear tu cuenta.

## La otra puerta: piezas sueltas

Para intervenir entre pasos o agrupar hasta 500 boletas en un sobre: `almacenCaf.reservarFolio(clave)`, `leerCaf`, `prepararDte` y `prepararSobre` de `@devlas-cl/xml`, y `enviarBoletas` de `@devlas-cl/sii`. Regla de oro: un folio se libera (`almacenCaf.liberarFolio(clave, folio, reserva)`) solo si algo falló **antes** de enviar.

```ts
import type { AlmacenCaf, ClaveCaf } from '@devlas-cl/comun'
import type { Documento } from '@devlas-cl/dominio'
import { type ConfigToken, enviarBoletas } from '@devlas-cl/sii'
import { type Certificado, leerCaf, prepararDte, prepararSobre } from '@devlas-cl/xml'

declare const configToken: ConfigToken
declare const almacenCaf: AlmacenCaf
declare const certificado: Certificado
declare const boleta: Documento
declare const clave: ClaveCaf

const ahora = configToken.reloj.ahora()
const reservado = await almacenCaf.reservarFolio(clave)
if (reservado === null) throw new Error('sin folios vigentes')

const caf = leerCaf(reservado.caf.xml)
const dte = caf.ok
  ? prepararDte({ documento: boleta, folio: reservado.folio, caf: caf.valor, certificado, ahora })
  : caf
if (!dte.ok) {
  await almacenCaf.liberarFolio(clave, reservado.folio, reservado.reserva) // el SII no vio este folio
} else {
  const sobre = prepararSobre({ documentos: [dte.valor], caratula: { fchResol: '2026-10-07', nroResol: 0 }, certificado, ahora })
  if (!sobre.ok) {
    await almacenCaf.liberarFolio(clave, reservado.folio, reservado.reserva)
  } else {
    // Desde aquí el folio queda consumido aunque el envío falle.
    const envio = await enviarBoletas(configToken, {
      sobre: sobre.valor.xml,
      rutEmisor: boleta.emisor.rut,
      rutEnvia: configToken.rutTitular,
    })
  }
}
```

`prepararSobre` elige `EnvioBOLETA` si el primer documento es boleta y `EnvioDTE` si no; mezclar familias o emisores es un error antes de firmar. Cuándo cerrar un grupo de boletas lo responde `siguienteAccionDeEnvio` de `@devlas-cl/protocolo` (por omisión espera 5 minutos desde la más antigua; la norma exige que cada boleta llegue al SII en máximo una hora).
