Cada mes, Caja Horizonte liquida la cuota monetaria de sus afiliados y la paga. Un error aquí cuesta caro: pagar dos veces a miles de afiliados, o dejar a la mitad sin pago porque el proceso se cayó a mitad del lote. Esta receta cierra el período octubre de 2026 de forma que se pueda interrumpir y relanzar en cualquier punto sin pagar dos veces.

Necesitas una llave con subsidies:submit (generar), subsidies:read (listar) y subsidies:disburse (pagar), un endpoint suscrito a monetary_allowance.disbursed y el módulo Subsidios y cuota monetaria a mano.

El proceso #

sequenceDiagram
  autonumber
  participant J as Job de cierre (Caja Horizonte)
  participant G as Gateway Compense
  participant H as Handler de webhooks
  J->>G: generate(period=2026-10, dry_run=true)
  G-->>J: liquidación simulada
  Note over J: compara con tu liquidación (afiliados, total)
  J->>G: generate(period=2026-10) · Idempotency-Key estable
  G-->>J: generación en firme
  loop página por página
    J->>G: list(period=2026-10, status=pending, page=n)
  end
  Note over J: guarda TODOS los disbursement_id antes de pagar
  loop por cada cuota
    J->>G: disburse(disbursement_id) · llave = horizonte-cuota-{id}
    G--)H: monetary_allowance.disbursed
  end
  Note over J,H: cierre: pagadas por webhook = pagadas en el listado

La regla de oro: una Idempotency-Key por operación de negocio, derivada de datos estables (el período, el disbursement_id), nunca un UUID nuevo por intento. Así, relanzar el job completo es inofensivo: lo ya hecho vuelve como idempotent_replay: true.

1. Simula con dry_run #

Lanza la simulación tantas veces como necesites mientras ajustas tus novedades del mes; cada simulación lleva su propia llave (…-simulacion-1, …-simulacion-2), porque reutilizar la misma te devolvería la respuesta de la primera.

sim = client.monetary_allowance.generate(
    period="2026-10", dry_run=True, idempotency_key="horizonte-cuota-2026-10-simulacion-1")
print(sim.transaction_id, sim.data)

Revisa antes de seguir: que el número de afiliados elegibles y el total cuadren con la liquidación de tu área de subsidios, y que los no elegibles tengan un motivo que reconozcas.

dry_run en producción

En el sandbox dry_run no cambia la respuesta (el sandbox nunca persiste). Antes de usar la simulación sobre tu base real, confirma con tu ejecutivo de Compense que el motor de tu entidad honra dry_run; si no, haz la revisión sobre tu propia liquidación y pasa directo al paso 2.

2. Genera en firme #

Una sola llave para la generación del período. Si el job se cae y la relanzas con la misma llave, recibes la respuesta original en vez de generar otra vez.

gen = client.monetary_allowance.generate(
    period="2026-10", idempotency_key="horizonte-cuota-2026-10-generacion")
print(gen.transaction_id, gen.idempotent_replay, gen.data)

Si el motor de tu entidad liquida por afiliado, repite este paso por cada afiliado con extra={"document_number": …} y una llave por afiliado y período (horizonte-cuota-2026-10-<documento>); ver Generar para un afiliado concreto.

3. Lista todas las cuotas pendientes (paginando) #

Recorre todas las páginas y guarda los disbursement_id antes de empezar a pagar: si pagas mientras paginas con status=pending, cada pago saca filas del filtro y las páginas siguientes se corren, así que te saltarías cuotas.

pendientes = []
page_no = 1
while True:
    page = client.monetary_allowance.list(period="2026-10", status="pending", page=page_no, page_size=100)
    pendientes.extend(page.results)
    if not page.next:
        break
    page_no += 1

print(len(pendientes), "cuotas por pagar")
erp.guardar_lote("2026-10", [(c["disbursement_id"], c["amount"]) for c in pendientes])

En .NET, JsonObject es System.Text.Json.Nodes.JsonObject. Las filas traen al menos disbursement_id, period, amount y status; guárdalas en tu ERP con un estado propio (POR_PAGAR).

4. Desembolsa cada cuota con reintentos seguros #

Por cada cuota, una llave derivada del disbursement_id (horizonte-cuota-<id>). Con eso:

  • Un reintento por error transitorio (502, 503, 504, 429, red caída) usa la misma llave y nunca paga dos veces. Los SDK ya reintentan esos casos con espera exponencial; el bucle de abajo cubre caídas más largas.
  • Un 409 con title: Idempotency conflict significa que el intento anterior sigue en curso: espera y reintenta con la misma llave.
  • Cualquier otro 4xx (por ejemplo 400 porque el afiliado no tiene medio de pago, o 409 porque la cuota ya no está pendiente) no se reintenta: márcala para revisión manual.
  • El event_id del webhook se deriva de la llave, así que tampoco recibirás eventos duplicados.
import time
from compense_gateway import CompenseAPIError, IdempotencyConflictError, RateLimitError, UpstreamError


def pagar(cuota_id: str) -> str:
    llave = f"horizonte-cuota-{cuota_id}"
    for intento in range(5):
        try:
            tx = client.monetary_allowance.disburse(cuota_id, idempotency_key=llave)
            return "PAGADA"
        except (UpstreamError, RateLimitError):
            time.sleep(2 ** intento)                         # transitorio: misma llave
        except IdempotencyConflictError as e:
            if e.title != "Idempotency conflict":
                return f"REVISAR: {e.detail}"
            time.sleep(2 ** intento)                         # sigue en curso: misma llave
        except CompenseAPIError as e:
            return f"REVISAR: {e.status} {e.detail}"
    return "REINTENTAR_DESPUES"


for c in pendientes:
    erp.actualizar_cuota(c["disbursement_id"], pagar(c["disbursement_id"]))

Relanzar el bucle completo después de una caída es seguro: las cuotas ya pagadas devuelven idempotent_replay: true sin volver a pagar. Si quieres paralelizar, hazlo por cuotas (nunca dos hilos con la misma cuota) y respeta los límites de tasa de tu llave.

5. Confirma por webhook #

Cada pago emite monetary_allowance.disbursed. Su resource.allowance_id es el disbursement_id que guardaste en el paso 3: con él marcas la cuota como PAGADA en tu ERP aunque el job no haya alcanzado a guardar la respuesta (por ejemplo, si se cayó justo después de pagar).

JSON
{
  "event": "monetary_allowance.disbursed",
  "version": "1",
  "tenant_id": "horizonte",
  "occurred_at": "2026-11-05T09:00:00Z",
  "resource": {
    "allowance_id": "5b2e9c1d-7f3a-4d8b-a6c2-9e0f1a2b3c4d",
    "disbursement_id": "5b2e9c1d-7f3a-4d8b-a6c2-9e0f1a2b3c4d",
    "amount": "47000.00",
    "currency": "COP",
    "method": "wallet",
    "disbursed_at": "2026-11-05T09:00:00Z",
    "status": "disbursed"
  }
}

La rama del handler (el handler completo, con verificación de firma y deduplicación, está en la receta Desembolsar un crédito y conciliar):

Rama del handler
if ev.event == "monetary_allowance.disbursed":
    r = ev.resource
    erp.marcar_cuota_pagada(r["allowance_id"], monto=r["amount"], pagada_en=r["disbursed_at"], evento=ev.event_id)

6. Cierra el período #

El período está cerrado cuando:

  • list(period="2026-10", status="pending") devuelve count: 0, y
  • el número y el total de cuotas PAGADA en tu ERP coinciden con los eventos monetary_allowance.disbursed recibidos y con list(period="2026-10", status="disbursed").

Lo que quedó en REVISAR lo resuelve el área de subsidios (medio de pago del afiliado, novedades) y se paga con la misma llave cuando esté listo. Las cuotas que nadie cobre pasan a prescribed cuando vence su plazo.

En el sandbox #

  • generate responde eligible: true, amount: 50000 y un id mad-E-… (con extra={"document_number": "1000000002"} verás un NOT_ELIGIBLE).
  • list devuelve siempre una cuota de muestra del período en estado GENERATED (el sandbox no guarda estado, así que no filtra por pending): para probar el bucle de pago, usa el disbursement_id de esa fila o el que devolvió generate.
  • disburse de un id mad-E-… responde DISBURSED; uno de un perfil no elegible (mad-I-…, mad-D-…), 409.
  • Repetir disburse con la misma llave devuelve idempotent_replay: true: es la mejor forma de comprobar que tu bucle es seguro antes de producción.

Más datos en Datos de prueba y la teoría en Idempotencia y Reintentos.