Skip to content

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 CDC

Lo 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 modeloQué esCampos que se usan
clienteLocalLa ficha del cliente en tu sistemaruc, razonSocial, nombre, esEmpresa, email, telefono, direccion, numeroCasa, ciudad, departamento, id
ventaLa operación que vas a facturarid, cliente, lineas, esACredito, fecha
lineaCada renglón de la ventadescripcion, cantidad, precioUnitario, sku, descuentoUnitario, producto
productoLa ficha del productotratamientoIva, 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/smartdocjsRecipient, 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 exenta

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

CampoPara qué
smartdocEntityEl tipo de documento: 'invoice', 'receipt'
smartdocIdEl id del documento en SmartDoc
cdcEl CDC, que llega recién con la aprobación
estadoFiscalEn 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 3000

y 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_invoices no incluye read_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 id y el tipo de documento al crear, antes de esperar nada.
  • [ ] Buscás por evento.documentKey, no solo por entityId.
  • [ ] Manejás ValidationError (no reintentar) distinto de ServerError (reintentar).
  • [ ] Probaste el camino de error con el RUC 99999901-7.
  • [ ] Si emitís desde varias sucursales, pasás establishment por llamada.

Y después

  • Webhooks — el detalle de la firma, los reintentos y la deduplicación.
  • Errores — la jerarquía completa y qué hacer con cada uno.
  • Recetas — migrar desde la API cruda, emisión por lotes.