Saltar al contenido
dte-sdk

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íneaCálculoMonto
Licencia anual1 × 120000120000 neto
Curso de capacitación (exento)2 × 4500090000 exento
IVA19% de 12000022800
Total120000 + 22800 + 90000232800

Configuración#

El flujo lee sus secretos y su configuración desde variables de entorno:

VariablePara qué sirve
DTE_CAF_33_PATHRuta al XML del CAF de facturas (tipo 33)
DTE_CERT_PATHRuta al certificado digital .pfx
DTE_CERT_PASSWORDLa contraseña del certificado
DTE_SENDER_RUTRUT de la persona dueña del certificado
SII_ENVIRONMENTcertification o production
SII_RESOLUTION_NUMBERNúmero de la resolución del SII que te autoriza (0 en certificación)
SII_RESOLUTION_DATEFecha 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:

TypeScript
// 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:

TypeScript
// 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() o stampDte() 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 signed y el job del paso 5 lo sube más tarde.
TypeScript
// 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:

TypeScript
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:

TypeScript
// 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:

TypeScript
// 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:

EstadoLo asignaSiguiente paso
signedEl handler de la solicitudEl job la sube
sentEl handler o el job, con un trackIdEl job consulta getStatus()
acceptedEl jobEntregar la factura al cliente
accepted_with_objectionsEl jobVálida; revisa siiReason
rejectedEl jobRevisa 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:

  1. Cambia SII_ENVIRONMENT a production.
  2. Define SII_RESOLUTION_NUMBER y SII_RESOLUTION_DATE con tu resolución de autorización.
  3. Apunta DTE_CAF_33_PATH a un CAF emitido para producción.

El código no cambia.

Relacionado#