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¶
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.
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
]
-
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í.
-
Solo hace falta en los tratamientos mixtos. Para el resto, pasá
Noneo 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.
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.
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")
- 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:
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.
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:
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_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.
- [ ] Guardás el
idy el tipo de documento al crear, antes de esperar nada. - [ ] Buscás por
evento.document_key, no solo porentity_id. - [ ] Manejás
ValidationError(no reintentar) distinto deServerError(reintentar). - [ ] Probaste el camino de error con el RUC
99999901. - [ ] Si emitís desde varias sucursales, pasás
establishmentpor llamada.