Saltar al contenido
dte-sdk

Emitir una boleta

Una boleta electrónica (tipo 39) es el documento de una venta al público. Se diferencia de una factura en cuatro cosas que este ejemplo muestra: los precios ya incluyen IVA, normalmente no hay receptor (el comprador es el consumidor final), el sobre es un EnvioBOLETA, y se envía por la API REST de boletas en producción y por DTEUpload en certificación. También arma el reporte de consumo de folios (RCOF) que pide la certificación de boletas, y termina con la boleta exenta (tipo 41).

Esta página corre sobre la 0.8.0 de dte-sdk desde npm (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; la API REST de boletas, los envíos y las consultas de estado todavía no se ejecutan en ninguno de los dos ambientes (issue 8). La certificación de boletas, que necesita una empresa inscrita en la aplicación de boletas, tampoco se ha ejecutado; el sobre de boletas y el reporte de consumo de folios sí validan contra los esquemas oficiales en los tests. Ejecútalo primero contra el ambiente de certificación.

Qué vas a construir#

Un programa, receipt.ts, que:

  • emite dos boletas tipo 39, una por venta, con precios que incluyen IVA y sin receptor,
  • las pone en un solo EnvioBOLETA, lo firma y guarda el XML firmado,
  • lo envía por el canal que corresponde al ambiente y espera el estado por ese mismo canal,
  • en certificación, arma, firma y envía el reporte de consumo de folios (RCOF) de esas boletas.

Las dos ventas y los totales que calcula el SDK. El precio unitario es lo que paga el cliente, así que el SDK deriva el neto desde el bruto (round(bruto / 1.19)) y el IVA es lo que sobra:

BoletaLíneasBrutoNetoIVA
Folio 12 × 2500 (café americano) + 3 × 1200 (medialuna)860072271373
Folio 21 × 2500 (café americano)25002101399

El total de una boleta es su monto bruto, y neto más IVA siempre suman ese monto.

Requisitos previos#

  • Todo lo de Emitir una factura: Node 22.12 o superior, firma.pfx con PFX_PASSWORD, y la resolución en SII_RESOLUTION_DATE y SII_RESOLUTION_NUMBER.
  • Un CAF del tipo 39 como caf-39.xml. Las boletas tienen su propio CAF, y el CAF del tipo 41 es otro.
  • SII_ENVIRONMENT en certification (el valor por defecto del programa) o production.
  • La empresa debe estar autorizada a emitir boletas en el SII. En certificación eso es la aplicación de boletas, que tiene su propio set de pruebas: ver Certificación de boletas.

El programa completo#

TypeScript
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import {
  createEnvelope,
  createFolioConsumption,
  createSiiClient,
  encodeLatin1,
  folioConsumptionEntryFromDocument,
  isDteError,
  issueDocument,
  loadCertificateFromPkcs12,
  parseCaf,
  signEnvelope,
  signFolioConsumption,
  uploadEnvelope,
  uploadFolioConsumption,
  waitForUploadStatus,
  type DocumentInput,
  type Environment,
  type SignedDocument,
} from 'dte-sdk'
import { createDirectoryCapture, createDirectoryFolioRegistry, createDirectoryUploadArchive } from 'dte-sdk/node'
 
const COMPANY_RUT = '76543210-3'
const ENVIRONMENT = (process.env.SII_ENVIRONMENT ?? 'certification') as Environment // o 'production'
const resolution = {
  date: process.env.SII_RESOLUTION_DATE!, // AAAA-MM-DD
  number: Number(process.env.SII_RESOLUTION_NUMBER ?? 0),
}
 
const issuer = {
  rut: COMPANY_RUT,
  businessName: 'Comercial Andina SpA',
  businessActivity: 'Cafetería',
  address: 'Av. Los Aromos 1540',
  commune: 'Santiago',
}
 
// Dos ventas. Los precios son lo que paga el cliente: ya incluyen IVA
const SALES: DocumentInput['items'][] = [
  [
    { name: 'Café americano', quantity: 2, unitPrice: 2500 },
    { name: 'Medialuna', quantity: 3, unitPrice: 1200 },
  ],
  [{ name: 'Café americano', quantity: 1, unitPrice: 2500 }],
]
 
const certificate = loadCertificateFromPkcs12(await readFile('firma.pfx'), process.env.PFX_PASSWORD!)
const caf = parseCaf(await readFile('caf-39.xml', 'latin1')) // el CAF del tipo 39
 
const folios = createDirectoryFolioRegistry({ dir: './data/folios' })
const scope = { companyRut: COMPANY_RUT, type: 39, range: caf.range, environment: ENVIRONMENT } as const
 
const sii = createSiiClient({
  environment: ENVIRONMENT,
  certificate,
  companyRut: COMPANY_RUT,
  capture: createDirectoryCapture({ dir: './data/capture' }),
  archive: createDirectoryUploadArchive({ dir: './data/uploads' }),
})
 
try {
  // 1. Una boleta por venta: sin receptor (consumidor final), precios con IVA
  const receipts: SignedDocument[] = []
  for (const items of SALES) {
    const folio = await folios.reserve(scope)
    const receipt = issueDocument({
      document: { type: 39, folio, issuer, items, pricesIncludeVat: true },
      caf,
      certificate,
    })
    receipts.push(receipt)
    console.log(`receipt 39 #${folio}`, JSON.stringify(receipt.header.totals))
  }
 
  // 2. Todas en un solo EnvioBOLETA
  const envelope = signEnvelope(
    createEnvelope({
      kind: 'receipt',
      documents: receipts,
      cover: {
        issuerRut: COMPANY_RUT,
        senderRut: certificate.rut!,
        resolutionDate: resolution.date,
        resolutionNumber: resolution.number,
      },
    }),
    certificate,
  )
  await mkdir('./data/signed', { recursive: true })
  await writeFile(`./data/signed/39-${receipts[0].header.folio}.xml`, encodeLatin1(envelope.xml))
 
  // 3. Envío: REST en producción, DTEUpload en certificación (sii.receiptsChannel dice cuál)
  const upload = await uploadEnvelope(sii, { envelope })
  for (const receipt of receipts) await folios.markSent(scope, receipt.header.folio, { trackId: upload.trackId })
  console.log(`uploaded through ${upload.channel}, track id ${upload.trackId}`)
 
  // 4. Esperar por el mismo canal por el que fue el archivo
  const status = await waitForUploadStatus(sii, { trackId: upload.trackId, channel: upload.channel })
  console.log(`envelope ${status.code} ${status.status}: ${status.message}`)
 
  // 5. Reporte de consumo de folios: la certificación de boletas lo pide
  if (ENVIRONMENT === 'certification' && status.status !== 'rejected') {
    const report = signFolioConsumption(
      createFolioConsumption({
        cover: {
          issuerRut: COMPANY_RUT,
          senderRut: certificate.rut!,
          resolutionDate: resolution.date,
          resolutionNumber: resolution.number,
        },
        entries: receipts.map(folioConsumptionEntryFromDocument),
      }),
      certificate,
    )
    console.log('consumption', JSON.stringify(report.summaries))
    const consumption = await uploadFolioConsumption(sii, { xml: report.xml })
    const consumptionStatus = await waitForUploadStatus(sii, { trackId: consumption.trackId })
    console.log(`consumption ${consumptionStatus.code} ${consumptionStatus.status}: ${consumptionStatus.message}`)
  }
} catch (error) {
  if (!isDteError(error)) throw error
  console.error(error.code, error.details)
  process.exitCode = 1
}

Paso a paso#

1

Emitir las boletas

El documento no tiene receiver. En una boleta el SDK completa el consumidor final: RUT 66666666-6, nombre Consumidor Final. Si el comprador pide identificarse, pasa un receiver; la dirección y la comuna no son obligatorias en una boleta. serviceIndicator (IndServicio) es 3 por defecto, ventas y servicios.

pricesIncludeVat: true es lo que hace que los precios sean brutos. Sin él, el SDK leería unitPrice como neto y sumaría el IVA encima, y al cliente se le cobraría más que el precio de la vitrina. Con él, cada monto de línea es bruto, el neto es round(bruto / 1.19) y el IVA es la diferencia. Una boleta nunca lleva TasaIVA y, a diferencia de una factura, su encabezado no tiene MntBruto.

El folio sale del registro, uno por boleta, del alcance del CAF del tipo 39. Un CAF del tipo 39 no vence: solo vence el de las facturas y el de los tipos 46, 56 y 61.

2

Ponerlas en un EnvioBOLETA

kind: 'receipt' produce un EnvioBOLETA. Su receptor es siempre el SII, y un documento que no sea boleta dentro de él, o una boleta en un EnvioDTE, lanza ENVELOPE_KIND_MISMATCH. Las boletas tipo 39 y 41 pueden viajar juntas. En el canal REST un sobre puede llevar como máximo 500 boletas: con más, uploadEnvelope lanza SII_UPLOAD_REJECTED con details.status igual a 'TOO_MANY' antes de enviar nada.

3

Enviar por el canal correcto

Lo decide el cliente: receiptsChannel es rest en producción y soap (el CGI DTEUpload) en certificación, y uploadEnvelope(sii, { envelope, channel }) fuerza uno. Un EnvioDTE siempre va por DTEUpload. El canal REST tiene su propia semilla y su propio token, que el cliente obtiene y renueva como los demás.

upload.channel dice cuál se usó, y waitForUploadStatus debe consultar por ese mismo canal: el programa pasa channel: upload.channel. El estado REST trae más que el SOAP: status.documents lista cada documento con su estado (DOK, RPR, RCH, RLV) y los errores encontrados en él, cada uno con su sección, línea, nivel, código y descripción. En el canal REST RLV es un éxito; en DTEUpload es un rechazo, y el SDK clasifica cada uno según corresponde.

El estado de una boleta, queryDocumentStatus, usa por defecto la API REST para los tipos 39 y 41, y esa API responde unos diez minutos después del envío.

4

El reporte de consumo de folios

El reporte de consumo de folios (RCOF, ConsumoFolios, hoy Resumen de Ventas Diarias) le informa al SII qué folios de boletas usaste en un día. Lo que debes saber de él: la certificación de boletas lo usa, y el SII probablemente ya no lo exige en producción. Por eso el programa lo envía solo en certificación. Si emites boletas en producción, consulta con el SII si debes enviarlo antes de hacerlo.

folioConsumptionEntryFromDocument deriva una línea por boleta (tipo, folio, fecha, neto, IVA, exento, total). createFolioConsumption calcula el resumen por tipo: montos, cantidades emitidas, anuladas y utilizadas, y los rangos de folios consecutivos (usedRanges). Sin from ni to en la carátula, el período es la fecha de emisión más temprana y la más tardía de las líneas. signFolioConsumption lo firma y uploadFolioConsumption lo envía por DTEUpload como ConsumoFolios.xml.

El reporte es un resumen diario: en una corrida real pasas todas las boletas del día, no solo las de un sobre. Los folios anulados en tu sistema antes de llegar al SII van en voidedFolios, como { type, folio }; un día sin ventas es un reporte sin líneas y con el from y el to de ese día, y sale como un resumen del tipo 39 en ceros. La carátula tiene una sequence, 1 por defecto: para corregir un reporte lo envías completo otra vez con un número mayor. El corredor de la certificación de boletas supone que el ambiente de certificación recibe un reporte por empresa por día.

Variantes#

Referencias de una boleta#

Una boleta tiene su propia forma de referencia. El esquema de boletas no le da fecha a Referencia y su código es texto libre, así que una boleta usa receiptReferences (code de hasta 18 caracteres, reason de hasta 90, y opcionalmente documentType y folio) y rechaza references con INVALID_OPTION. A la inversa, receiptReferences en una factura o una nota también se rechaza. En el set de pruebas de certificación cada boleta lleva la referencia SET / CASO-<n>:

TypeScript
const receipt = issueDocument({
  document: {
    type: 39,
    folio,
    issuer,
    items,
    pricesIncludeVat: true,
    // en el set de pruebas de certificación cada boleta apunta a su caso
    receiptReferences: [{ code: 'SET', reason: 'CASO-1' }],
  },
  caf,
  certificate,
})

Fuera de la certificación la referencia es opcional.

Boleta exenta (tipo 41)#

La boleta exenta es una boleta sin IVA: todas las líneas son exentas, no hay neto ni IVA, y pricesIncludeVat no hace nada. Tiene su propio CAF y sus propios folios, así que tiene su propio alcance en el registro. Viaja en el mismo EnvioBOLETA que las boletas tipo 39, y su línea de consumo de folios va en el mismo reporte: el resumen del tipo 41 tiene neto e IVA en cero y no tiene vatRate.

TypeScript
// una boleta exenta (tipo 41): su propio CAF, sin IVA y sin pricesIncludeVat
const caf41 = parseCaf(await readFile('caf-41.xml', 'latin1'))
const scope41 = { companyRut: COMPANY_RUT, type: 41, range: caf41.range, environment: ENVIRONMENT } as const
 
const exempt = issueDocument({
  document: {
    type: 41,
    folio: await folios.reserve(scope41),
    issuer,
    items: [{ name: 'Entrada al museo', quantity: 2, unitPrice: 3000 }],
  },
  caf: caf41,
  certificate,
})
exempt.header.totals // { exemptAmount: 6000, totalAmount: 6000 }

Para una boleta de 2 entradas a 3000, los totales son { exemptAmount: 6000, totalAmount: 6000 }. Para enviarla con las demás, agrega exempt a los documents de createEnvelope y a las líneas del reporte.

Resultado esperado#

En certificación, con un registro nuevo y un CAF del tipo 39 que parte en el folio 1:

Text
receipt 39 #1 {"netAmount":7227,"vatAmount":1373,"totalAmount":8600}
receipt 39 #2 {"netAmount":2101,"vatAmount":399,"totalAmount":2500}
uploaded through soap, track id 4503230
envelope EPR accepted: Envío procesado
consumption [{"type":39,"netAmount":9328,"vatAmount":1772,"vatRate":19,"exemptAmount":0,"totalAmount":11100,"issuedCount":2,"voidedCount":0,"usedCount":2,"usedRanges":[{"from":1,"to":2}],"voidedRanges":[]}]
consumption EPR accepted: Envío procesado

Los totales de las boletas y el resumen del reporte los calcula el SDK y siempre son estos. Los track id, los códigos y los textos del SII son ejemplos: los tuyos serán distintos. En producción la segunda línea dice uploaded through rest, no hay reporte de consumo, y los códigos de estado son los de la API REST.

Qué puede salir mal#

CódigoDóndeQué hacer
CAF_TYPE_MISMATCH, CAF_ISSUER_MISMATCH, FOLIO_OUT_OF_RANGEissueDocument, reserveEl CAF no es del tipo 39 ni de este RUT, o no tiene más folios.
INVALID_OPTIONcreateDocumentPasaste references en una boleta, o receiptReferences en otro tipo.
FIELD_TOO_LONG, MISSING_FIELD, INVALID_AMOUNTcreateDocumentLa entrada está mal. details.field dice qué campo.
ENVELOPE_KIND_MISMATCHcreateEnvelopeUn documento tipo 33 o similar en un EnvioBOLETA. Las boletas y los demás documentos van en sobres distintos.
INVALID_DOCUMENT_TYPEfolioConsumptionEntryFromDocument, createFolioConsumptionSolo los tipos 39 y 41 van en el reporte.
SII_UPLOAD_REJECTEDuploadEnvelope, uploadFolioConsumptionEl SII rechazó el archivo (details.status en DTEUpload), o enviaste más de 500 boletas por el canal REST ('TOO_MANY').
SII_TOKEN_INVALIDCualquier llamada al SIISe rechazó el token (un HTTP 401 en REST, estado 5 en DTEUpload) incluso tras una renovación.
WAIT_TIMEOUTwaitForUploadStatusEl SII sigue trabajando. Consulta más tarde con el track id.

Los estados, los estados de envío de DTEUpload y los códigos de transporte son los mismos que en la factura: ver Qué puede salir mal. Un estado rejected del sobre hace que el programa omita el reporte de consumo de folios.

Siguientes pasos#