Integrar con un ERP
Sincronizar la facturación diaria de cada local hacia el sistema de gestión, de forma incremental y reprocesable
Actualizado el 2026-08-16Para: Desarrolladores
Casi todas las empresas con más de un local ya tienen un ERP, y lo que quieren de la Transactions API es que el ERP vea las ventas sin que nadie cargue nada a mano.
Esta receta arma esa sincronización: incremental, reprocesable y con trazabilidad de qué se envió y cuándo.
Arquitectura
Transactions API tu integración ERP
────────────────────────────────────────────────────────────────────
GET /sales ──► tabla staging (upsert por uuid)
GET /payments ──► tabla staging (upsert por uuid)
│
▼
transformación
(mapeo de locales,
medios de pago, impuestos)
│
▼
cola de salida ──► API del ERP
La pieza que no hay que saltear es el staging. Escribir directo de la API al ERP parece más simple hasta la primera vez que el ERP está caído: sin staging, ese día se pierde y hay que reconstruirlo a mano.
Paso 1. Sincronización incremental
El motor es una ventana sobre updated_at con superposición.
def sync_window(cursor_state):
"""Ventana nueva: desde 15 minutos antes de la última sincronización."""
desde = cursor_state["last_synced_at"] - timedelta(minutes=15)
hasta = datetime.now()
return desde, hasta
Los 15 minutos de superposición traen algunas filas repetidas. El upsert las absorbe sin ruido y te cubren contra relojes desfasados. Sin superposición, un hueco de un segundo es un hueco para siempre. Está explicado en
Paso 2. Staging con upsert
CREATE TABLE staging_sales (
uuid BIGINT PRIMARY KEY,
shop_code INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
total NUMERIC(14,2) NOT NULL,
currency CHAR(3) NOT NULL,
payload JSONB NOT NULL,
erp_status TEXT NOT NULL DEFAULT 'pending',
erp_sent_at TIMESTAMP,
erp_document TEXT
);
Dos columnas que parecen de más y no lo son:
payloadguarda la respuesta cruda. El día que el ERP pida un campo que hoy descartás, lo tenés sin volver a pedirle a la API un año de historia.erp_statusconvierte "lo mandamos" en un dato consultable. Sin eso, la respuesta a "¿esta venta llegó al ERP?" es leer logs.
Cuando una venta se modifica, vuelve con updated_at nuevo. El upsert tiene que
reabrir el envío:
ON CONFLICT (uuid) DO UPDATE SET
total = EXCLUDED.total,
updated_at = EXCLUDED.updated_at,
payload = EXCLUDED.payload,
erp_status = 'pending' -- volvió a cambiar: hay que reenviarla
WHERE staging_sales.updated_at < EXCLUDED.updated_at;
Paso 3. Mapear locales y medios de pago
El ERP no conoce los shop_code de Bistro ni sus medios de pago. Ese mapeo va en
una tabla de configuración, nunca hardcodeado en el código: cuando abra el
local 1011 el mes que viene, tiene que ser una fila, no un despliegue.
CREATE TABLE erp_shop_mapping (
shop_code INTEGER PRIMARY KEY,
erp_branch TEXT NOT NULL,
cost_center TEXT NOT NULL
);
CREATE TABLE erp_payment_mapping (
payment_method TEXT PRIMARY KEY,
erp_account TEXT NOT NULL
);
Si aparece un local o un medio de pago sin mapear, la fila queda en
erp_status = 'unmapped' y se avisa. Lo que no hay que hacer es inventar un
valor por defecto: una venta imputada al centro de costo equivocado es peor que
una venta no imputada, porque nadie la va a buscar.
Paso 4. Enviar al ERP
def push_pending(db, erp_client, batch_size=200):
filas = db.fetch_pending(limit=batch_size)
for fila in filas:
try:
documento = erp_client.create_invoice(transform(fila))
except ErpTemporaryError:
continue # queda pendiente para la próxima
except ErpValidationError as error:
db.mark(fila["uuid"], status="rejected", detail=str(error))
continue
db.mark(fila["uuid"], status="sent", document=documento["id"])
Tres estados finales y ninguna zona gris: sent, rejected o sigue pending.
Un error temporal no marca nada y se reintenta solo en la corrida siguiente.
Paso 5. Controles diarios
Un tablero mínimo que evita el 90% de las sorpresas de fin de mes:
| Control | Alarma si |
|---|---|
| Ventas leídas por local, por día | Algún local activo trae cero |
Antigüedad de la fila pending más vieja | Supera las 24 horas |
Cantidad de rejected | Es mayor a cero |
Cantidad de unmapped | Es mayor a cero |
Total enviado al ERP contra total de sales | Difieren en más de un centavo por venta |
Qué no hacer
- No borres el staging. Es tu única forma de reconstruir sin volver a pedirle a la API un histórico entero.
- No uses el ERP como registro de qué sincronizaste. Si el ERP se cae o se migra, perdiste el estado.
- No sincronices en tiempo real venta por venta. La Transactions API es de lectura y paginada: está pensada para batches, no para un webhook por venta.
- No corras varias instancias con el mismo usuario. Comparten el límite de llamadas y se van a estorbar entre sí. Ver