Caja Horizonte registra las horas extra y los recargos de su personal en un sistema de turnos y lleva su contabilidad en un ERP. En esta receta conectas ambos con el módulo Administrativo de Compense: cargas las novedades del mes al periodo de nómina, esperas a que nómina lo apruebe en el panel, lees los desprendibles y el estado de la nómina electrónica, y causas el asiento contable en borrador para que el contador lo contabilice.

Todo lo que entra por API es una solicitud o un borrador: calcular, aprobar y contabilizar la nómina, y transmitir la nómina electrónica a la DIAN, lo hacen personas con rol en el panel.

sequenceDiagram
    autonumber
    participant T as Sistema de turnos
    participant E as ERP de Caja Horizonte
    participant G as Gateway Compense
    participant N as Nómina (panel)
    participant C as Contador (panel)
    T->>E: horas extra y recargos de octubre
    E->>G: payroll.list_periods(year, status=ABIERTO)
    E->>G: payroll.create_novelties(period_id, items[≤500]) + Idempotency-Key
    G-)E: admin.payroll.novelty.received (por novedad)
    N->>N: calcula y aprueba el periodo
    loop hasta APROBADO
        E->>G: payroll.get_period(id)
    end
    E->>G: payroll.list_period_payslips(id)
    E->>G: payroll.electronic(year, month)
    E->>G: accounting.compute_taxes(items)
    E->>G: accounting.create_entry(external_id, lines) → DRAFT
    G-)E: admin.accounting.entry.drafted
    C->>C: revisa y contabiliza el comprobante

Requisitos #

  • Una llave con payroll:read, payroll:write, accounting:read y accounting:write (o el preset admin-erp).
  • Los colaboradores ya existen en Compense (si hay ingresos nuevos, créalos antes con hr.upsert_employee; ver Talento humano).
  • Opcional: un endpoint de webhooks suscrito a admin.payroll.novelty.received y admin.accounting.entry.drafted.

Los ejemplos crean el cliente en el paso 1 y lo reutilizan; en Go asumen ctx y client creados como en el quickstart.

1. Encuentra el periodo abierto #

Las novedades se cargan a un periodo ABIERTO o CALCULADO. Un periodo aprobado o revertido responde 409 PERIOD_CLOSED.

import os
from compense_gateway import CompenseClient

client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")

periodos = client.admin.payroll.list_periods(year=2026, status="ABIERTO")
periodo = next(p for p in periodos.results if p["status"] in ("ABIERTO", "CALCULADO"))
print(periodo["id"], periodo["name"], periodo["start_date"], periodo["end_date"])

2. Carga las novedades del mes #

Convierte cada registro del sistema de turnos en una novedad: las horas extra y recargos (HED, HEN, HRN, HRDDF, HRNDF, HEDDF, HENDF) van con quantity en horas; las bonificaciones, comisiones y deducciones (BONIFICACION_NS, COMISION, DESCUENTO…) con amount. Los conceptos que calcula el motor (salario, salud, pensión, retención…) se rechazan.

Envíalas en lotes de hasta 500. Cada lote es todo o nada: si una fila falla, no entra ninguna y el error señala la fila (items[3].quantity: …). Deriva la Idempotency-Key del periodo y del número de lote de tu proceso, para que reprocesar el mismo archivo no duplique horas.

from compense_gateway import IdempotencyConflictError, ValidationError

registros = [   # lo que exportó el sistema de turnos
    {"documento": "1000000001", "concepto": "HED", "horas": 4, "fecha": "2026-10-15"},
    {"documento": "1000000001", "concepto": "HRN", "horas": 6, "fecha": "2026-10-18"},
    {"documento": "1000000002", "concepto": "HEN", "horas": 2, "fecha": "2026-10-21"},
]
items = [{"employee": r["documento"], "concept_code": r["concepto"],
          "quantity": r["horas"], "date": r["fecha"]} for r in registros]

for n, i in enumerate(range(0, len(items), 500), start=1):
    try:
        tx = client.admin.payroll.create_novelties(
            period_id=periodo["id"], items=items[i:i + 500],
            idempotency_key=f"horizonte-turnos-{periodo['id']}-lote-{n}",
        )
        print(f"Lote {n}: {tx.data['created']} novedades", tx.data.get("warnings"))
    except ValidationError as e:                   # 422: una fila inválida, el lote no entró
        print(f"Lote {n} rechazado: {e.detail}")
    except IdempotencyConflictError as e:          # 409: PERIOD_CLOSED (o llave en curso)
        print(f"Lote {n}: {e.detail}")
JSON
{"status": "success", "transaction_id": "gw-…", "module": "admin",
 "data": {"created": 3, "period_id": "per-O-…", "warnings": [],
          "results": [{"id": "pnv-E-…", "employee": {"id": "emp-E-…", "name": "Laura Sandbox"},
                       "concept": {"code": "HED"}, "date": "2026-10-15", "quantity": "4",
                       "status": "PENDIENTE"}]}}

Si el periodo ya tenía la prenómina calculada, warnings lo avisa: nómina debe recalcularla. Para revisar lo que ya cargaste usa payroll.list_novelties(period=…, employee=…).

3. Espera la aprobación y lee los desprendibles #

Nómina calcula y aprueba el periodo en el panel. Hoy no hay webhook de aprobación: consulta el periodo con payroll.get_period (por ejemplo, una vez por hora el día de cierre) hasta que esté APROBADO. Luego lee los desprendibles del periodo: devengos (earnings), deducciones (deductions) y neto (net_pay) por colaborador. Para el detalle por concepto usa payroll.get_payslip(id). Cada consulta de desprendibles queda en la bitácora de nómina.

import time

while client.admin.payroll.get_period(periodo["id"])["status"] != "APROBADO":
    time.sleep(3600)

desprendibles = client.admin.payroll.list_period_payslips(periodo["id"])
for d in desprendibles.results:
    print(d["employee_doc"], d["earnings"], d["deductions"], d["net_pay"])

4. Revisa la nómina electrónica #

Nómina genera y transmite la nómina electrónica a la DIAN desde el panel. Por API consultas el estado del mes: proveedor, resumen por estado y los documentos. Úsalo para alertar si quedó algún documento rechazado antes de causar.

ne = client.admin.payroll.electronic(year=2026, month=10)
print(ne.raw.get("provider"), ne.raw.get("summary"))      # p. ej. {'ACEPTADO': 2}
for doc in ne.results:
    print(doc)

Los SDK devuelven esta lectura como una página: los documentos están en results y el resumen y el proveedor, en el cuerpo crudo (raw).

5. Causa el asiento en borrador #

Con los desprendibles aprobados, arma el comprobante de causación: el gasto de nómina al débito y, al crédito, las deducciones y el neto por pagar. Si en la misma causación incluyes servicios de terceros (por ejemplo, el operador de nómina electrónica), calcula antes su retención con el simulador de impuestos, que es una consulta pura: no escribe y no lleva Idempotency-Key.

El comprobante entra en DRAFT y su external_id (tu id en el ERP) lo hace idempotente en el motor: si reenvías el mismo external_id, recibes el comprobante existente (created: false) en vez de uno nuevo.

from decimal import Decimal

dev = sum(Decimal(d["earnings"]) for d in desprendibles.results)
ded = sum(Decimal(d["deductions"]) for d in desprendibles.results)
neto = sum(Decimal(d["net_pay"]) for d in desprendibles.results)

# Retención del operador de nómina electrónica (servicio de un tercero)
ret = client.admin.accounting.compute_taxes(
    items=[{"concept": "RF_SERVICIOS", "base": "350000"}], date="2026-10-31", third_party="900123456",
).results[0]
retencion = Decimal(ret["amount"])

tx = client.admin.accounting.create_entry(
    date="2026-10-31",
    description="Causación nómina octubre 2026",
    lines=[
        {"account": "510506", "debit": str(dev), "description": "Sueldos"},
        {"account": "237005", "credit": str(ded), "description": "Deducciones de nómina"},
        {"account": "250505", "credit": str(neto), "description": "Salarios por pagar"},
        {"account": "513595", "debit": "350000", "third_party": "900123456", "description": "Operador nómina electrónica"},
        {"account": "236525", "credit": str(retencion), "third_party": "900123456", "tax_concept": "RF_SERVICIOS", "base_amount": "350000"},
        {"account": "233595", "credit": str(Decimal("350000") - retencion), "third_party": "900123456"},
    ],
    external_id="HORIZONTE-ERP-NOM-2026-10",
    voucher_type="CC",
    idempotency_key="horizonte-erp-nom-2026-10",
)
print(tx.data["id"], tx.data["status"], tx.data.get("created"))      # … DRAFT True

Las cuentas del ejemplo son ilustrativas: usa las de tu plan de cuentas. El comprobante debe cuadrar (débitos = créditos) para que el contador pueda contabilizarlo; el panel valida además cuentas de movimiento, tercero y centro de costo obligatorios, y periodo abierto. post: true responde 422 POST_NOT_ALLOWED.

6. Sigue el comprobante hasta que se contabilice #

El comprobante del ERP lleva source_module: "gateway" y el panel no lo edita: si el contador encuentra un error, lo anula y tú lo corriges en el ERP y lo reenvías con el mismo external_id. Para saber si ya se contabilizó, consulta por external_id:

entradas = client.admin.accounting.list_entries(external_id="HORIZONTE-ERP-NOM-2026-10", source="gateway")
for e in entradas.results:
    print(e["id"], e["status"], e.get("number"))

Eventos #

EventoCuándoÚsalo para
admin.payroll.novelty.receivedSe cargó cada novedad de nóminaMarcar en el sistema de turnos qué registros ya entraron (novelty_id, employee_id, concept_code, period_id)
admin.accounting.entry.draftedSe creó el comprobante en borradorGuardar el entry_id junto a tu external_id

Los eventos se emiten en la misma transacción que el registro y su event_id sale de la Idempotency-Key: un reintento no duplica el callback. Ninguno significa que se movió dinero. El catálogo completo está en Eventos.

Pruébalo en el sandbox #

Para probarHaz esto
Periodo abiertopayroll.list_periods devuelve per-O-… (octubre 2026, ABIERTO)
Periodo cerradoCarga novedades a per-C-… (septiembre 2026, APROBADO) → 409 PERIOD_CLOSED
Fila inválidaUn concepto del sistema (SALARIO, SALUD…) o una fecha fuera de octubre → 422 y el lote no entra
Colaborador inexistenteemployee 9999999999 → 422 en la fila
DesprendiblesCualquier periodo per-…: Laura Sandbox (1000000001) y Andrés Sandbox (1000000002)
Nómina electrónicapayroll.electronic(year=2026, month=10) → resumen ACEPTADO
SimuladorAplica una tarifa fija del 4 % a cada base
ComprobanteSiempre DRAFT; mismo external_id → mismo id; post: true → 422

En el sandbox el periodo de octubre se queda ABIERTO (no hay quién lo apruebe): para probar el paso 3 sin esperar, lee los desprendibles directamente.

Lista de verificación #

  • Cargas novedades solo a periodos ABIERTO o CALCULADO y manejas 409 PERIOD_CLOSED.
  • Lotes de hasta 500 con una Idempotency-Key por periodo y lote.
  • Ante un 422 corriges la fila señalada y reenvías todo el lote.
  • Esperas APROBADO antes de causar y revisas la nómina electrónica del mes.
  • El simulador de impuestos se llama sin Idempotency-Key.
  • El comprobante cuadra, entra en DRAFT y lleva un external_id estable de tu ERP.