En las oficinas de Caja Horizonte los afiliados pagan sus cuotas de crédito en efectivo, y cada mes llega además el archivo de descuentos de nómina (libranza). Todo ese dinero entra por fuera de la plataforma, así que la caja tiene que reportarlo a Compense para que el saldo, la mora, la app del afiliado y el paz y salvo estén al día. Esta receta integra el sistema de taquilla y el proceso del archivo de nómina con el módulo Cartera.

Necesitas una llave con loans:read para la consulta en ventanilla y otra con loans:collect para registrar los pagos (mejor separadas), y un endpoint suscrito a payment.received, payment.applied y loan.paid_off.

El flujo en la ventanilla #

sequenceDiagram
  autonumber
  actor A as Afiliado
  participant T as Taquilla (Caja Horizonte)
  participant G as Gateway Compense
  A->>T: "Vengo a pagar mi crédito" (cédula)
  T->>G: loans.list(document) y installment_breakdown
  G-->>T: préstamos, próxima cuota, desglose
  A->>T: entrega el efectivo
  Note over T: imprime el comprobante HORIZONTE-TAQ-OFI01-…
  T->>G: register_payment(reference=comprobante, Idempotency-Key)
  G-->>T: recibo: allocation, saldo después, paz_y_salvo
  G--)T: webhooks payment.received / payment.applied
  alt saldó el crédito
    G--)T: webhook loan.paid_off
    T->>G: certificate(pdf=true)
    T-->>A: paz y salvo en PDF
  end

La referencia: tu comprobante es la llave #

Todo el diseño se apoya en una idea: el número del comprobante es la llave natural del recibo. Compense identifica cada recaudo por préstamo + canal + referencia:

SituaciónResultado
Primera vez que llega HORIZONTE-TAQ-OFI01-2026-000981Se aplica el pago: 201, replayed: false.
Llega otra vez, mismo monto (doble clic, reintento, reproceso)Mismo recibo, replayed: true; no se aplica dos veces ni se re-emiten webhooks.
Llega otra vez con otro monto409 REFERENCE_REUSED: alguien digitó mal o reutilizó un número.

Reglas prácticas:

  • Usa como reference el número de comprobante impreso que conserva la oficina (máx. 100 caracteres) y hazlo único por canal: HORIZONTE-TAQ-<oficina>-<año>-<consecutivo> para taquilla, la referencia del banco o de la nómina para los archivos.
  • Usa también una Idempotency-Key derivada de la misma referencia (taq-<referencia>). Las dos capas se complementan: la llave protege el reintento inmediato en el borde; la referencia protege aunque la llave cambie (por ejemplo, al reprocesar un archivo completo otro día).

1. Consulta en ventanilla: cuánto debe y cómo se aplicaría #

loans = client.loans.list("1000000003", status="ACTIVE")
loan = loans.results[0]
loan_id = loan["loan_id"]
cuota = loan["next_installment"]
print(loan["loan_number"], loan["status"], "cuota", cuota["number"], cuota["pending_amount"],
      "mora:", cuota["days_overdue"], "días")

desglose = client.loans.installment_breakdown(loan_id, cuota["number"], document="1000000003")
for paso in desglose["waterfall"]:
    print(f'{paso["concept"]:>20} {paso["amount"]}')

Si el afiliado tiene una cuota más antigua en mora, el desglose de las siguientes viene con payable: false y blocked_reason: el cajero debe cobrar primero la vencida. Para “¿cuánto para quedar a paz y salvo hoy?”, usa la cotización del pago total (paso 4).

2. Registra el pago de taquilla #

El cajero recibe el efectivo, el sistema imprime el comprobante y registra el pago con esa referencia. Este es el bloque que más casos tiene que manejar bien:

ResultadoQué hace la taquilla
200/201 con replayed: falsePago aplicado: imprime el recibo con la allocation y el saldo.
200 con replayed: trueYa estaba aplicado (doble clic o reintento): reimprime el mismo recibo.
409, title: Idempotency conflictEl intento anterior sigue en curso: reintenta en unos segundos con la misma llave.
409, title: Request rejected by engineREFERENCE_REUSED: ese comprobante ya se usó con otro monto. No reintentes: escala al supervisor.
422Monto inválido, mayor a la deuda (AMOUNT_EXCEEDS_DEBT), crédito saldado, cuota bloqueada… Muestra detail al cajero.
502/504 o red caídaReintenta con la misma llave y la misma referencia; nunca generes un comprobante nuevo para el mismo dinero.
from compense_gateway import IdempotencyConflictError, UpstreamError, ValidationError

comprobante = "HORIZONTE-TAQ-OFI01-2026-000981"

def registrar_taquilla(loan_id: str, documento: str, monto: str) -> dict:
    try:
        tx = client.loans.register_payment(
            loan_id, amount=monto, reference=comprobante, channel="CASH",
            value_date="2026-11-02", document=documento, idempotency_key=f"taq-{comprobante}",
        )
        return {"ok": True, "recibo": tx.data, "ya_estaba": tx.data.get("replayed") or tx.idempotent_replay}
    except IdempotencyConflictError as e:
        if e.title == "Idempotency conflict":
            return {"ok": False, "accion": "REINTENTAR_MISMA_LLAVE"}
        return {"ok": False, "accion": "ESCALAR", "motivo": e.detail, "trace_id": e.trace_id}  # REFERENCE_REUSED
    except ValidationError as e:
        return {"ok": False, "accion": "CORREGIR", "motivo": e.detail}
    except UpstreamError:
        return {"ok": False, "accion": "REINTENTAR_MISMA_LLAVE"}

print(registrar_taquilla(loan_id, "1000000003", "467009.88"))

El recibo trae la allocation por la prelación (iva, collection_fees, insurance, overdue_interest, current_interest, principal), las cuotas a las que se aplicó, el saldo de capital después y loan_status_after: lo que el afiliado necesita ver impreso.

3. Procesa el archivo de nómina (libranza) #

El archivo mensual de descuentos de nómina es un lote de recaudos con canal ACH_LIBRANZA. La referencia de cada fila es la que trae el archivo (pagaduría + período + empleado), así que reprocesar el archivo completo es seguro: las filas ya aplicadas vuelven con replayed: true.

import csv

resumen = {"aplicadas": 0, "ya_estaban": 0, "revisar": []}
with open("nomina-horizonte-2026-10.csv", newline="", encoding="utf-8") as fh:
    for fila in csv.DictReader(fh):
        ref = f'NOM-{fila["pagaduria"]}-2026-10-{fila["documento"]}'
        try:
            tx = client.loans.register_payment(
                fila["credito"], amount=fila["valor"], reference=ref, channel="ACH_LIBRANZA",
                value_date=fila["fecha_pago"], document=fila["documento"], idempotency_key=f"nom-{ref}",
            )
            resumen["ya_estaban" if tx.data.get("replayed") else "aplicadas"] += 1
        except (IdempotencyConflictError, ValidationError) as e:
            resumen["revisar"].append((ref, e.status, e.detail))
print(resumen)

La columna credito puede traer el loan_number (CRD-00123): el gateway acepta el UUID o el número. Envía siempre document para que un número de crédito mal digitado no termine abonado al préstamo de otra persona (sería un 404).

4. Abono extraordinario y pago total #

Cuando el afiliado quiere abonar a capital, cotiza primero y muéstrale el efecto antes de cobrar; si quiere saldar, cotiza el pago total del día.

# Abono a capital: cotizar, confirmar con el afiliado y registrar
cot = client.loans.quote_extra_payment(loan_id, amount="1000000", document="1000000003")
print(cot["allocation"], cot["outstanding_principal_after"], cot["scenarios"])
abono = client.loans.register_extra_payment(
    loan_id, amount="1000000", reference="HORIZONTE-TAQ-OFI01-2026-000982", mode="reduce_term",
    channel="CASH", document="1000000003", idempotency_key="taq-HORIZONTE-TAQ-OFI01-2026-000982")

# Pago total: el monto debe ser exactamente el cotizado hoy
total = client.loans.quote_payoff(loan_id, document="1000000003")["payoff_amount_today"]
paz = client.loans.register_payoff(
    loan_id, reference="HORIZONTE-TAQ-OFI01-2026-000983", amount=total, channel="CASH",
    document="1000000003", idempotency_key="taq-HORIZONTE-TAQ-OFI01-2026-000983")
print(paz.data["loan_status_after"], paz.data["paz_y_salvo"])

Si entre la cotización y el registro cambió el día (o se causó mora), el pago total responde 422 PAYOFF_AMOUNT_MISMATCH con el valor correcto en el mensaje: vuelve a cotizar y cobra la diferencia. Recuerda que hoy el mode del abono se registra pero el plan de cuotas no se regenera (ver Cartera).

5. Entrega el paz y salvo #

Cuando el crédito queda en cero (respuesta con paz_y_salvo: true o el webhook loan.paid_off), genera el certificado en PDF y entrégalo al afiliado. El pdf_url es firmado y temporal: descárgalo y archívalo en tu gestor documental en vez de guardar el enlace.

cert = client.loans.certificate(loan_id, document="1000000003", pdf=True)
if cert["paz_y_salvo"] and cert.get("pdf_url"):
    erp.archivar_pdf(loan_id, cert["pdf_url"])
else:
    print("Aún no está a paz y salvo o falló el PDF:", cert["status"], cert.get("pdf_error"))

El mismo certificado sin paz_y_salvo sirve como certificado de deuda a una fecha (as_of), el que piden los bancos para compras de cartera.

6. Cuadra la caja del día #

Al cierre, cada oficina compara su arqueo con lo registrado:

  • Webhooks: payment.received trae channel, reference, amount y value_date de cada recaudo; payment.applied trae cómo se distribuyó; loan.paid_off marca los créditos saldados. Cruza por resource.reference contra los comprobantes de la oficina. Ver Eventos.
  • Historial: client.loans.payments(loan_id, document=…) lista los recibos de cada préstamo con su referencia y estado, útil para auditar un caso puntual.
  • Un recaudo que la oficina cobró pero no aparece registrado se reenvía con su misma referencia: si ya estaba, vuelve con replayed: true; si no, se aplica.

En el sandbox #

  • 1000000001 tiene un préstamo al día, 1000000003 uno con la cuota 4 en mora (12 días) y 1000000004 uno en DEFAULTED con las cuotas 3 a 5 vencidas (prueba ahí el blocked_reason).
  • Las escrituras validan como el motor (AMOUNT_EXCEEDS_DEBT, PAYOFF_AMOUNT_MISMATCH, INVALID_CHANNEL, REFERENCE_REQUIRED…) pero no guardan estado: el saldo de las lecturas no cambia y REFERENCE_REUSED no se puede reproducir (la misma referencia siempre devuelve el mismo receipt_id). Prueba ese caso con un mock de tu lado.
  • pdf_url llega en null. Más en Datos de prueba.