Saltar a contenido

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

smartdoc_config.py
import os
import smartdoc

def cliente() -> smartdoc.Client:
    """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.
    """
    return smartdoc.Client(
        api_key=os.environ["SMARTDOC_API_KEY"],
        establishment="001",
        dispatch_point="001",
    )

Reusá el cliente

Mantiene una conexión HTTP abierta y cachea los datos del emisor. 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
cliente_local La ficha del cliente en tu sistema ruc, razon_social, nombre, es_empresa, email, telefono, direccion, numero_casa, ciudad, departamento, id
venta La operación que vas a facturar id, cliente, lineas, es_a_credito, fecha
linea Cada renglón de la venta descripcion, cantidad, precio_unitario, sku, descuento_unitario, producto
producto La ficha del producto tratamiento_iva, porcentaje_gravado

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 empieza con smartdoc.smartdoc.Recipient, smartdoc.Item, smartdoc.Client—.

Las dos funciones de abajo son el puente entre las dos cosas.

mapeo.py
import smartdoc

def recipient_for(cliente_local) -> smartdoc.Recipient:
    """Traduce un cliente de tu sistema al receptor del documento.

    Entra `cliente_local`, que es tuyo. Sale un `smartdoc.Recipient`.
    """
    if not cliente_local.ruc:
        # Venta de mostrador, sin identificar al comprador.
        return smartdoc.Recipient.not_nominated()

    comun = {
        "email": cliente_local.email,
        "phone": cliente_local.telefono,
        "code": f"cli-{cliente_local.id}",   # para cruzarlo después
    }

    if cliente_local.ciudad:
        comun |= {
            "address": cliente_local.direccion,
            "house_number": cliente_local.numero_casa,
            "city": smartdoc.geo.find_city(
                cliente_local.ciudad,
                department=cliente_local.departamento,
            ),
        }

    if cliente_local.es_empresa:
        return smartdoc.Recipient.entity(
            ruc=cliente_local.ruc,
            social_name=cliente_local.razon_social,
            **comun,
        )
    return smartdoc.Recipient.person(
        ruc=cliente_local.ruc,
        name=cliente_local.nombre,
        **comun,
    )


def items_de(venta) -> list[smartdoc.Item]:
    """Traduce las líneas de tu venta a ítems del documento.

    Entra `venta`, que es tuya. Sale una lista de `smartdoc.Item`.
    """
    return [
        smartdoc.Item(
            linea.descripcion,
            quantity=linea.cantidad,
            unit_amount=linea.precio_unitario,   # con IVA incluido
            iva=linea.producto.tratamiento_iva,  # (1)!
            iva_base=linea.producto.porcentaje_gravado,  # (2)!
            code=linea.sku,
            discount=linea.descuento_unitario,   # por unidad, no de la línea
        )
        for linea in venta.lineas
    ]
  1. 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í.

  2. Solo hace falta en los tratamientos mixtos. Para el resto, pasá None o no lo pases.

No mapees el IVA desde una categoría comercial

Es tentador escribir algo como {"general": Iva.TEN, "canasta_basica": 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

smartdoc.Iva.TEN          # 10% — la tasa general
smartdoc.Iva.FIVE         #  5% — tasa reducida
smartdoc.Iva.EXEMPT       # exento
smartdoc.Iva.MIXED_TEN    # parte gravada al 10%, parte exenta
smartdoc.Iva.MIXED_FIVE   # parte gravada al  5%, parte exenta

Los dos mixtos exigen iva_base: qué porcentaje de la línea está gravado.

# Un combo de 1.000.000 donde el 60% tributa al 10% y el 40% está exento
smartdoc.Item(
    "Combo",
    quantity=1,
    unit_amount=1000000,
    iva=smartdoc.Iva.MIXED_TEN,
    iva_base=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é
smartdoc_entity El tipo de documento: "invoice", "receipt"
smartdoc_id El id del documento en SmartDoc
cdc El CDC, que llega recién con la aprobación
estado_fiscal 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.

facturacion.py
import logging
import smartdoc

log = logging.getLogger(__name__)


def emitir(sd: smartdoc.Client, venta) -> int:
    """Emite la factura de una venta y devuelve el id en SmartDoc."""
    factura = sd.invoices.create(
        recipient=recipient_for(venta.cliente),
        items=items_de(venta),
        sale_type=(
            smartdoc.SaleType.CREDIT if venta.es_a_credito
            else smartdoc.SaleType.CASH
        ),
        transaction_type=smartdoc.TransactionType.MERCHANDISE_SALE,
        invoice_date=venta.fecha,
        description=f"Venta #{venta.id}",
    )

    venta.smartdoc_entity = "invoice"      # el id se numera por tipo
    venta.smartdoc_id = factura.id
    venta.estado_fiscal = str(factura.status)
    venta.save()

    log.info("Venta %s → factura %s (%s)", venta.id, factura.id, factura.status)
    return factura.id

Guardá el id y el tipo enseguida

Es lo que va a llegar en el webhook como entity_id y entity_type. Sin eso no vas a poder vincular el evento con tu venta.

Guardá los dos: entity_id 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

def emitir_con_manejo(sd, venta) -> int | None:
    try:
        return emitir(sd, venta)

    except smartdoc.ValidationError as e:
        # Datos mal armados. No sirve reintentar: hay que corregir.
        log.error("Venta %s rechazada: %s %s", venta.id, e.message, e.field_errors)
        venta.marcar_error_fiscal(str(e))
        return None

    except smartdoc.PermissionError as e:
        # A la API Key le falta un permiso. Es de configuración.
        log.critical("Falta el permiso %s en la API Key", e.missing_permission)
        raise

    except (smartdoc.RateLimitError, smartdoc.ServerError) as e:
        # Transitorio. El SDK ya reintentó; encolá para más tarde.
        log.warning("Venta %s no se pudo emitir ahora: %s", venta.id, e)
        cola.reintentar_mas_tarde(venta.id)
        return None

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.

webhooks_smartdoc.py
import os
import smartdoc

webhooks = smartdoc.Webhooks(secret=os.environ["SMARTDOC_WEBHOOK_SECRET"])


def venta_de(evento) -> Venta:
    """La venta que corresponde al evento.

    Se busca por el par (entidad, id) y no solo por el id: `entity_id` se
    numera por tipo de documento, así que el recibo 20 y la nota de débito 20
    conviven.
    """
    entidad, numero = evento.document_key
    return Venta.objects.get(smartdoc_entity=entidad, smartdoc_id=numero)


@webhooks.on(smartdoc.Event.INVOICE_APPROVED)
def factura_aprobada(evento):
    venta = venta_de(evento)
    if venta.estado_fiscal == "aprobada":
        return                          # (1)!

    venta.cdc = evento.cdc
    venta.estado_fiscal = "aprobada"
    venta.save()

    # El KuDE ya está disponible; el trabajo pesado va a una cola.
    cola.enqueue(adjuntar_kude, venta.id)


@webhooks.on(smartdoc.Event.INVOICE_ERROR)
def factura_en_error(evento):
    venta = venta_de(evento)
    venta.estado_fiscal = "error"
    venta.error_fiscal = f"{evento.error_code}: {evento.error_message}"
    venta.save()

    alertar_al_equipo(venta)


@webhooks.on(smartdoc.Event.INVOICE_CANCELLED)
def factura_anulada(evento):
    entidad, numero = evento.document_key
    Venta.objects.filter(
        smartdoc_entity=entidad, smartdoc_id=numero
    ).update(estado_fiscal="anulada")
  1. 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.

Y se monta:

urls.py
urlpatterns = [
    path("webhooks/smartdoc", webhooks.django_view()),
]

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

def adjuntar_kude(venta_id: int) -> None:
    venta = Venta.objects.get(id=venta_id)
    sd = cliente()

    url = sd.invoices.kude_url(venta.smartdoc_id)   # URL temporal
    pdf = requests.get(url, timeout=30).content

    venta.kude.save(f"factura-{venta.cdc}.pdf", ContentFile(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:

def anular(sd: smartdoc.Client, venta, motivo: str) -> None:
    try:
        sd.invoices.cancel(venta.smartdoc_id, reason=motivo)
        venta.estado_fiscal = "anulación solicitada"
        venta.save()
    except smartdoc.ValidationError as e:
        if e.code == "ACTION_NOT_ALLOWED":
            raise NoSePuedeAnular(
                "Solo se puede anular una factura aprobada por la DNIT."
            ) from e
        raise

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.

test_integracion.py
import smartdoc

RUC_QUE_FALLA = "99999901"     # rechazo de la DNIT, código 0160


def test_una_venta_normal_se_aprueba(sd, venta_de_prueba):
    factura_id = emitir(sd, venta_de_prueba)
    factura = sd.invoices.wait_until_final(factura_id)

    assert factura.is_approved
    assert factura.cdc


def test_una_venta_rechazada_queda_en_error(sd, venta_de_prueba):
    venta_de_prueba.cliente.ruc = RUC_QUE_FALLA

    factura_id = emitir(sd, venta_de_prueba)
    factura = sd.invoices.wait_until_final(factura_id)

    assert factura.is_error
    assert factura.error_code == "0160"

Un documento tarda en llegar a su estado final: pasa por varios intermedios y cada salto lo da el servidor por su cuenta. wait_until_final 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:

ngrok http 8000

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.
  • [ ] Guardás el id y el tipo de documento al crear, antes de esperar nada.
  • [ ] Buscás por evento.document_key, no solo por entity_id.
  • [ ] Manejás ValidationError (no reintentar) distinto de ServerError (reintentar).
  • [ ] Probaste el camino de error con el RUC 99999901.
  • [ ] 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.