Saltar al contenido
dte-sdk

Emitir una factura

Este ejemplo emite una factura electrónica tipo 33 de principio a fin y la sigue en el SII: carga el certificado digital y el CAF, reserva un folio, emite un documento con tres líneas, lo envuelve en un sobre EnvioDTE firmado, lo envía al ambiente de certificación y espera hasta que el SII lo procesa. Es un solo programa que puedes copiar, adaptar y ejecutar.

Esta página corre sobre la 0.8.0 de dte-sdk desde npm, la versión más reciente (ver Instalación). Los documentos y los sobres están probados contra respuestas sintéticas del SII y todavía no se han enviado al SII real. De los servicios web del SII, solo la semilla y el token por SOAP y el registro de aceptación o reclamo se han enfrentado a él, y solo en producción; los envíos y las consultas de estado todavía no se ejecutan en ninguno de los dos ambientes (issue 8). Ejecútalo primero contra el ambiente de certificación y lee Estado.

Qué vas a construir#

Un programa, invoice.ts, que:

  • carga tu certificado .pfx y el CAF del tipo 33,
  • reserva el siguiente folio en un registro local, para que dos corridas nunca usen el mismo,
  • emite una factura con tres líneas: una con 10 % de descuento y una exenta de IVA,
  • la envuelve en un EnvioDTE, la firma y guarda el XML firmado en disco,
  • la envía al SII (certificación), espera el resultado y consulta el estado del documento,
  • registra en disco cada intercambio HTTP y cada envío, para tener algo que adjuntar a un ticket de soporte del SII.

La factura y los totales que calcula el SDK:

LíneaCálculoMonto
Plan anual1 × 100000, menos 10 % (10000)90000
Horas de soporte3 × 2500075000
Curso de capacitación (exento)2 × 4500090000, exento
Neto90000 + 75000165000
IVA 19 %165000 × 19 / 10031350
Exento90000
Total165000 + 31350 + 90000286350

Requisitos previos#

  • Node 22.12 o superior, en un proyecto de módulos ES ("type": "module" en package.json), con dte-sdk instalado (npm install dte-sdk, revisa Instalación).
  • Tu certificado digital como firma.pfx y su contraseña en la variable de entorno PFX_PASSWORD. El titular del certificado es quien firma y envía.
  • Un CAF del tipo de documento 33, emitido para el mismo RUT que el emisor, como caf-33.xml. Si no tienes uno, Obtener folios del SII muestra cómo pedirlo. El CAF contiene una llave privada: trata el archivo como un secreto.
  • La resolución que autoriza a tu empresa en el SII, del ambiente que uses: su fecha en SII_RESOLUTION_DATE (AAAA-MM-DD) y su número en SII_RESOLUTION_NUMBER. En certificación el número es 0. La fecha y el número son distintos en certificación y en producción.
  • Los RUT, nombres y direcciones de abajo son ficticios. Reemplaza el emisor por tu empresa: el RUT del CAF, el del emisor y el del sobre deben ser el mismo.

El programa completo#

TypeScript
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import {
  PAYMENT_TERMS,
  createEnvelope,
  createSiiClient,
  encodeLatin1,
  isDteError,
  issueDocument,
  loadCertificateFromPkcs12,
  parseCaf,
  queryDocumentStatus,
  signEnvelope,
  uploadEnvelope,
  waitForUploadStatus,
  type DteError,
} from 'dte-sdk'
import { createDirectoryCapture, createDirectoryFolioRegistry, createDirectoryUploadArchive } from 'dte-sdk/node'
 
const COMPANY_RUT = '76543210-3'
const ENVIRONMENT = 'certification'
 
// Lo que el SII le dio a esta empresa para este ambiente (en certificación el número es 0)
const resolution = {
  date: process.env.SII_RESOLUTION_DATE!, // AAAA-MM-DD
  number: Number(process.env.SII_RESOLUTION_NUMBER ?? 0),
}
 
const certificate = loadCertificateFromPkcs12(await readFile('firma.pfx'), process.env.PFX_PASSWORD!)
const caf = parseCaf(await readFile('caf-33.xml', 'latin1')) // Los archivos CAF son ISO-8859-1
 
// Control local de los folios de este CAF: un archivo JSON, un proceso
const folios = createDirectoryFolioRegistry({ dir: './data/folios' })
const scope = { companyRut: COMPANY_RUT, type: 33, range: caf.range, environment: ENVIRONMENT } as const
 
const sii = createSiiClient({
  environment: ENVIRONMENT,
  certificate,
  companyRut: COMPANY_RUT,
  capture: createDirectoryCapture({ dir: './data/capture' }), // cada intercambio HTTP, con los secretos tachados
  archive: createDirectoryUploadArchive({ dir: './data/uploads' }), // los bytes exactos de cada envío
})
 
try {
  // 1. Reservar el siguiente folio libre del CAF
  const folio = await folios.reserve(scope)
  console.log(`folio ${folio} reserved in the range ${caf.range.from}-${caf.range.to}`)
 
  // 2. Crear, timbrar (TED) y firmar la factura
  const invoice = issueDocument({
    document: {
      type: 33,
      folio,
      issuer: {
        rut: COMPANY_RUT,
        businessName: 'Comercial Andina SpA',
        businessActivity: 'Software',
        activityCode: 620200,
        address: 'Av. Los Aromos 1540',
        commune: 'Santiago',
      },
      receiver: {
        rut: '12345678-5',
        businessName: 'Comercializadora Rapel Ltda',
        businessActivity: 'Comercio',
        address: 'Pasaje Los Olmos 88',
        commune: 'Providencia',
      },
      items: [
        { name: 'Plan anual', quantity: 1, unitPrice: 100000, discountPercent: 10 },
        { name: 'Horas de soporte', quantity: 3, unit: 'HRS', unitPrice: 25000 },
        { name: 'Curso de capacitación', quantity: 2, unitPrice: 45000, exempt: true },
      ],
      options: { paymentTerms: PAYMENT_TERMS.cash },
    },
    caf,
    certificate,
  })
  const { totals } = invoice.header
  console.log('totals', JSON.stringify(totals))
 
  // 3. Envolverla en un EnvioDTE y firmar el SetDTE
  const envelope = signEnvelope(
    createEnvelope({
      kind: 'dte',
      documents: [invoice],
      cover: {
        issuerRut: COMPANY_RUT,
        senderRut: certificate.rut!, // el titular del certificado firma y envía
        resolutionDate: resolution.date,
        resolutionNumber: resolution.number,
      },
    }),
    certificate,
  )
 
  // 4. Guardar el XML firmado antes de enviarlo: es el documento legal
  await mkdir('./data/signed', { recursive: true })
  const file = `./data/signed/33-${folio}.xml`
  await writeFile(file, encodeLatin1(envelope.xml))
  console.log(`signed envelope saved to ${file}`)
 
  // 5. Enviarla y registrar el folio como enviado
  const upload = await uploadEnvelope(sii, { envelope })
  await folios.markSent(scope, folio, { trackId: upload.trackId })
  console.log(`uploaded: track id ${upload.trackId}${upload.duplicate ? ' (the SII already had it)' : ''}`)
 
  // 6. Esperar a que el SII termine con el sobre (consulta cada 5 s, se rinde a los 10 min)
  const status = await waitForUploadStatus(sii, { trackId: upload.trackId })
  const counts = status.counts ?? { reported: 0, accepted: 0, rejected: 0, objections: 0 }
  console.log(`envelope ${status.code} ${status.status}: ${status.message}`, JSON.stringify(counts))
 
  if (status.status === 'rejected' || counts.rejected > 0) {
    console.error('The SII rejected the envelope or a document in it. Check data/capture and data/uploads.')
    process.exitCode = 1
  } else {
    if (status.status === 'acceptedWithObjections' || counts.objections > 0)
      console.warn('Accepted with objections. The answer of the SII is in status.raw.')
 
    // 7. Preguntar al SII por el documento mismo
    const document = await queryDocumentStatus(sii, {
      receiverRut: invoice.header.receiver.rut,
      type: 33,
      folio,
      issueDate: invoice.header.issueDate,
      totalAmount: totals.totalAmount,
    })
    console.log(`document ${document.code} ${document.status}: ${document.message}`)
  }
} catch (error) {
  if (!isDteError(error)) throw error
  explain(error)
  process.exitCode = 1
}
 
function explain(error: DteError): void {
  switch (error.code) {
    case 'CAF_EXPIRED':
      console.error('The CAF is more than six months old: ask the SII for a new one.')
      break
    case 'FOLIO_OUT_OF_RANGE':
      console.error('No folios left in this CAF: ask the SII for a new one.')
      break
    case 'SII_UPLOAD_REJECTED':
      console.error('The SII refused the file.', error.details.status, error.details.errors)
      break
    case 'WAIT_TIMEOUT':
      console.error('The SII is still working on it. Ask again later with the track id:', error.details.trackId)
      break
    default:
      console.error(error.code, error.details)
  }
}

Paso a paso#

1

Cargar el certificado y el CAF

loadCertificateFromPkcs12(bytes, password) es síncrona. Lee archivos .pfx antiguos y modernos, encuentra el certificado hoja y toma de él el RUT del titular: es certificate.rut, que pasa a ser quien envía el sobre. Lanza CERT_PASSWORD_WRONG, CERT_INVALID o CERT_EXPIRED.

parseCaf(xml) recibe el CAF como texto. El SII entrega los archivos CAF en ISO-8859-1, así que el archivo se lee como latin1: el bloque <CAF> se copia byte por byte en el timbre de cada documento, y una decodificación equivocada rompe la firma que revisa el SII. caf.range es el rango inclusivo de folios que autoriza.

2

Reservar un folio

Un folio se puede usar una sola vez. createDirectoryFolioRegistry (de dte-sdk/node) guarda el libro en folios.json dentro del directorio que le des, escrito de forma atómica. reserve(scope) entrega el siguiente folio libre del CAF: después del último reservado, o el menor reservado y aún no enviado, y nunca uno usado o anulado. Cuando el rango se agota lanza FOLIO_OUT_OF_RANGE.

El registro es para un solo proceso y su directorio debe sobrevivir a los reinicios: si lo pierdes puedes repetir folios ante el SII. Un servicio con varias réplicas o con su propia base implementa el tipo FolioRegistry con sus propias transacciones. En Obtener folios del SII está el resto del registro.

3

Emitir la factura

issueDocument ejecuta todo el flujo: createDocument (validar, normalizar, calcular totales), stampDocument (construir el timbre electrónico, el TED, y firmarlo con la llave del CAF) y signDocument (firmar el documento con tu certificado). El resultado trae el XML firmado en invoice.xml.

Las líneas muestran las opciones que más usarás:

  • unitPrice es neto, porque el ejemplo no fija pricesIncludeVat.
  • discountPercent: 10 es un descuento sobre esa línea: base 100000, descuento 10000, monto de la línea 90000. El redondeo es línea por línea, porque el SII compara cada monto de línea con quantity × unitPrice.
  • exempt: true marca la línea como exenta (IndExe 1). Su monto va a exemptAmount, fuera del neto y sin IVA.
  • unit tiene como máximo 4 caracteres. options.paymentTerms es la forma de pago (PAYMENT_TERMS.cash es el código 1).
  • Una factura necesita la dirección y la comuna del receptor. La fecha de emisión es hoy en hora de Chile por defecto (issueDate la reemplaza).

Los textos se sanitizan y se revisan contra los largos del esquema del SII al crear el documento: el SII rechaza el sobre completo si un campo es muy largo, así que el SDK lanza FIELD_TOO_LONG antes de enviar nada. Para descontar todo el documento en vez de una línea, usa globalAdjustments; ver Totales.

4

Envolverla en un sobre y firmarlo

createEnvelope agrupa documentos firmados bajo una carátula (Caratula) y los cuenta por tipo. kind: 'dte' produce un EnvioDTE; las boletas usan 'receipt'. El receptor del sobre es el SII por defecto. senderRut es el RUT del titular del certificado, que debe estar autorizado para enviar por la empresa. signEnvelope firma el SetDTE, y envelope.xml es un XML que declara ISO-8859-1. Más en Sobres.

5

Guardar el XML firmado antes de enviarlo

El XML firmado es el documento legal. Guárdalo antes del envío, para que una caída o una falla de red nunca lo pierdan. El SDK envía el XML como bytes ISO-8859-1, por eso el archivo se escribe con encodeLatin1.

6

Crear el cliente, enviar y registrar el folio

createSiiClient devuelve datos planos: cada operación es una función que lo recibe primero. Obtiene la semilla y el token, y los renueva cuando hace falta. El transporte por defecto es fetch, con la verificación TLS activada.

uploadEnvelope envía el archivo por DTEUpload y devuelve el TrackID que asigna el SII. Un envío se reintenta solo mientras el SII no haya respondido: una vez que el SII respondió, diga lo que diga, el archivo no se vuelve a enviar, salvo una vez con un token nuevo cuando el SII rechazó el token. Si el SII ya tenía el archivo, devuelve el TrackID original con duplicate: true.

Justo después del envío, markSent registra el folio como enviado con su TrackID. Si el programa muere antes de esa línea, el folio queda reservado y sin enviar, y reserve lo vuelve a entregar en la corrida siguiente. Antes de reutilizar un folio de un sobre que el SII pudo haber procesado (tras un WAIT_TIMEOUT, por ejemplo), pregúntale al SII con folioReceived(sii, { type: 33, folio }): responde true, false o null (indeterminado).

7

Esperar al SII

waitForUploadStatus consulta QueryEstUp cada 5 segundos (intervalMs) hasta que el estado deja de ser pending, y se rinde a los 10 minutos (timeoutMs) con WAIT_TIMEOUT. Un servicio de consulta inestable no termina la espera: los errores de consulta y las fallas de transporte cuentan como pendiente. El resultado trae status.status, la clase del código, y status.counts con la cantidad de documentos informados, aceptados, rechazados y aceptados con reparos.

El programa lee ambos. EPR significa que el SII procesó el sobre; un documento dentro de él puede haber sido rechazado igual, y counts.rejected lo dice. acceptedWithObjections (RPR) significa que el documento es válido pero el SII encontró algo que reparar, y la respuesta del SII está en status.raw.

8

Consultar el estado del documento

queryDocumentStatus consulta QueryEstDte por un documento. Necesita lo que el SII usa para encontrarlo: el receptor, el tipo, el folio, la fecha de emisión y el total. El programa los toma del documento emitido, así coinciden con lo que se firmó. DOK significa que el SII tiene el documento y los datos coinciden.

Para tickets de soporte: captura y archivo#

El cliente del programa tiene dos puertos que escriben en disco, ambos de dte-sdk/node:

  • capture: createDirectoryCapture({ dir }) escribe un archivo por intento HTTP, <seq>-<operación>-<estado>.json (por ejemplo 0003-uploadEnvelope-200.json), y agrega una línea a index.jsonl. Los tokens, semillas, cookies y llaves privadas se tachan antes de escribir nada.
  • archive: createDirectoryUploadArchive({ dir }) escribe, por cada envío que el SII respondió, los bytes exactos enviados como <trackId>-dte.xml y el registro sin ellos como <trackId>-dte.json, bajo <dir>/<ambiente>/<aaaa-mm>/. Un envío rechazado se guarda como rejected-<aaaammddHHMMSS>-dte.

Ninguno lanza errores: una falla en un puerto se registra y la operación sigue. Cuando abras un ticket con el SII, estos archivos son lo que te piden. Léelos antes de compartirlos: contienen los datos de tu documento.

Resultado esperado#

En una corrida exitosa, con un registro nuevo y un CAF para los folios 1 al 100:

Text
folio 1 reserved in the range 1-100
totals {"netAmount":165000,"exemptAmount":90000,"vatRate":19,"vatAmount":31350,"totalAmount":286350}
signed envelope saved to ./data/signed/33-1.xml
uploaded: track id 4503218
envelope EPR accepted: Envío procesado {"reported":1,"accepted":1,"rejected":0,"objections":0}
document DOK received: Documento recibido por el SII, datos coinciden

El folio y el rango salen de tu CAF. Los totales los calcula el SDK y siempre son estos. El TrackID, el código y el texto de las dos últimas líneas vienen del SII: los tuyos serán distintos.

La corrida deja estos archivos:

RutaQué es
data/folios/folios.jsonEl registro: folio 1 usado, enviado, con su TrackID
data/signed/33-1.xmlEl EnvioDTE firmado, ISO-8859-1
data/uploads/certification/<aaaa-mm>/<trackId>-dte.xml y .jsonEl archivo del envío
data/capture/Un archivo JSON por intercambio HTTP, y index.jsonl

Qué puede salir mal#

Toda falla es un DteError con un code estable. Discrimina por el código, nunca por el mensaje. Los mensajes son genéricos y no llevan RUT ni montos; el valor que falló está en error.details.

DóndeCódigoQué hacer
CertificadoCERT_PASSWORD_WRONG, CERT_INVALID, CERT_EXPIREDRevisa la contraseña y el archivo. Un certificado vencido no se carga.
CAFCAF_INVALIDEl archivo no es un CAF que el SDK pueda leer (details.reason dice por qué).
FolioFOLIO_OUT_OF_RANGEEl CAF no tiene más folios, o el folio está fuera de su rango. Obtén un CAF nuevo.
EmisiónCAF_EXPIREDEl CAF de una factura (y de los tipos 46, 56 y 61) vence seis meses después de su fecha de autorización.
EmisiónCAF_TYPE_MISMATCH, CAF_ISSUER_MISMATCHEl CAF es de otro tipo de documento o de otro RUT.
EmisiónMISSING_FIELD, INVALID_RUT, INVALID_DATE, INVALID_AMOUNT, FIELD_TOO_LONGLa entrada está mal. details.field dice qué campo.
SobreENVELOPE_EMPTY, ENVELOPE_KIND_MISMATCHSin documentos, o un documento que no corresponde a este tipo de sobre.
EnvíoSII_UPLOAD_REJECTEDEl SII rechazó el archivo. details.status es el estado de DTEUpload como número y details.errors las líneas que mandó el SII.
EnvíoSII_TOKEN_INVALID, SII_SEED_FAILED, SII_TOKEN_FAILEDFalló la autenticación con el certificado.
EnvíoTRANSPORT_FAILED, TRANSPORT_TLS, REQUEST_TIMEOUTLa petición falló antes de que el SII respondiera. El SDK ya la reintentó; volver a intentar es seguro.
EnvíoSII_HTTP_ERROR, SII_RESPONSE_INVALIDEl SII respondió, pero con un error o con algo que el SDK no puede leer. El SDK no vuelve a enviar el archivo, porque el SII puede tenerlo ya: verifica con folioReceived o queryDocumentStatus antes de hacerlo.
EsperaWAIT_TIMEOUTEl SII sigue trabajando. details.trackId te permite consultar más tarde con queryUploadStatus.

El estado del envío (DTEUpload, en details.status) significa:

EstadoSignificado
0Recibido, TrackID asignado
1El remitente no tiene permiso para enviar por esta empresa
2El archivo es muy grande o muy chico
3El archivo llegó cortado
5No autenticado: token ausente o vencido
6La empresa no está autorizada a enviar archivos
7Esquema inválido, o el archivo ya fue enviado
8Error en la firma
9El sistema del SII está bloqueado: reintenta más tarde
99El archivo ya fue enviado. El SDK devuelve el TrackID original con duplicate: true

El estado del sobre que devuelve waitForUploadStatus (status.code):

CódigoClaseSignificado
EPRacceptedEnvío procesado. Lee counts para los documentos que contiene.
RPRacceptedWithObjectionsAceptado con reparos.
REC, SOK, FOK, PRD, CRT, DNK, PDRpendingTodavía en validación. La espera sigue consultando.
RPT, RSCrejectedRechazado por esquema.
RFRrejectedRechazado por la firma.
RCTrejectedRechazado por la carátula. Revisa la fecha y el número de resolución de este ambiente y el RUT de quien envía.
RCH, RCO, RLVrejectedRechazado. Lee status.raw y los archivos de data/capture.
VOFrejectedError interno del SII. Reintenta más tarde.

Y el estado del documento (queryDocumentStatus): DOK el SII lo tiene y los datos coinciden, DNK lo tiene pero los datos no coinciden con lo que preguntaste, FAU no lo ha recibido, FNA y EMP no autorizado, FAN folio anulado, y ANC, MMC o TMC cuando una nota de crédito anula el documento, corrige sus montos o corrige su texto (AND, MMD y TMD para las notas de débito).

Siguientes pasos#