Receta
Recaudo de cartera
Integra la taquilla y el archivo de nómina de la caja con la cartera: cotiza, registra cada pago con una referencia única, maneja REFERENCE_REUSED y el reintento idempotente, registra abonos extraordinarios y entrega el paz y salvo con su certificado PDF.
En esta página
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
endLa 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ón | Resultado |
|---|---|
Primera vez que llega HORIZONTE-TAQ-OFI01-2026-000981 | Se 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 monto | 409 REFERENCE_REUSED: alguien digitó mal o reutilizó un número. |
Reglas prácticas:
- Usa como
referenceel 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-Keyderivada 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"]}')const loans = await client.loans.list("1000000003", { status: "ACTIVE" });
const loan = loans.results[0];
const loanId = String(loan.loan_id);
const cuota = loan.next_installment as Record<string, unknown>;
console.log(loan.loan_number, loan.status, "cuota", cuota.number, cuota.pending_amount, "mora:", cuota.days_overdue);
const desglose = await client.loans.installmentBreakdown(loanId, Number(cuota.number), { document: "1000000003" });
for (const paso of desglose.waterfall as Array<Record<string, unknown>>) console.log(paso.concept, paso.amount);$loans = $client->loans->list('1000000003', status: 'ACTIVE');
$loan = $loans->results[0];
$loanId = $loan['loan_id'];
$cuota = $loan['next_installment'];
echo $loan['loan_number'], ' cuota ', $cuota['number'], ' ', $cuota['pending_amount'], ' mora ', $cuota['days_overdue'], PHP_EOL;
$desglose = $client->loans->installmentBreakdown($loanId, (int) $cuota['number'], document: '1000000003');
foreach ($desglose['waterfall'] as $paso) {
echo str_pad($paso['concept'], 20), ' ', $paso['amount'], PHP_EOL;
}Page loans = await client.Loans.ListAsync("1000000003", "ACTIVE");
var loan = loans.Results[0];
string loanId = loan["loan_id"]!.GetValue<string>();
var cuota = loan["next_installment"]!.AsObject();
int numero = cuota["number"]!.GetValue<int>();
Console.WriteLine($"{loan["loan_number"]} cuota {numero} {cuota["pending_amount"]} mora {cuota["days_overdue"]}");
var desglose = await client.Loans.InstallmentBreakdownAsync(loanId, numero, document: "1000000003");
foreach (var paso in desglose["waterfall"]!.AsArray())
{
Console.WriteLine($"{paso!["concept"],20} {paso["amount"]}");
}loans, err := client.Loans.List(ctx, "1000000003", &compense.LoanListParams{Status: "ACTIVE"})
if err != nil {
log.Fatal(err)
}
loan := loans.Results[0]
loanID := fmt.Sprint(loan["loan_id"])
cuota, _ := loan["next_installment"].(map[string]any)
numero, _ := cuota["number"].(json.Number).Int64() // el SDK decodifica los números como json.Number
fmt.Println(loan["loan_number"], "cuota", numero, cuota["pending_amount"], "mora", cuota["days_overdue"])
desglose, err := client.Loans.InstallmentBreakdown(ctx, loanID, int(numero),
&compense.InstallmentBreakdownParams{Document: "1000000003"})
if err != nil {
log.Fatal(err)
}
fmt.Println(desglose["waterfall"])curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/loans/?document=1000000003&status=ACTIVE" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq '.results[0] | {loan_id, loan_number, status, next_installment}'
curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/installments/4/breakdown/?document=1000000003" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq .waterfallSi 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:
| Resultado | Qué hace la taquilla |
|---|---|
200/201 con replayed: false | Pago aplicado: imprime el recibo con la allocation y el saldo. |
200 con replayed: true | Ya estaba aplicado (doble clic o reintento): reimprime el mismo recibo. |
409, title: Idempotency conflict | El intento anterior sigue en curso: reintenta en unos segundos con la misma llave. |
409, title: Request rejected by engine | REFERENCE_REUSED: ese comprobante ya se usó con otro monto. No reintentes: escala al supervisor. |
422 | Monto inválido, mayor a la deuda (AMOUNT_EXCEEDS_DEBT), crédito saldado, cuota bloqueada… Muestra detail al cajero. |
502/504 o red caída | Reintenta 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"))import { IdempotencyConflictError, UpstreamError, ValidationError } from "@compense/sdk";
const comprobante = "HORIZONTE-TAQ-OFI01-2026-000981";
async function registrarTaquilla(loanId: string, documento: string, monto: string) {
try {
const tx = await client.loans.registerPayment(loanId, {
amount: monto, reference: comprobante, channel: "CASH",
valueDate: "2026-11-02", document: documento, idempotencyKey: `taq-${comprobante}`,
});
return { ok: true, recibo: tx.data, yaEstaba: Boolean(tx.data.replayed) || tx.idempotentReplay };
} catch (err) {
if (err instanceof IdempotencyConflictError) {
return err.title === "Idempotency conflict"
? { ok: false, accion: "REINTENTAR_MISMA_LLAVE" }
: { ok: false, accion: "ESCALAR", motivo: err.detail, traceId: err.traceId };
}
if (err instanceof ValidationError) return { ok: false, accion: "CORREGIR", motivo: err.detail };
if (err instanceof UpstreamError) return { ok: false, accion: "REINTENTAR_MISMA_LLAVE" };
throw err;
}
}
console.log(await registrarTaquilla(loanId, "1000000003", "467009.88"));use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\UpstreamException;
use Compense\Sdk\Exception\ValidationException;
$comprobante = 'HORIZONTE-TAQ-OFI01-2026-000981';
try {
$tx = $client->loans->registerPayment(
$loanId, amount: '467009.88', reference: $comprobante, channel: 'CASH',
valueDate: '2026-11-02', document: '1000000003', idempotencyKey: "taq-{$comprobante}",
);
$resultado = ['ok' => true, 'recibo' => $tx->data, 'ya_estaba' => ($tx->data['replayed'] ?? false) || $tx->idempotentReplay];
} catch (IdempotencyConflictException $e) {
$resultado = $e->title === 'Idempotency conflict'
? ['ok' => false, 'accion' => 'REINTENTAR_MISMA_LLAVE']
: ['ok' => false, 'accion' => 'ESCALAR', 'motivo' => $e->detail, 'trace_id' => $e->traceId];
} catch (ValidationException $e) {
$resultado = ['ok' => false, 'accion' => 'CORREGIR', 'motivo' => $e->detail];
} catch (UpstreamException) {
$resultado = ['ok' => false, 'accion' => 'REINTENTAR_MISMA_LLAVE'];
}
print_r($resultado);const string comprobante = "HORIZONTE-TAQ-OFI01-2026-000981";
try
{
Transaction tx = await client.Loans.RegisterPaymentAsync(
loanId, amount: "467009.88", reference: comprobante, channel: "CASH",
valueDate: "2026-11-02", document: "1000000003", idempotencyKey: $"taq-{comprobante}");
bool yaEstaba = (tx.Data["replayed"]?.GetValue<bool>() ?? false) || tx.IdempotentReplay;
Console.WriteLine($"Aplicado. Saldo: {tx.Data["outstanding_principal_after"]} (ya estaba: {yaEstaba})");
}
catch (IdempotencyConflictException e) when (e.Title == "Idempotency conflict")
{
Console.WriteLine("REINTENTAR con la misma llave");
}
catch (IdempotencyConflictException e)
{
Console.WriteLine($"ESCALAR (comprobante reutilizado): {e.Detail} {e.TraceId}");
}
catch (ValidationException e)
{
Console.WriteLine($"CORREGIR: {e.Detail}");
}
catch (UpstreamException)
{
Console.WriteLine("REINTENTAR con la misma llave");
}comprobante := "HORIZONTE-TAQ-OFI01-2026-000981"
tx, err := client.Loans.RegisterPayment(ctx, loanID, &compense.LoanPaymentParams{
Amount: "467009.88", Reference: comprobante, Channel: "CASH",
ValueDate: "2026-11-02", Document: "1000000003",
}, compense.WithIdempotencyKey("taq-"+comprobante))
var apiErr *compense.APIError
errors.As(err, &apiErr)
switch {
case err == nil:
fmt.Println("Aplicado. Saldo:", tx.Data["outstanding_principal_after"], "replayed:", tx.Data["replayed"])
case compense.IsIdempotencyConflict(err) && apiErr.Title == "Idempotency conflict",
compense.IsUpstreamError(err):
fmt.Println("REINTENTAR con la misma llave")
case compense.IsIdempotencyConflict(err):
fmt.Println("ESCALAR (comprobante reutilizado):", apiErr.Detail, apiErr.TraceID)
case compense.IsValidationError(err):
fmt.Println("CORREGIR:", apiErr.Detail)
default:
log.Fatal(err)
}COMPROBANTE="HORIZONTE-TAQ-OFI01-2026-000981"
curl -s -w '\nHTTP %{http_code}\n' -X POST \
"https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payments/" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: taq-$COMPROBANTE" \
-H "Content-Type: application/json" \
-d '{"document": "1000000003", "amount": "467009.88", "channel": "CASH",
"reference": "'"$COMPROBANTE"'", "value_date": "2026-11-02"}'
# 409 con "title": "Request rejected by engine" → comprobante reutilizado con otro montoEl 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)import { readFile } from "node:fs/promises";
const resumen = { aplicadas: 0, yaEstaban: 0, revisar: [] as unknown[] };
const [, ...filas] = (await readFile("nomina-horizonte-2026-10.csv", "utf8")).trim().split("\n");
for (const linea of filas) {
const [credito, documento, pagaduria, valor, fechaPago] = linea.split(",");
const ref = `NOM-${pagaduria}-2026-10-${documento}`;
try {
const tx = await client.loans.registerPayment(credito, {
amount: valor, reference: ref, channel: "ACH_LIBRANZA",
valueDate: fechaPago, document: documento, idempotencyKey: `nom-${ref}`,
});
if (tx.data.replayed) resumen.yaEstaban++; else resumen.aplicadas++;
} catch (err) {
if (err instanceof IdempotencyConflictError || err instanceof ValidationError) resumen.revisar.push([ref, err.status, err.detail]);
else throw err;
}
}
console.log(resumen);$resumen = ['aplicadas' => 0, 'ya_estaban' => 0, 'revisar' => []];
$fh = fopen('nomina-horizonte-2026-10.csv', 'r');
$cabeceras = fgetcsv($fh);
while (($fila = fgetcsv($fh)) !== false) {
$f = array_combine($cabeceras, $fila);
$ref = "NOM-{$f['pagaduria']}-2026-10-{$f['documento']}";
try {
$tx = $client->loans->registerPayment(
$f['credito'], amount: $f['valor'], reference: $ref, channel: 'ACH_LIBRANZA',
valueDate: $f['fecha_pago'], document: $f['documento'], idempotencyKey: "nom-{$ref}",
);
($tx->data['replayed'] ?? false) ? $resumen['ya_estaban']++ : $resumen['aplicadas']++;
} catch (IdempotencyConflictException | ValidationException $e) {
$resumen['revisar'][] = [$ref, $e->status, $e->detail];
}
}
print_r($resumen);int aplicadas = 0, yaEstaban = 0;
var revisar = new List<string>();
foreach (var linea in File.ReadLines("nomina-horizonte-2026-10.csv").Skip(1))
{
var c = linea.Split(','); // credito,documento,pagaduria,valor,fecha_pago
string referencia = $"NOM-{c[2]}-2026-10-{c[1]}";
try
{
Transaction tx = await client.Loans.RegisterPaymentAsync(
c[0], amount: c[3], reference: referencia, channel: "ACH_LIBRANZA",
valueDate: c[4], document: c[1], idempotencyKey: $"nom-{referencia}");
if (tx.Data["replayed"]?.GetValue<bool>() == true) yaEstaban++; else aplicadas++;
}
catch (CompenseApiException e) when (e is IdempotencyConflictException or ValidationException)
{
revisar.Add($"{referencia} {e.Status} {e.Detail}");
}
}
Console.WriteLine($"aplicadas={aplicadas} ya_estaban={yaEstaban} revisar={revisar.Count}");f, err := os.Open("nomina-horizonte-2026-10.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
filas, err := csv.NewReader(f).ReadAll() // credito,documento,pagaduria,valor,fecha_pago
if err != nil {
log.Fatal(err)
}
aplicadas, yaEstaban := 0, 0
var revisar []string
for _, c := range filas[1:] {
ref := fmt.Sprintf("NOM-%s-2026-10-%s", c[2], c[1])
tx, err := client.Loans.RegisterPayment(ctx, c[0], &compense.LoanPaymentParams{
Amount: compense.Amount(c[3]), Reference: ref, Channel: "ACH_LIBRANZA",
ValueDate: c[4], Document: c[1],
}, compense.WithIdempotencyKey("nom-"+ref))
switch {
case compense.IsIdempotencyConflict(err), compense.IsValidationError(err):
revisar = append(revisar, fmt.Sprint(ref, " ", err))
case err != nil:
log.Fatal(err)
case tx.Data["replayed"] == true:
yaEstaban++
default:
aplicadas++
}
}
fmt.Println("aplicadas", aplicadas, "ya estaban", yaEstaban, "revisar", len(revisar))tail -n +2 nomina-horizonte-2026-10.csv | while IFS=, read -r CREDITO DOC PAGADURIA VALOR FECHA; do
REF="NOM-$PAGADURIA-2026-10-$DOC"
curl -s -o /dev/null -w "$REF %{http_code}\n" -X POST \
"https://sandbox.dev.compense.co/gw/v1/gateway/loans/$CREDITO/payments/" \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Idempotency-Key: nom-$REF" \
-H "Content-Type: application/json" \
-d '{"document": "'"$DOC"'", "amount": "'"$VALOR"'", "channel": "ACH_LIBRANZA",
"reference": "'"$REF"'", "value_date": "'"$FECHA"'"}'
doneLa 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"])const cot = await client.loans.quoteExtraPayment(loanId, { amount: "1000000", document: "1000000003" });
console.log(cot.allocation, cot.outstanding_principal_after, cot.scenarios);
await client.loans.registerExtraPayment(loanId, {
amount: "1000000", reference: "HORIZONTE-TAQ-OFI01-2026-000982", mode: "reduce_term",
channel: "CASH", document: "1000000003", idempotencyKey: "taq-HORIZONTE-TAQ-OFI01-2026-000982",
});
const { payoff_amount_today } = await client.loans.quotePayoff(loanId, { document: "1000000003" });
const paz = await client.loans.registerPayoff(loanId, {
reference: "HORIZONTE-TAQ-OFI01-2026-000983", amount: String(payoff_amount_today), channel: "CASH",
document: "1000000003", idempotencyKey: "taq-HORIZONTE-TAQ-OFI01-2026-000983",
});
console.log(paz.data.loan_status_after, paz.data.paz_y_salvo);$cot = $client->loans->quoteExtraPayment($loanId, amount: '1000000', document: '1000000003');
$client->loans->registerExtraPayment(
$loanId, amount: '1000000', reference: 'HORIZONTE-TAQ-OFI01-2026-000982', mode: 'reduce_term',
channel: 'CASH', document: '1000000003', idempotencyKey: 'taq-HORIZONTE-TAQ-OFI01-2026-000982');
$total = $client->loans->quotePayoff($loanId, document: '1000000003')['payoff_amount_today'];
$paz = $client->loans->registerPayoff(
$loanId, reference: 'HORIZONTE-TAQ-OFI01-2026-000983', amount: $total, channel: 'CASH',
document: '1000000003', idempotencyKey: 'taq-HORIZONTE-TAQ-OFI01-2026-000983');
echo $paz->data['loan_status_after'], PHP_EOL;var cot = await client.Loans.QuoteExtraPaymentAsync(loanId, "1000000", "1000000003");
await client.Loans.RegisterExtraPaymentAsync(
loanId, "1000000", "HORIZONTE-TAQ-OFI01-2026-000982", mode: "reduce_term", channel: "CASH",
document: "1000000003", idempotencyKey: "taq-HORIZONTE-TAQ-OFI01-2026-000982");
var cotTotal = await client.Loans.QuotePayoffAsync(loanId, "1000000003");
Transaction paz = await client.Loans.RegisterPayoffAsync(
loanId, "HORIZONTE-TAQ-OFI01-2026-000983", cotTotal["payoff_amount_today"]!.GetValue<string>(),
channel: "CASH", document: "1000000003", idempotencyKey: "taq-HORIZONTE-TAQ-OFI01-2026-000983");
Console.WriteLine($"{paz.Data["loan_status_after"]} {paz.Data["paz_y_salvo"]}");cot, err := client.Loans.QuoteExtraPayment(ctx, loanID,
&compense.ExtraPaymentQuoteParams{Amount: "1000000", Document: "1000000003"})
if err != nil {
log.Fatal(err)
}
fmt.Println(cot["allocation"], cot["outstanding_principal_after"])
_, err = client.Loans.RegisterExtraPayment(ctx, loanID, &compense.ExtraPaymentParams{
Amount: "1000000", Reference: "HORIZONTE-TAQ-OFI01-2026-000982", Mode: "reduce_term",
Channel: "CASH", Document: "1000000003",
}, compense.WithIdempotencyKey("taq-HORIZONTE-TAQ-OFI01-2026-000982"))
if err != nil {
log.Fatal(err)
}
cotTotal, err := client.Loans.QuotePayoff(ctx, loanID, &compense.LoanParams{Document: "1000000003"})
if err != nil {
log.Fatal(err)
}
paz, err := client.Loans.RegisterPayoff(ctx, loanID, &compense.PayoffParams{
Reference: "HORIZONTE-TAQ-OFI01-2026-000983",
Amount: compense.Amount(fmt.Sprint(cotTotal["payoff_amount_today"])),
Channel: "CASH", Document: "1000000003",
}, compense.WithIdempotencyKey("taq-HORIZONTE-TAQ-OFI01-2026-000983"))
if err != nil {
log.Fatal(err)
}
fmt.Println(paz.Data["loan_status_after"], paz.Data["paz_y_salvo"])TOTAL=$(curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payoff/quote/?document=1000000003" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq -r .payoff_amount_today)
curl -X POST "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payoff/" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: taq-HORIZONTE-TAQ-OFI01-2026-000983" \
-H "Content-Type: application/json" \
-d '{"document": "1000000003", "amount": "'"$TOTAL"'", "channel": "CASH",
"reference": "HORIZONTE-TAQ-OFI01-2026-000983"}'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"))const cert = await client.loans.certificate(loanId, { document: "1000000003", pdf: true });
if (cert.paz_y_salvo && cert.pdf_url) await erp.archivarPdf(loanId, String(cert.pdf_url));
else console.log("Aún no está a paz y salvo o falló el PDF:", cert.status, cert.pdf_error);$cert = $client->loans->certificate($loanId, document: '1000000003', pdf: true);
if (($cert['paz_y_salvo'] ?? false) && !empty($cert['pdf_url'])) {
$erp->archivarPdf($loanId, $cert['pdf_url']);
}var cert = await client.Loans.CertificateAsync(loanId, "1000000003", pdf: true);
bool paz = cert["paz_y_salvo"]?.GetValue<bool>() ?? false;
string? pdf = cert["pdf_url"]?.GetValue<string>();
if (paz && pdf is not null) await erp.ArchivarPdfAsync(loanId, pdf);cert, err := client.Loans.Certificate(ctx, loanID, &compense.CertificateParams{
Document: "1000000003", PDF: compense.Bool(true),
})
if err != nil {
log.Fatal(err)
}
if pdf, ok := cert["pdf_url"].(string); ok && cert["paz_y_salvo"] == true {
erp.ArchivarPDF(loanID, pdf)
}curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/certificate/?document=1000000003&pdf=true" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq '{status, paz_y_salvo, total_debt, pdf_url}'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.receivedtraechannel,reference,amountyvalue_datede cada recaudo;payment.appliedtrae cómo se distribuyó;loan.paid_offmarca los créditos saldados. Cruza porresource.referencecontra 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 #
1000000001tiene un préstamo al día,1000000003uno con la cuota 4 en mora (12 días) y1000000004uno enDEFAULTEDcon las cuotas 3 a 5 vencidas (prueba ahí elblocked_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 yREFERENCE_REUSEDno se puede reproducir (la misma referencia siempre devuelve el mismoreceipt_id). Prueba ese caso con un mock de tu lado. pdf_urlllega ennull. Más en Datos de prueba.