At the Caja Horizonte branch offices, affiliates pay their loan installments in cash, and every month the payroll deduction file (libranza) arrives as well. All that money comes in outside the platform, so the family compensation fund (caja de compensación) has to report it to Compense to keep the balance, delinquency, the affiliate’s app, and the paid-in-full certificate (paz y salvo) up to date. This recipe connects the teller system and the payroll file process to the Loans module.

You need one key with loans:read for lookups at the teller window and another with loans:collect to record payments (keep them separate), plus an endpoint subscribed to payment.received, payment.applied, and loan.paid_off.

The teller window flow #

sequenceDiagram
  autonumber
  actor A as Affiliate
  participant T as Teller (Caja Horizonte)
  participant G as Compense Gateway
  A->>T: "I'm here to pay my loan" (ID number)
  T->>G: loans.list(document) and installment_breakdown
  G-->>T: loans, next installment, breakdown
  A->>T: hands over the cash
  Note over T: prints receipt HORIZONTE-TAQ-OFI01-…
  T->>G: register_payment(reference=receipt, Idempotency-Key)
  G-->>T: receipt: allocation, balance after, paz_y_salvo
  G--)T: webhooks payment.received / payment.applied
  alt loan paid off
    G--)T: webhook loan.paid_off
    T->>G: certificate(pdf=true)
    T-->>A: paid-in-full certificate as PDF
  end

The reference: your receipt is the key #

The whole design rests on one idea: the receipt number is the natural key of the payment. Compense identifies each collection by loan + channel + reference:

SituationResult
HORIZONTE-TAQ-OFI01-2026-000981 arrives for the first timeThe payment is applied: 201, replayed: false.
It arrives again with the same amount (double click, retry, reprocessing)Same receipt, replayed: true; it is not applied twice and webhooks are not re-sent.
It arrives again with a different amount409 REFERENCE_REUSED: someone mistyped or reused a number.

Practical rules:

  • Use the printed receipt number that the office keeps as the reference (max. 100 characters) and make it unique per channel: HORIZONTE-TAQ-<office>-<year>-<sequence> for the teller window, and the bank or payroll reference for files.
  • Also use an Idempotency-Key derived from the same reference (taq-<reference>). The two layers complement each other: the key protects the immediate retry at the edge; the reference protects you even if the key changes (for example, when you reprocess an entire file on another day).

1. Look up at the teller window: how much is owed and how it would be applied #

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"], "installment", cuota["number"], cuota["pending_amount"],
      "overdue:", cuota["days_overdue"], "days")

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

If the affiliate has an older overdue installment, the breakdown of the following ones comes back with payable: false and blocked_reason: the teller must collect the overdue one first. For “how much to be paid in full today?”, use the payoff quote (step 4).

2. Record the teller payment #

The teller receives the cash, the system prints the receipt, and it records the payment with that reference. This is the block with the most cases to handle correctly:

ResultWhat the teller system does
200/201 with replayed: falsePayment applied: print the receipt with the allocation and the balance.
200 with replayed: trueIt was already applied (double click or retry): reprint the same receipt.
409, title: Idempotency conflictThe previous attempt is still in progress: retry in a few seconds with the same key.
409, title: Request rejected by engineREFERENCE_REUSED: that receipt was already used with a different amount. Don’t retry: escalate to the supervisor.
422Invalid amount, more than the debt (AMOUNT_EXCEEDS_DEBT), loan already paid off, blocked installment… Show detail to the teller.
502/504 or network downRetry with the same key and the same reference; never generate a new receipt for the same money.
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"))

The receipt includes the allocation following the payment priority order (iva, collection_fees, insurance, overdue_interest, current_interest, principal), the installments it was applied to, the outstanding principal afterwards, and loan_status_after: what the affiliate needs to see printed.

3. Process the payroll deduction file (libranza) #

The monthly payroll deduction file is a batch of collections on the ACH_LIBRANZA channel. Each row’s reference is the one the file carries (paying employer + period + employee), so reprocessing the whole file is safe: rows already applied come back with 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)

The credito column may contain the loan_number (CRD-00123): the gateway accepts either the UUID or the number. Always send document so that a mistyped loan number doesn’t end up credited to someone else’s loan (it would be a 404).

4. Extra payment and full payoff #

When the affiliate wants to pay down principal, quote first and show them the effect before you collect; if they want to pay off the loan, quote today’s payoff amount.

# Principal prepayment: quote, confirm with the affiliate, and record
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")

# Full payoff: the amount must be exactly today's quote
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"])

If the day changed between the quote and the payment (or late interest accrued), the payoff responds 422 PAYOFF_AMOUNT_MISMATCH with the correct amount in the message: quote again and collect the difference. Keep in mind that today the extra payment’s mode is recorded but the installment schedule is not regenerated (see Loans).

5. Hand over the paid-in-full certificate #

When the loan reaches zero (a response with paz_y_salvo: true or the loan.paid_off webhook), generate the PDF certificate and give it to the affiliate. The pdf_url is signed and temporary: download it and file it in your document management system instead of storing the link.

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("Not paid in full yet, or the PDF failed:", cert["status"], cert.get("pdf_error"))

The same certificate without paz_y_salvo works as a debt certificate as of a given date (as_of), the one banks ask for when buying out a loan.

6. Reconcile the day’s cash #

At closing, each office compares its cash count with what was recorded:

  • Webhooks: payment.received carries the channel, reference, amount, and value_date of each collection; payment.applied carries how it was allocated; loan.paid_off flags loans paid off. Match on resource.reference against the office’s receipts. See Events.
  • History: client.loans.payments(loan_id, document=…) lists each loan’s receipts with their reference and status, useful for auditing a specific case.
  • A collection that the office took in but that doesn’t show up as recorded is re-sent with its same reference: if it was already there, it comes back with replayed: true; if not, it is applied.

In the sandbox #

  • 1000000001 has a loan that is current, 1000000003 one with installment 4 overdue (12 days), and 1000000004 one in DEFAULTED with installments 3 to 5 past due (test blocked_reason there).
  • Writes validate the way the engine does (AMOUNT_EXCEEDS_DEBT, PAYOFF_AMOUNT_MISMATCH, INVALID_CHANNEL, REFERENCE_REQUIRED…) but don’t keep state: the balance returned by reads doesn’t change and REFERENCE_REUSED can’t be reproduced (the same reference always returns the same receipt_id). Test that case with a mock on your side.
  • pdf_url comes back as null. More in Test data.