Ir al contenido
Developers

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:

  • payload guarda 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_status convierte "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:

ControlAlarma si
Ventas leídas por local, por díaAlgún local activo trae cero
Antigüedad de la fila pending más viejaSupera las 24 horas
Cantidad de rejectedEs mayor a cero
Cantidad de unmappedEs mayor a cero
Total enviado al ERP contra total de salesDifieren 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