@egestia/sdk
v1.2.0
Published
Cliente oficial de la API pública de Egestia: emitir boletas, facturas, boletas de honorarios y facturas de compra electrónicas y consultar su estado.
Maintainers
Readme
@egestia/sdk
Cliente de la API pública de Egestia. Le mandas el JSON de una venta y te devuelve el documento tributario: boleta o factura, con su folio y su estado frente al SII. Y si a quien le pagas es un prestador y no un cliente, le mandas el bruto y te devuelve la boleta de honorarios con la retención que aplicó el SII y el líquido a transferir —o la factura de compra del DTE 46, si el prestador está en otro país—.
El CAF lo maneja Egestia, con los folios de ese cliente. Tu sistema no necesita saber nada del SII: manda la venta y recibe el resultado.
npm install @egestia/sdkRequiere Node 18 o superior (usa fetch nativo). No trae dependencias.
¿Cómo funciona por dentro? En
docs/está la mecánica: arquitectura, reintentos y folios, estados, problemas, la API por debajo y recetas.
Empezar
La API key se genera en Egestia → Integraciones y empieza con egst_.
import { Egestia } from '@egestia/sdk';
const egestia = new Egestia({
apiKey: process.env.EGESTIA_API_KEY!,
appName: 'mi-web/1.0', // aparece en el log de Egestia
});Emitir una venta
const doc = await egestia.documentos.emitir({
tipo: 'boleta', // boleta | boleta_exenta | factura | factura_exenta
referencia: pedido.id, // ← IMPORTANTE, ver más abajo
origen: 'mi-web',
cliente: {
name: 'Juan Pérez Soto',
rut: '12.345.678-5', // sin RUT se emite a consumidor final
email: '[email protected]',
address: 'Los Aromos 442', // la FACTURA imprime dirección, ciudad y giro
city: 'Santiago',
giro: 'Particular',
},
items: [
{ sku: 'PLAN-BASICO', name: 'Plan básico mensual', unitPrice: 19900, quantity: 1, isService: true },
{ sku: 'DOM-CL', name: 'Dominio .cl', unitPrice: 9500, quantity: 2 },
],
pago: { method: 'webpay', amount: 46886 },
});
console.log(doc.folio, doc.status, doc.trackId);Los precios van netos: Egestia calcula el IVA.
Con pago el documento nace pagado y se manda al SII. Sin pago, o con
emitir: false, queda en borrador para que alguien lo revise en Egestia.
El cliente y los productos se crean solos
- Cliente: se busca por RUT; si no hay RUT, por correo. Si no existe se crea con todo lo que mandes. Si ya existe se reutiliza y se le completan los campos que le falten, sin pisar lo que el ERP ya tenía.
- Productos: se buscan por
sku. La primera venta de un SKU crea el producto en el catálogo; todas las siguientes usan ese mismo. Sinskula línea entra como texto suelto y después no se puede saber cuánto se vendió de qué.
referencia: lo que evita emitir dos veces
Es el identificador de la venta en tu sistema. Mándalo siempre.
Con referencia, repetir la llamada devuelve el documento que ya existe
(repetido: true) en vez de emitir otro. Sin ella, un reintento —una cola que
reenvía, un timeout, un usuario que hace doble clic— emite un segundo DTE y
quema otro folio del CAF por una venta que ya estaba facturada.
Por eso el SDK sólo reintenta automáticamente cuando hay referencia.
Si dudas de si una venta alcanzó a facturarse:
const existente = await egestia.documentos.buscarPorReferencia(pedido.id, { origen: 'mi-web' });
if (!existente) await egestia.documentos.emitir({ /* ... */ });Reintentos: nunca se quema un folio de más
Un reintento usa el mismo folio. Siempre. El SDK se encarga, y no necesita que le pases nada para lograrlo.
Cada envío abre una interacción —un identificador que pone el SDK, no tú—. Si mandas la misma venta otra vez, aunque sea con los datos corregidos, llega la misma interacción y Egestia corrige el documento que ya existe, conservando su folio:
// Primer envío: el precio va mal
await egestia.documentos.emitir({ referencia: 'WEB-40001', items: [{ sku: 'X', unitPrice: 0 }], ... });
// → documento 8112790f, folio 5001, rechazado
// Se corrige y se reenvía. Nada especial que hacer.
await egestia.documentos.emitir({ referencia: 'WEB-40001', items: [{ sku: 'X', unitPrice: 120000 }], ... });
// → MISMO documento 8112790f, MISMO folio 5001, con el dato corregido
// reintento: true · attempts: 2Puedes mirar el registro cuando quieras:
const i = await egestia.interacciones.ver('mi-web:WEB-40001');
// { id, intentos: 2, documentId, folio: '5001', estado, ultimoProblema }Por defecto el registro vive en memoria. Si tus reintentos ocurren en otro proceso —una cola, un servidor que se reinicia— enchufa algo que persista:
new Egestia({
apiKey,
almacen: {
async leer(clave) { return JSON.parse(await redis.get(clave) ?? 'null'); },
async guardar(clave, inter) { await redis.set(clave, JSON.stringify(inter)); },
},
});Estados: qué se puede corregir y qué no
| Estado | Qué significa | ¿Se puede corregir? |
|---|---|---|
| draft | Creado, sin ir al SII | Sí, reenviando |
| rejected | El SII lo rechazó | Sí, reenviando — conserva el folio |
| sent_to_sii | Enviado, esperando respuesta | No: espera el veredicto |
| accepted | El SII lo aceptó | No. Hay que anular y emitir uno nuevo |
| reparo | Aceptado con reparos | No: se corrige con nota de crédito o débito |
Un DTE aceptado existe en el mundo y en el libro de ventas: no hay corrección posible. El SDK lo detecta solo —compara el contenido del envío con el anterior— y te frena antes de que creas que tu corrección se aplicó:
tipo ........ ya_aceptado
mensaje ..... El SII ya aceptó este documento: no admite correcciones.
qué hacer ... Anúlalo con una nota de crédito y emite uno nuevo:
documentos.anularYReemitir(id, ventaCorregida)Ojo con la diferencia: un reenvío idéntico contra un documento aceptado —la cola que reintenta— no es un error, devuelve el documento tal cual. Sólo se frena cuando los datos cambiaron, porque ahí sí hay una corrección que no se puede aplicar.
const { anulado, emitido } = await egestia.documentos.anularYReemitir(doc.id, ventaCorregida);Validación con el SII, automática
En la app de Egestia consultar el estado es un botón que alguien aprieta. Por
SDK no hay nadie: emitir() hace el ciclo completo —emitir, firmar, enviar al
SII y preguntar hasta tener respuesta— y te devuelve el documento ya
resuelto.
const doc = await egestia.documentos.emitir(venta);
console.log(doc.status); // accepted | rejected | sent_to_sii (si el SII se demoró)Si emites desde una cola y prefieres no esperar:
await egestia.documentos.emitir({ ...venta, esperarSii: false });
// y más tarde:
const doc = await egestia.documentos.sincronizar(id);Quedarse sin folios
Es el problema que detiene la facturación entera, y hasta ahora sólo se notaba cuando una venta fallaba. Puedes adelantarte:
const { tipos } = await egestia.folios();
// [{ dteCode: 39, disponibles: 120, desde: 8001, hasta: 8120, cafs: 1 }, ...]
for (const t of tipos) {
if (t.disponibles < 50) avisar(`Quedan ${t.disponibles} folios del DTE ${t.dteCode}`);
}Y si igual te agarra sin folios, el problema lo dice con todas sus letras:
tipo ........ sin_folios
mensaje ..... No hay folios disponibles para este tipo de documento
qué hacer ... Hay que pedir un CAF nuevo al SII y cargarlo en Egestia.
La venta queda registrada en borrador y se emite sola al reintentar.
documento ... 4f94eb3e… ← la venta NO se perdióEso último es lo importante: sin folios, la venta no se pierde. El documento queda creado en borrador y el siguiente reenvío lo emite, con el primer folio del CAF nuevo.
Consultar
const doc = await egestia.documentos.obtener(id);
// → { folio, status, total, trackId, items, contact, ... }
const xml = await egestia.documentos.xml(id); // DTE firmado, para archivarlostatus puede ser: draft (aún no va al SII), sent_to_sii (enviado, sin
respuesta), accepted, rejected, reparo.
El SII no responde al instante. Si necesitas esperar el veredicto:
const final = await egestia.documentos.emitirYEsperar(venta, { intentos: 10, esperaMs: 3000 });Anular
En Chile un DTE emitido no se borra: se anula emitiendo una nota de crédito que lo referencia. El SDK lo hace por ti, copiando las líneas y el cliente del original.
const nc = await egestia.documentos.anular(doc.id, { motivo: 'Compra devuelta' });
console.log(nc.folio, nc.anulaId);Es idempotente: si el documento ya estaba anulado, devuelve la nota que existía
(repetido: true) en vez de emitir una segunda.
Sólo se puede anular lo que ya tiene folio. Un borrador todavía no existe para el SII: se descarta desde Egestia.
Boletas de honorarios
Cuando a quien le pagas no es un cliente sino un prestador, el documento no es una venta: es una boleta de honorarios de terceros que la empresa emite por cuenta de él, le retiene al SII lo que corresponde y le transfiere el resto. Otro registro del SII, y no gasta folios del CAF.
const boleta = await egestia.honorarios.emitir({
rut: '11.111.111-1',
nombre: 'Ana Soto',
bruto: 1_000_000, // lo que acordaste pagarle
referencia: pago.id, // ← IMPORTANTE, más abajo
origen: 'pagos',
descripcion: 'Diseño de marca',
sucursal: 3, // a qué sucursal se carga el gasto
});La respuesta trae los tres montos ya resueltos por el SII:
boleta.folio // '184' número de la boleta
boleta.grossAmount // 1000000 lo que ganó el prestador; lo que él declara
boleta.withholdingRate // 14.5 la tasa que aplicó el SII, en porcentaje
boleta.withheldAmount // 145000 lo retiene la empresa y lo entera al SII
boleta.netAmount // 855000 ← lo ÚNICO que se transfiere
await transferir(boleta.issuer.rut, boleta.netAmount);Se transfiere netAmount, no grossAmount. La diferencia la entera la
empresa al SII: transferirla igual es pagarla dos veces, una al prestador y otra
al fisco.
Tú mandas el bruto y nada más. La retención no se calcula por fuera: la aplica el SII con la tasa vigente para ese receptor, que cambia todos los años. Emitir con una tasa vieja significa retener de menos y responder por la diferencia.
referencia: acá pesa más que en una venta
Un DTE duplicado es un folio quemado. Una boleta de honorarios duplicada es una retención duplicada que la empresa declara y entera al SII, y un prestador con dos boletas a su nombre. Deshacerlo es anular en el SII —con causa, que él puede reclamar— y rehacer la transferencia.
Con referencia, repetir la llamada devuelve la boleta que ya existe en vez de emitir otra, y el SDK puede reintentar solo ante un corte de red:
const boleta = await egestia.honorarios.emitir({ referencia: pago.id, /* ... */ });
if (boleta.repetido) {
// No se emitió nada: esta referencia ya tenía boleta. Si ya transferiste,
// no vuelvas a transferir.
}Sin referencia el SDK no reintenta, porque un segundo intento sería una segunda boleta.
Si dudas de si un pago alcanzó a emitir:
const existente = await egestia.honorarios.buscarPorReferencia(pago.id, { origen: 'pagos' });
if (!existente) await egestia.honorarios.emitir({ /* ... */ });Y si la misma referencia llega con otro monto, eso no es un reintento: es otro pago con la referencia equivocada. El SDK lo frena en vez de devolverte la boleta vieja —que te haría transferir el líquido de otra prestación—.
Consultar y anular
const boleta = await egestia.honorarios.obtener(id);
// → { folio, status, grossAmount, withheldAmount, netAmount, issuer, ... }En el SII una boleta de honorarios sí se anula —no hay nota de crédito de por medio, como en un DTE— pero hay que decir por qué, y el SII ofrece exactamente dos causas:
await egestia.honorarios.anular(boleta.id, { causa: 'error_digitacion' });
// 'no_prestacion' el servicio no se prestó
// 'error_digitacion' la boleta salió con un dato maloEs idempotente: si ya estaba anulada devuelve esa misma (repetido: true).
Anular no devuelve la plata: si ya transferiste el líquido, eso se arregla
aparte.
La sucursal
sucursal acepta el número que sale en el listado de sucursales, o su UUID.
Un honorario es un gasto, y sin sucursal se carga entero a la principal: el
informe por centro de costo queda con una inflada y las otras limpias. Es el
mismo criterio con que los DTE guardan la suya.
El scope
Emitir boletas de honorarios necesita el scope honorarios, que va aparte
de documents: retener plata de un prestador y enterarla al SII no es lo mismo
que facturar una venta, y una key de tienda no tiene por qué poder hacerlo.
También está intentarEmitir() / intentarAnular(), con la misma forma que en
documentos: devuelven el problema explicado en vez de lanzarlo.
Facturas de compra por servicios del exterior
Cuando le pagas a un prestador de otro país —un creador, un freelancer, un servicio— y tu empresa es contribuyente de IVA en Chile, la ley te convierte en el sujeto del impuesto (DL 825 art. 11 letra e). El SII no espera una factura de él: te exige emitirla tú, recargar el IVA y retenerlo entero (Res. Ex. 42/2018). Es el DTE 46, la factura de compra electrónica.
const factura = await egestia.facturasCompra.emitir({
nombre: creador.nombre,
pais: creador.pais,
monto: 1000, // el NETO, en la moneda del pago
moneda: 'USD',
referencia: pago.id, // ← IMPORTANTE, más abajo
origen: 'mi-plataforma',
descripcion: 'Contenido de septiembre',
});Mandas el neto y nada más. Egestia lo convierte con el tipo de cambio del día —que es lo que exige el SII—, recarga el IVA y lo retiene:
factura.folio // '87'
factura.amount // 1000 lo pactado, en su moneda
factura.currency // 'USD'
factura.exchangeRate // 980.5 con qué se convirtió, el día de emisión
factura.net // 980.500 el neto en pesos
factura.tax // 186.295 el IVA recargado: tu crédito fiscal
factura.withheld // 186.295 el IVA retenido: código 39 del F29
factura.total // 980.500 el total del documento, que ES el neto
await transferir(creador, factura.amount); // lo pactado, en su monedaEl total del documento es el neto, porque el IVA se recarga y se retiene
entero: neto + IVA − IVA retenido = neto. Al prestador se le transfiere lo que
se acordó con él (amount en su moneda, o net en pesos); el IVA retenido lo
declaras y lo pagas en el código 39 del F29, y el mismo monto es tu crédito
fiscal. Con débito suficiente en el mes el efecto en caja es cero, pero son dos
partidas, no una compra sin IVA.
El RUT del prestador
Un creador de otro país no tiene RUT chileno, y no hace falta que lo tenga. El
receptor del DTE es su número en la nómina de prestadores extranjeros
inscritos del SII, o el 55.555.555-5 que el SII indica para los no
inscritos.
Manda rut si lo tienes. Si no, o si lo que mandas no tiene forma de RUT
chileno, Egestia resuelve el que corresponde —busca el nombre entre los
inscritos conocidos— y te dice cuál usó:
factura.supplier.rut // '55555555-5'
factura.avisos // ['Jane Doe no figura entre los inscritos conocidos: se usa 55555555-5.']Vale la pena mirar avisos: viene vacío cuando no hubo nada que resolver.
referencia: tres cosas mal en vez de una
Un DTE de venta duplicado es un folio quemado. Un DTE 46 duplicado son tres cosas: el folio quemado, una cuenta por pagar de más a un prestador que prestó el servicio una sola vez, y un crédito fiscal duplicado en el F29 —o sea un impuesto declarado de menos—. El camino de vuelta es una nota de crédito que el SII y el prestador ven.
Con referencia, repetir la llamada devuelve la factura que ya existe:
if (factura.repetido) {
// No se emitió nada: esta referencia ya tenía factura.
// Si ya transferiste, no vuelvas a transferir.
}Y hay un tercer caso que el SDK resuelve solo: si un intento anterior quedó en borrador —faltaba el CAF del 46, no había tipo de cambio ese día— el reintento retoma ese borrador y lo emite con su mismo folio, en vez de dejar un borrador nuevo por cada intento.
Si la misma referencia llega con otro monto, eso no es un reintento: es otro pago con la referencia equivocada, y el SDK lo frena antes de que transfieras el líquido de otro servicio.
Consultar
const factura = await egestia.facturasCompra.obtener(id);
const existente = await egestia.facturasCompra.buscarPorReferencia(pago.id, { origen: 'mi-plataforma' });El SII acepta el envío y resuelve después, así que una factura recién emitida
queda enviada. Para saber en qué quedó:
const final = await egestia.facturasCompra.verificar(factura.id);
// status: 'borrador' | 'enviada' | 'aceptada' | 'rechazada'O de una vez, emitiendo:
const final = await egestia.facturasCompra.emitirYEsperar(pago, { intentos: 8, esperaMs: 3000 });Lo que hay que tener listo en Egestia
Dos cosas, y las dos fallan con un mensaje que las nombra:
- Un CAF del tipo 46. Es distinto del de las facturas de venta: tener folios de factura no da folios de factura de compra.
- El tipo de cambio del día. Egestia lo saca de sus indicadores y no lo inventa: si no lo tiene, la factura queda en borrador con su referencia y el reintento la emite. Un dólar supuesto es un DTE mal emitido, y eso sólo se arregla con nota de crédito.
No hay anular
Un DTE 46 emitido se echa atrás con una nota de crédito, y eso hoy se hace desde Egestia. El SDK no lo inventa.
El scope
Emitir facturas de compra necesita el scope compras. Va aparte de
documents por la misma razón que honorarios: esto no factura una venta, crea
una deuda y un crédito fiscal.
Cuando algo falla, el SDK te dice qué pasó
Hay dos formas de trabajar. La que no lanza es la recomendada para procesar ventas, porque el problema viene explicado:
const r = await egestia.documentos.intentarEmitir(venta);
if (r.ok) {
await guardarFolio(r.datos.folio);
} else {
console.error(r.problema.mensaje); // qué falló
console.error(r.problema.queHacer); // qué hacer al respecto
if (r.problema.documentId) {
// La venta YA quedó registrada en Egestia. NO reemitir.
await guardarPendiente(r.problema.documentId);
}
}El problema trae:
| Campo | Qué es |
|---|---|
| tipo | validacion, auth, scope, configuracion, sii, red, no_encontrado, servidor |
| mensaje | Lo que respondió Egestia |
| queHacer | La salida concreta, en una frase |
| reintentable | Si insistir tiene sentido |
| documentId | Si viene, el documento existe pese al error |
| sii | Lo último que respondió el SII |
Ejemplos reales de lo que devuelve:
tipo ........ configuracion
mensaje ..... Configure la empresa SII primero (SII > Configuración)
qué hacer ... El cliente no ha configurado sus datos del SII en Egestia.
El documento YA está creado: reintenta con la MISMA referencia.
documento ... 99eec2cb… (draft)
tipo ........ red
mensaje ..... No se pudo conectar con Egestia: fetch failed
qué hacer ... No hubo respuesta, así que no se sabe si la venta se facturó.
Antes de reintentar, pregunta con buscarPorReferencia().Los problemas de configuración que reconoce y explica: SII sin configurar, ambiente no habilitado, CAF sin folios, certificado vencido, documento ya emitido, rechazo del SII.
También está intentarAnular(id), con la misma forma.
Errores
import {
EgestiaValidationError, // 400 — los datos no pasan la validación
EgestiaAuthError, // 401 — la key falta, venció o la revocaron
EgestiaScopeError, // 403 — a la key le falta el scope
EgestiaEmissionError, // 502 — el documento SE CREÓ pero no se emitió
EgestiaNetworkError, // 0 — no llegó o no alcanzó a responder
} from '@egestia/sdk';El importante es EgestiaEmissionError. La venta quedó registrada: trae
documentId y documentStatus. Nunca lo resuelvas reintentando a ciegas.
explicar(error) convierte cualquiera de estos en el mismo Problema de la
sección anterior, por si prefieres try/catch:
import { explicar } from '@egestia/sdk';
try {
await egestia.documentos.emitir(venta);
} catch (e) {
const p = explicar(e);
logger.error({ tipo: p.tipo, mensaje: p.mensaje, documentId: p.documentId });
}try {
await egestia.documentos.emitir(venta);
} catch (e) {
if (e instanceof EgestiaEmissionError) {
// El documento existe. Guarda el id y revísalo en Egestia; reintentar con
// la misma referencia devolverá ESE documento, no uno nuevo.
await guardar({ documentId: e.documentId, estado: e.documentStatus });
} else if (e instanceof EgestiaValidationError) {
// Datos malos: repetirlo no lo arregla.
}
}Catálogo y stock
Egestia lleva el libro mayor del stock. Tu sistema no lo escribe: pide que se descuente al cobrar y que se devuelva si el pago se cae.
const productos = await egestia.productos.listar();
await egestia.productos.guardar({ sku: 'DOM-CL', name: 'Dominio .cl', price: 9500 });
await egestia.stock.comprometer({ reference: pedido.id, items: [{ sku: 'DOM-CL', quantity: 1 }] });
await egestia.stock.liberar({ reference: pedido.id }); // si el pago no se concretóConfiguración
| Opción | Por defecto | Para qué |
|---|---|---|
| apiKey | — | La clave egst_.... Obligatoria. |
| baseUrl | https://api.egestia.cl/api/pub/v1 | Otra instancia o el entorno local. |
| timeout | 30000 | Milisegundos antes de abandonar. |
| reintentos | 2 | Sólo aplica a operaciones repetibles. |
| appName | — | Se antepone al User-Agent. |
| fetch | globalThis.fetch | Implementación propia, si el entorno no la trae. |
Scopes
La key necesita el scope de cada cosa: documents para emitir, consultar y
anular boletas y facturas; honorarios para las boletas de honorarios de
terceros; compras para las facturas de compra por servicios del exterior;
read para el catálogo; write para stock y productos. Una key con write
puede todo.
honorarios y compras van aparte de documents a propósito: emitir por cuenta
de un prestador retiene plata que la empresa entera al SII, y una factura de
compra además crea una deuda y un crédito fiscal. Nada de eso debería venir de
regalo con el permiso de facturar una venta.
