Tema
Adaptar tu sistema
Las decisiones que solo podés tomar vos. Cómo traducir tu modelo de datos al del documento electrónico, qué criterio usar con el IVA, cómo tratar cada tipo de error y qué revisar antes de producción.
Para el código de cada operación —emitir, cobrar, corregir, anular— andá a Cómo integrar. Esta página no lo repite: se ocupa de lo que esa no puede decidir por vos.
Sobre un sistema de ventas ficticio, para tener nombres concretos.
El punto de partida
Sistema de ventas SmartDoc DNIT
│ │ │
│──── emitir factura ─────────>│ │
│<─── id, status=pending ──────│ │
│ │──── envía el DE ────────>│
│ │<─── aprobado ────────────│
│<─── webhook invoice.approved │ │
│ │ │
guarda CDCLo importante: emitir es asíncrono. La llamada vuelve enseguida con la factura en pending, y la aprobación llega después.
Paso 1: la configuración
ts
// smartdoc.ts
import { Client } from '@araitek/smartdocjs';
/**
* El cliente, configurado una sola vez.
*
* El establecimiento y el punto de expedición se fijan acá porque este sistema
* factura siempre desde la misma caja. Si facturaras desde varias sucursales,
* no los fijes: pasalos por llamada.
*/
export const sd = new Client({
apiKey: process.env.SMARTDOC_API_KEY,
establishment: '001',
dispatchPoint: '001',
});Reusá el cliente
Cachea los datos del emisor y el descubrimiento del timbrado. Crear uno por request desperdicia las dos cosas.
Paso 2: mapear tus datos
Esta es la única parte que es realmente tuya: traducir tu modelo al del documento electrónico.
Para seguir el ejemplo, este es tu modelo —el de tu base de datos— con los nombres que se usan de acá en adelante:
| Tu modelo | Qué es | Campos que se usan |
|---|---|---|
clienteLocal | La ficha del cliente en tu sistema | ruc, razonSocial, nombre, esEmpresa, email, telefono, direccion, numeroCasa, ciudad, departamento, id |
venta | La operación que vas a facturar | id, cliente, lineas, esACredito, fecha |
linea | Cada renglón de la venta | descripcion, cantidad, precioUnitario, sku, descuentoUnitario, producto |
producto | La ficha del producto | tratamientoIva, porcentajeGravado |
Ninguno de esos nombres viene del SDK: son los de tu aplicación, y los vas a cambiar por los tuyos. Lo que sí es del SDK es todo lo que se importa de @araitek/smartdocjs —Recipient, Item, Client—.
Las dos funciones de abajo son el puente entre las dos cosas.
ClienteLocal, Venta y Linea son los tipos de tu aplicación:
ts
// mapeo.ts
import { Item, Recipient, geo } from '@araitek/smartdocjs';
/** Traduce un cliente de tu sistema al receptor del documento. */
export function recipientFor(clienteLocal: ClienteLocal): Recipient {
if (!clienteLocal.ruc) {
// Venta de mostrador, sin identificar al comprador.
return Recipient.notNominated();
}
const comun = {
email: clienteLocal.email,
phone: clienteLocal.telefono,
code: `cli-${clienteLocal.id}`, // para cruzarlo después
...(clienteLocal.ciudad
? {
address: clienteLocal.direccion,
houseNumber: clienteLocal.numeroCasa,
city: geo.findCity(clienteLocal.ciudad, {
department: clienteLocal.departamento,
}),
}
: {}),
};
return clienteLocal.esEmpresa
? Recipient.entity({ ruc: clienteLocal.ruc, socialName: clienteLocal.razonSocial, ...comun })
: Recipient.person({ ruc: clienteLocal.ruc, name: clienteLocal.nombre, ...comun });
}
/** Traduce las líneas de tu venta a ítems del documento. */
export function itemsDe(venta: Venta): Item[] {
return venta.lineas.map(
(linea: Linea) =>
new Item(linea.descripcion, {
quantity: linea.cantidad,
unitAmount: linea.precioUnitario, // con IVA incluido
iva: linea.producto.tratamientoIva, // el IVA es del producto
ivaBase: linea.producto.porcentajeGravado, // solo en los mixtos
code: linea.sku,
discount: linea.descuentoUnitario, // por unidad, no de la línea
}),
);
}El IVA es un atributo del producto, no de una categoría. Dos productos de la misma categoría comercial pueden tributar distinto, y el mismo producto puede cambiar de tratamiento si cambia la ley. Guardalo en la ficha del producto y leelo de ahí.
ivaBase solo hace falta en los tratamientos mixtos. Para el resto, pasá undefined o no lo pases.
No mapees el IVA desde una categoría comercial
Es tentador escribir algo como { general: Iva.TEN, canastaBasica: Iva.FIVE }, pero la correspondencia no es fiable: el tratamiento tributario es una propiedad de cada producto, hay excepciones dentro de cualquier categoría, y existen los casos mixtos. Si emitís con el tratamiento equivocado, la corrección es anular y reemitir.
Los cinco tratamientos
ts
Iva.TEN // 10% — la tasa general
Iva.FIVE // 5% — tasa reducida
Iva.EXEMPT // exento
Iva.MIXED_TEN // parte gravada al 10%, parte exenta
Iva.MIXED_FIVE // parte gravada al 5%, parte exentaLos dos mixtos exigen ivaBase: qué porcentaje de la línea está gravado.
ts
// Un combo de 1.000.000 donde el 60% tributa al 10% y el 40% está exento
new Item('Combo', {
quantity: 1,
unitAmount: 1_000_000,
iva: Iva.MIXED_TEN,
ivaBase: 60,
});Si no se pasa iva, el ítem se emite al 10%, que es la tasa general. Es un default cómodo para el caso mayoritario, pero conviene ser explícito cuando el catálogo tiene productos con tratamientos distintos.
Los precios van con IVA incluido
En Paraguay el IVA está contenido en el precio, no se suma. Si tu sistema guarda precios sin IVA, sumáselo antes de armar el ítem.
Paso 3: emitir
Antes de emitir, agregá cuatro campos a tu tabla de ventas. Son tuyos, y es donde vas a guardar lo que devuelve SmartDoc:
| Campo | Para qué |
|---|---|
smartdocEntity | El tipo de documento: 'invoice', 'receipt'… |
smartdocId | El id del documento en SmartDoc |
cdc | El CDC, que llega recién con la aprobación |
estadoFiscal | En qué estado quedó |
El cdc es el que más se olvida y el que más se necesita después: para emitir un recibo o una nota de crédito sobre esa factura hay que referenciarla por su CDC.
ts
// facturacion.ts
import { SaleType, TransactionType, type Client } from '@araitek/smartdocjs';
/** Emite la factura de una venta y devuelve el id en SmartDoc. */
export async function emitir(sd: Client, venta: Venta): Promise<number> {
const factura = await sd.invoices.create({
recipient: recipientFor(venta.cliente),
items: itemsDe(venta),
saleType: venta.esACredito ? SaleType.CREDIT : SaleType.CASH,
transactionType: TransactionType.MERCHANDISE_SALE,
invoiceDate: venta.fecha,
description: `Venta #${venta.id}`,
});
venta.smartdocEntity = 'invoice'; // el id se numera por tipo
venta.smartdocId = factura.id;
venta.estadoFiscal = String(factura.status);
await venta.save();
return factura.id;
}Guardá el id y el tipo enseguida
Es lo que va a llegar en el webhook como entityId y entityType. Sin eso no vas a poder vincular el evento con tu venta.
Guardá los dos: entityId se numera por tipo de documento, así que el recibo 20 y la nota de débito 20 existen a la vez.
Si la creación falla
ts
import {
PermissionError,
RateLimitError,
ServerError,
ValidationError,
type Client,
} from '@araitek/smartdocjs';
export async function emitirConManejo(sd: Client, venta: Venta): Promise<number | null> {
try {
return await emitir(sd, venta);
} catch (error) {
// Datos mal armados. No sirve reintentar: hay que corregir.
if (error instanceof ValidationError) {
log.error('Venta %s rechazada: %s %o', venta.id, error.apiMessage, error.fieldErrors);
await venta.marcarErrorFiscal(error.message);
return null;
}
// A la API Key le falta un permiso. Es de configuración.
if (error instanceof PermissionError) {
log.error('Falta el permiso %s en la API Key', error.missingPermission);
throw error;
}
// Transitorio. El SDK ya reintentó; encolá para más tarde.
if (error instanceof RateLimitError || error instanceof ServerError) {
log.warn('Venta %s no se pudo emitir ahora: %s', venta.id, error.message);
await cola.reintentarMasTarde(venta.id);
return null;
}
throw error;
}
}Paso 4: escuchar los eventos
En el panel de SmartDoc creá un endpoint de webhook y suscribilo a invoice.approved, invoice.error e invoice.cancelled. Guardá el secreto.
ts
// webhooks-smartdoc.ts
import { Event, Webhooks, type WebhookEvent } from '@araitek/smartdocjs';
export const webhooks = new Webhooks({
secret: process.env.SMARTDOC_WEBHOOK_SECRET,
});
/**
* La venta que corresponde al evento.
*
* Se busca por el par (entidad, id) y no solo por el id: `entityId` se numera
* por tipo de documento, así que el recibo 20 y la nota de débito 20 conviven.
*/
async function ventaDe(evento: WebhookEvent): Promise<Venta> {
const [entidad, numero] = evento.documentKey;
return await Venta.findOne({ smartdocEntity: entidad, smartdocId: numero });
}
webhooks.on(Event.INVOICE_APPROVED, async (evento) => {
const venta = await ventaDe(evento);
if (venta.estadoFiscal === 'aprobada') return; // ya se procesó
venta.cdc = evento.cdc;
venta.estadoFiscal = 'aprobada';
await venta.save();
// El KuDE ya está disponible; el trabajo pesado va a una cola.
await cola.encolar('adjuntarKude', venta.id);
});
webhooks.on(Event.INVOICE_ERROR, async (evento) => {
const venta = await ventaDe(evento);
venta.estadoFiscal = 'error';
venta.errorFiscal = `${evento.errorCode}: ${evento.errorMessage}`;
await venta.save();
await alertarAlEquipo(venta);
});
webhooks.on(Event.INVOICE_CANCELLED, async (evento) => {
const [entidad, numero] = evento.documentKey;
await Venta.update(
{ smartdocEntity: entidad, smartdocId: numero },
{ estadoFiscal: 'anulada' },
);
});El handler tiene que ser idempotente. Puede correr más de una vez para el mismo hecho. Escribir el CDC dos veces es inofensivo; encolar el KuDE o mandar un mail dos veces, no. Por eso el primero corta si la venta ya está aprobada.
Y se monta:
ts
// server.ts
app.post(
'/webhooks/smartdoc',
express.raw({ type: 'application/json' }),
webhooks.expressHandler(),
);Respondé en menos de 10 segundos
Pasado ese tiempo la entrega se marca fallida y se reintenta, aunque la hayas procesado bien. Por eso el KuDE se descarga en una cola, no acá.
No esperes todas las transiciones
Podés no ver los estados intermedios de un documento. Tratá cada evento como "esta factura está ahora así", no como un paso de una secuencia.
Paso 5: el KuDE
ts
import { writeFile } from 'node:fs/promises';
export async function adjuntarKude(ventaId: number): Promise<void> {
const venta = await Venta.findById(ventaId);
const url = await sd.invoices.kudeUrl(venta.smartdocId); // URL temporal
const respuesta = await fetch(url);
const pdf = Buffer.from(await respuesta.arrayBuffer());
await writeFile(`facturas/factura-${venta.cdc}.pdf`, pdf);
}La URL es temporal, así que hay que descargar el PDF, no guardarla.
Paso 6: anular y corregir
El código está en Cómo integrar. Lo que conviene decidir de antemano es quién puede hacerlo y con qué motivo: el motivo es obligatorio, viaja a la DNIT y queda en el documento.
Una envoltura que traduzca el error de la API al lenguaje de tu sistema evita que la regla se repita en cada pantalla:
ts
import { ValidationError, type Client } from '@araitek/smartdocjs';
export async function anular(sd: Client, venta: Venta, motivo: string): Promise<void> {
try {
await sd.invoices.cancel(venta.smartdocId, motivo);
venta.estadoFiscal = 'anulación solicitada';
await venta.save();
} catch (error) {
if (error instanceof ValidationError && error.code === 'ACTION_NOT_ALLOWED') {
throw new NoSePuedeAnular(
'Solo se puede anular una factura aprobada por la DNIT.',
);
}
throw error;
}
}Para un documento que quedó en error la vía no es anular: es editarlo con update, y SmartDoc lo reprocesa. Anular es para lo que ya quedó aprobado y está mal.
Paso 7: probar sin emitir de verdad
Con una cuenta demo se puede recorrer todo el circuito sin certificado de firma digital y sin mandar nada a la DNIT.
ts
// integracion.test.ts
import assert from 'node:assert/strict';
import test from 'node:test';
const RUC_QUE_FALLA = '99999901-7'; // rechazo de la DNIT, código 0160
test('una venta normal se aprueba', async () => {
const facturaId = await emitir(sd, ventaDePrueba);
const factura = await sd.invoices.waitUntilFinal(facturaId);
assert.equal(factura.isApproved, true);
assert.ok(factura.cdc);
});
test('una venta rechazada queda en error', async () => {
ventaDePrueba.cliente.ruc = RUC_QUE_FALLA;
const facturaId = await emitir(sd, ventaDePrueba);
const factura = await sd.invoices.waitUntilFinal(facturaId);
assert.equal(factura.isError, true);
assert.equal(factura.errorCode, '0160');
});Un documento tarda en llegar a su estado final: pasa por varios intermedios y cada salto lo da el servidor por su cuenta. waitUntilFinal ya trae un plazo holgado por omisión, así que conviene no acortarlo en las pruebas.
Para probar los webhooks, exponé tu servidor con un túnel:
bash
ngrok http 3000y registrá la URL https:// que devuelva. El panel tiene un botón Enviar evento de prueba para verificar la firma sin emitir nada.
Checklist antes de producción
- [ ] La API Key tiene marcados todos los permisos que usás. No hay jerarquía:
write_invoicesno incluyeread_invoices. - [ ] La key y el secreto de webhook están en variables de entorno, no en el código.
- [ ] El handler del webhook responde en menos de 10 segundos y encola lo pesado.
- [ ] Tus handlers de webhook son idempotentes: pueden correr más de una vez para el mismo hecho.
- [ ] La ruta del webhook recibe el cuerpo crudo, sin parsear.
- [ ] Guardás el
idy el tipo de documento al crear, antes de esperar nada. - [ ] Buscás por
evento.documentKey, no solo porentityId. - [ ] Manejás
ValidationError(no reintentar) distinto deServerError(reintentar). - [ ] Probaste el camino de error con el RUC
99999901-7. - [ ] Si emitís desde varias sucursales, pasás
establishmentpor llamada.
