Emitir una factura
Un flujo de servidor completo que emite una factura electrónica tipo 33 con una línea afecta y una exenta, la envía al SII y le hace seguimiento hasta que el SII la acepta.
La factura que vamos a emitir:
| Línea | Cálculo | Monto |
|---|---|---|
| Licencia anual | 1 × 120000 | 120000 neto |
| Curso de capacitación (exento) | 2 × 45000 | 90000 exento |
| IVA | 19% de 120000 | 22800 |
| Total | 120000 + 22800 + 90000 | 232800 |
Configuración#
El flujo lee sus secretos y su configuración desde variables de entorno:
| Variable | Para qué sirve |
|---|---|
DTE_CAF_33_PATH | Ruta al XML del CAF de facturas (tipo 33) |
DTE_CERT_PATH | Ruta al certificado digital .pfx |
DTE_CERT_PASSWORD | La contraseña del certificado |
DTE_SENDER_RUT | RUT de la persona dueña del certificado |
SII_ENVIRONMENT | certification o production |
SII_RESOLUTION_NUMBER | Número de la resolución del SII que te autoriza (0 en certificación) |
SII_RESOLUTION_DATE | Fecha de esa resolución, YYYY-MM-DD |
En los snippets, db representa tu propia capa de persistencia.
1. Carga el CAF y el certificado una sola vez#
Leer el CAF y abrir el certificado son las partes lentas. Hazlo una vez por proceso y comparte el resultado:
// lib/dte.ts
import { readFile } from 'node:fs/promises'
import { loadCertificate, parseCaf, type Caf, type Certificate } from 'dte-sdk'
export const ISSUER = {
rut: '76.123.456-0',
businessName: 'Arrow Software SpA',
activity: 'Desarrollo de software',
acteco: 620200,
address: 'Av. Providencia 1234',
commune: 'Providencia',
}
export const SII = {
environment: process.env.SII_ENVIRONMENT as 'certification' | 'production',
senderRut: process.env.DTE_SENDER_RUT!,
resolution: {
number: Number(process.env.SII_RESOLUTION_NUMBER),
date: process.env.SII_RESOLUTION_DATE!,
},
}
type Credentials = { caf: Caf; certificate: Certificate }
let credentials: Promise<Credentials> | undefined
async function load(): Promise<Credentials> {
const [cafXml, pfx] = await Promise.all([
readFile(process.env.DTE_CAF_33_PATH!, 'latin1'), // CAF files are ISO-8859-1
readFile(process.env.DTE_CERT_PATH!),
])
return {
caf: parseCaf(cafXml),
certificate: await loadCertificate(pfx, process.env.DTE_CERT_PASSWORD!),
}
}
export function getCredentials(): Promise<Credentials> {
credentials ??= load().catch((error) => {
credentials = undefined // try again on the next call
throw error
})
return credentials
}2. Construye, timbra y firma#
Una sola función convierte un pedido en una factura firmada. El job del paso 5 la reutiliza para reconstruir una factura que no se pudo subir, por eso acepta un issuedAt opcional:
// lib/invoice.ts
import { createDte, signDte, stampDte, type Dte, type LineItem } from 'dte-sdk'
import { ISSUER, getCredentials } from './dte'
export type InvoiceInput = {
folio: number
issuedAt?: string // defaults to today in America/Santiago
receiver: Dte['receiver']
items: LineItem[]
}
export async function buildInvoice({ folio, issuedAt, receiver, items }: InvoiceInput) {
const { caf, certificate } = await getCredentials()
const dte = createDte({
type: 33,
folio,
issuedAt,
issuer: ISSUER,
receiver,
items,
paymentMethod: 'credit',
})
return signDte(stampDte(dte, caf), certificate)
}3. Atiende la solicitud#
El handler asigna un folio, guarda la factura firmada y luego la envía. Hay dos detalles importantes:
- El folio se asigna dentro de una transacción junto con el insert. Si
createDte()ostampDte()lanzan un error, la transacción se revierte y el folio no se pierde. - El XML firmado se guarda antes de enviarlo. Una vez firmado, es el documento legal; si el SII está caído, queda como
signedy el job del paso 5 lo sube más tarde.
// app/invoices/route.ts
import { DteError, sendToSii } from 'dte-sdk'
import { SII, getCredentials } from '@/lib/dte'
import { buildInvoice, type InvoiceInput } from '@/lib/invoice'
import { toErrorResponse } from '@/lib/errors'
import { db } from '@/lib/db'
export async function POST(request: Request): Promise<Response> {
const { receiver, items } = (await request.json()) as Pick<InvoiceInput, 'receiver' | 'items'>
try {
const signed = await db.transaction(async (tx) => {
const folio = await tx.folios.next(33)
const signed = await buildInvoice({ folio, receiver, items })
await tx.invoices.insert({
folio,
issuedAt: signed.issuedAt,
receiver,
items,
totals: signed.totals,
xml: signed.xml,
state: 'signed',
})
return signed
})
try {
const { certificate } = await getCredentials()
const { trackId } = await sendToSii(signed, { certificate, ...SII })
// Persist the track id: it's the only way to ask for the result later
await db.invoices.update(signed.folio, { trackId, state: 'sent' })
return Response.json({ folio: signed.folio, trackId, totals: signed.totals }, { status: 201 })
} catch (error) {
if (error instanceof DteError && error.code === 'SII_UNAVAILABLE') {
// Stored as 'signed': the status job uploads it when the SII is back
return Response.json({ folio: signed.folio, state: 'pending_upload' }, { status: 202 })
}
throw error
}
} catch (error) {
return toErrorResponse(error)
}
}Llamándolo con nuestras dos líneas:
const response = await fetch('/invoices', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
receiver: {
rut: '77.654.321-7',
businessName: 'Comercial Los Andes Ltda.',
activity: 'Comercio al por menor',
address: 'Huérfanos 1055',
commune: 'Santiago',
},
items: [
{ name: 'Licencia anual', quantity: 1, unitPrice: 120000 },
{ name: 'Curso de capacitación', quantity: 2, unitPrice: 45000, exempt: true },
],
}),
})
await response.json()
// {
// folio: 1024,
// trackId: '0234567890',
// totals: { net: 120000, exempt: 90000, vatRate: 19, vat: 22800, total: 232800 }
// }4. Traduce los errores a respuestas#
Toda falla es un DteError con un code estable. Agrupa los códigos según quién puede resolver el problema:
// lib/errors.ts
import { DteError } from 'dte-sdk'
import { alertOps } from './ops' // your alerting
export function toErrorResponse(error: unknown): Response {
if (!(error instanceof DteError)) throw error
switch (error.code) {
// Bad input: the client can fix it
case 'MISSING_FIELD':
case 'INVALID_RUT':
case 'INVALID_AMOUNT':
return Response.json({ error: error.code }, { status: 422 })
// No usable CAF: someone has to load a new one
case 'FOLIO_OUT_OF_RANGE':
case 'CAF_EXPIRED':
case 'CAF_MISMATCH':
alertOps(error.code)
return Response.json({ error: 'FOLIOS_UNAVAILABLE' }, { status: 503 })
// Wrong certificate file or password
case 'CERTIFICATE_INVALID':
alertOps(error.code)
return Response.json({ error: 'CONFIGURATION' }, { status: 500 })
// The SII refused the upload: check sender RUT, resolution and certificate
case 'SII_REJECTED':
alertOps(error.code)
return Response.json({ error: error.code }, { status: 502 })
case 'SII_UNAVAILABLE':
return Response.json({ error: error.code }, { status: 503 })
}
}Los mensajes de DteError nunca incluyen RUTs, montos, llaves del CAF ni contraseñas, así que puedes registrarlos en logs tal cual.
5. Sigue el estado desde un job#
El SII valida los documentos de forma asíncrona. En vez de mantener abierta la solicitud, ejecuta cada pocos minutos un job que sube todo lo que siga en signed y consulta todo lo que esté en sent:
// jobs/sync-invoices.ts
import { DteError, getStatus, sendToSii } from 'dte-sdk'
import { ISSUER, SII, getCredentials } from '@/lib/dte'
import { buildInvoice } from '@/lib/invoice'
import { db } from '@/lib/db'
export async function syncInvoices() {
const { certificate } = await getCredentials()
try {
// Upload invoices that couldn't be sent: same folio, date, receiver and lines
for (const invoice of await db.invoices.findMany({ state: 'signed' })) {
const signed = await buildInvoice(invoice)
const { trackId } = await sendToSii(signed, { certificate, ...SII })
await db.invoices.update(invoice.folio, { trackId, xml: signed.xml, state: 'sent' })
}
// Ask the SII about invoices in flight
for (const invoice of await db.invoices.findMany({ state: 'sent' })) {
const status = await getStatus(invoice.trackId, {
certificate,
environment: SII.environment,
issuerRut: ISSUER.rut,
})
if (status.state === 'received') continue // still validating
const result = status.documents.find((doc) => doc.type === 33 && doc.folio === invoice.folio)
await db.invoices.update(invoice.folio, {
state: result?.state ?? status.state,
siiReason: result?.reason,
})
}
} catch (error) {
if (error instanceof DteError && error.code === 'SII_UNAVAILABLE') return // next run
throw error
}
}Una factura pasa por estos estados:
| Estado | Lo asigna | Siguiente paso |
|---|---|---|
signed | El handler de la solicitud | El job la sube |
sent | El handler o el job, con un trackId | El job consulta getStatus() |
accepted | El job | Entregar la factura al cliente |
accepted_with_objections | El job | Válida; revisa siiReason |
rejected | El job | Revisa siiReason, corrige la causa y vuelve a emitir |
Revisa Estados de aceptación para saber qué significa cada estado del SII.
Paso a producción#
Cuando el SII te autorice:
- Cambia
SII_ENVIRONMENTaproduction. - Define
SII_RESOLUTION_NUMBERySII_RESOLUTION_DATEcon tu resolución de autorización. - Apunta
DTE_CAF_33_PATHa un CAF emitido para producción.
El código no cambia.
Relacionado#
- Emitir una nota de crédito - Anula o corrige esta factura
createDte()- Todos los campos que acepta un documentosendToSii()- Opciones de envío, reintentos ygetStatus()- Seguridad - Cómo guardar el CAF y el certificado de forma segura