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
.pfxy 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ínea | Cálculo | Monto |
|---|---|---|
| Plan anual | 1 × 100000, menos 10 % (10000) | 90000 |
| Horas de soporte | 3 × 25000 | 75000 |
| Curso de capacitación (exento) | 2 × 45000 | 90000, exento |
| Neto | 90000 + 75000 | 165000 |
| IVA 19 % | 165000 × 19 / 100 | 31350 |
| Exento | 90000 | |
| Total | 165000 + 31350 + 90000 | 286350 |
Requisitos previos#
- Node 22.12 o superior, en un proyecto de módulos ES (
"type": "module"enpackage.json), condte-sdkinstalado (npm install dte-sdk, revisa Instalación). - Tu certificado digital como
firma.pfxy su contraseña en la variable de entornoPFX_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 enSII_RESOLUTION_NUMBER. En certificación el número es0. 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#
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#
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.
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.
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:
unitPricees neto, porque el ejemplo no fijapricesIncludeVat.discountPercent: 10es 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 conquantity × unitPrice.exempt: truemarca la línea como exenta (IndExe1). Su monto va aexemptAmount, fuera del neto y sin IVA.unittiene como máximo 4 caracteres.options.paymentTermses la forma de pago (PAYMENT_TERMS.cashes 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 (
issueDatela 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.
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.
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.
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).
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.
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 ejemplo0003-uploadEnvelope-200.json), y agrega una línea aindex.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.xmly el registro sin ellos como<trackId>-dte.json, bajo<dir>/<ambiente>/<aaaa-mm>/. Un envío rechazado se guarda comorejected-<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:
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 coincidenEl 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:
| Ruta | Qué es |
|---|---|
data/folios/folios.json | El registro: folio 1 usado, enviado, con su TrackID |
data/signed/33-1.xml | El EnvioDTE firmado, ISO-8859-1 |
data/uploads/certification/<aaaa-mm>/<trackId>-dte.xml y .json | El 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ónde | Código | Qué hacer |
|---|---|---|
| Certificado | CERT_PASSWORD_WRONG, CERT_INVALID, CERT_EXPIRED | Revisa la contraseña y el archivo. Un certificado vencido no se carga. |
| CAF | CAF_INVALID | El archivo no es un CAF que el SDK pueda leer (details.reason dice por qué). |
| Folio | FOLIO_OUT_OF_RANGE | El CAF no tiene más folios, o el folio está fuera de su rango. Obtén un CAF nuevo. |
| Emisión | CAF_EXPIRED | El CAF de una factura (y de los tipos 46, 56 y 61) vence seis meses después de su fecha de autorización. |
| Emisión | CAF_TYPE_MISMATCH, CAF_ISSUER_MISMATCH | El CAF es de otro tipo de documento o de otro RUT. |
| Emisión | MISSING_FIELD, INVALID_RUT, INVALID_DATE, INVALID_AMOUNT, FIELD_TOO_LONG | La entrada está mal. details.field dice qué campo. |
| Sobre | ENVELOPE_EMPTY, ENVELOPE_KIND_MISMATCH | Sin documentos, o un documento que no corresponde a este tipo de sobre. |
| Envío | SII_UPLOAD_REJECTED | El 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ío | SII_TOKEN_INVALID, SII_SEED_FAILED, SII_TOKEN_FAILED | Falló la autenticación con el certificado. |
| Envío | TRANSPORT_FAILED, TRANSPORT_TLS, REQUEST_TIMEOUT | La petición falló antes de que el SII respondiera. El SDK ya la reintentó; volver a intentar es seguro. |
| Envío | SII_HTTP_ERROR, SII_RESPONSE_INVALID | El 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. |
| Espera | WAIT_TIMEOUT | El SII sigue trabajando. details.trackId te permite consultar más tarde con queryUploadStatus. |
El estado del envío (DTEUpload, en details.status) significa:
| Estado | Significado |
|---|---|
| 0 | Recibido, TrackID asignado |
| 1 | El remitente no tiene permiso para enviar por esta empresa |
| 2 | El archivo es muy grande o muy chico |
| 3 | El archivo llegó cortado |
| 5 | No autenticado: token ausente o vencido |
| 6 | La empresa no está autorizada a enviar archivos |
| 7 | Esquema inválido, o el archivo ya fue enviado |
| 8 | Error en la firma |
| 9 | El sistema del SII está bloqueado: reintenta más tarde |
| 99 | El archivo ya fue enviado. El SDK devuelve el TrackID original con duplicate: true |
El estado del sobre que devuelve waitForUploadStatus (status.code):
| Código | Clase | Significado |
|---|---|---|
EPR | accepted | Envío procesado. Lee counts para los documentos que contiene. |
RPR | acceptedWithObjections | Aceptado con reparos. |
REC, SOK, FOK, PRD, CRT, DNK, PDR | pending | Todavía en validación. La espera sigue consultando. |
RPT, RSC | rejected | Rechazado por esquema. |
RFR | rejected | Rechazado por la firma. |
RCT | rejected | Rechazado 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, RLV | rejected | Rechazado. Lee status.raw y los archivos de data/capture. |
VOF | rejected | Error 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#
- Emitir una nota de crédito para anular o corregir esta factura.
- Obtener folios del SII para pedir CAF desde tu programa.
- Certificación para el proceso que autoriza a tu empresa, y Documentos, Timbre, Firma y Servicios web del SII para el detalle de cada paso.
- Errores para todos los códigos de
DteError.