Receta
Cuota monetaria del período
Cierra el mes de cuota monetaria: simula con dry_run, revisa, genera en firme, lista todas las cuotas paginando y desembolsa cada una con su Idempotency-Key estable, con reintentos seguros y el webhook monetary_allowance.disbursed.
En esta página
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 listadoLa 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)const sim = await client.monetaryAllowance.generate({
period: "2026-10", dryRun: true, idempotencyKey: "horizonte-cuota-2026-10-simulacion-1",
});
console.log(sim.transactionId, sim.data);$sim = $client->monetaryAllowance->generate(
period: '2026-10', dryRun: true, idempotencyKey: 'horizonte-cuota-2026-10-simulacion-1');
print_r($sim->data);Transaction sim = await client.MonetaryAllowance.GenerateAsync(
"2026-10", dryRun: true, idempotencyKey: "horizonte-cuota-2026-10-simulacion-1");
Console.WriteLine(sim.Data.ToJsonString());sim, err := client.MonetaryAllowance.Generate(ctx,
&compense.MonetaryAllowanceGenerateParams{Period: "2026-10", DryRun: true},
compense.WithIdempotencyKey("horizonte-cuota-2026-10-simulacion-1"))
if err != nil {
log.Fatal(err)
}
fmt.Println(sim.TransactionID, sim.Data)curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/subsidies/monetary-allowance/generate/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cuota-2026-10-simulacion-1" \
-H "Content-Type: application/json" \
-d '{"period": "2026-10", "dry_run": true}'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)const gen = await client.monetaryAllowance.generate({
period: "2026-10", idempotencyKey: "horizonte-cuota-2026-10-generacion",
});
console.log(gen.transactionId, gen.idempotentReplay, gen.data);$gen = $client->monetaryAllowance->generate(
period: '2026-10', idempotencyKey: 'horizonte-cuota-2026-10-generacion');
echo $gen->transactionId, ' ', var_export($gen->idempotentReplay, true), PHP_EOL;Transaction gen = await client.MonetaryAllowance.GenerateAsync(
"2026-10", idempotencyKey: "horizonte-cuota-2026-10-generacion");
Console.WriteLine($"{gen.TransactionId} {gen.IdempotentReplay} {gen.Data.ToJsonString()}");gen, err := client.MonetaryAllowance.Generate(ctx,
&compense.MonetaryAllowanceGenerateParams{Period: "2026-10"},
compense.WithIdempotencyKey("horizonte-cuota-2026-10-generacion"))
if err != nil {
log.Fatal(err)
}
fmt.Println(gen.TransactionID, gen.IdempotentReplay, gen.Data)curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/subsidies/monetary-allowance/generate/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cuota-2026-10-generacion" \
-H "Content-Type: application/json" \
-d '{"period": "2026-10", "dry_run": false}'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])const pendientes: Array<Record<string, unknown>> = [];
const first = await client.monetaryAllowance.list({ period: "2026-10", status: "pending", pageSize: 100 });
for await (const row of first) pendientes.push(row); // recorre todas las páginas
console.log(pendientes.length, "cuotas por pagar");
await erp.guardarLote("2026-10", pendientes.map((c) => [String(c.disbursement_id), String(c.amount)]));$pendientes = [];
$pageNo = 1;
do {
$page = $client->monetaryAllowance->list(period: '2026-10', page: $pageNo, pageSize: 100, status: 'pending');
array_push($pendientes, ...$page->results);
$pageNo++;
} while ($page->hasNext());
echo count($pendientes), " cuotas por pagar\n";
$erp->guardarLote('2026-10', $pendientes);var pendientes = new List<JsonObject>();
int pageNo = 1;
Page page;
do
{
page = await client.MonetaryAllowance.ListAsync("2026-10", pageNo, 100, "pending");
pendientes.AddRange(page.Results);
pageNo++;
} while (page.Next is not null);
Console.WriteLine($"{pendientes.Count} cuotas por pagar");
await erp.GuardarLoteAsync("2026-10", pendientes);it := compense.NewPageIterator(ctx, func(ctx context.Context, page int) (*compense.Page, error) {
return client.MonetaryAllowance.List(ctx, &compense.MonetaryAllowanceListParams{
Period: "2026-10", Status: "pending", Page: page, PageSize: 100,
})
})
var pendientes []map[string]any
for it.Next() {
pendientes = append(pendientes, it.Item())
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
fmt.Println(len(pendientes), "cuotas por pagar")
erp.GuardarLote("2026-10", pendientes)PAGE=1
: > pendientes.txt
while :; do
RESP=$(curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/subsidies/monetary-allowance/?period=2026-10&status=pending&page=$PAGE&page_size=100" \
-H "X-API-Key: $COMPENSE_API_KEY")
echo "$RESP" | jq -r '.results[].disbursement_id' >> pendientes.txt
[ "$(echo "$RESP" | jq -r .next)" = "null" ] && break
PAGE=$((PAGE + 1))
done
wc -l pendientes.txtEn .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
409contitle: Idempotency conflictsignifica que el intento anterior sigue en curso: espera y reintenta con la misma llave. - Cualquier otro
4xx(por ejemplo400porque el afiliado no tiene medio de pago, o409porque la cuota ya no está pendiente) no se reintenta: márcala para revisión manual. - El
event_iddel 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"]))import { CompenseAPIError, IdempotencyConflictError, RateLimitError, UpstreamError } from "@compense/sdk";
const dormir = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function pagar(cuotaId: string): Promise<string> {
const idempotencyKey = `horizonte-cuota-${cuotaId}`;
for (let intento = 0; intento < 5; intento++) {
try {
await client.monetaryAllowance.disburse(cuotaId, { idempotencyKey });
return "PAGADA";
} catch (err) {
if (err instanceof UpstreamError || err instanceof RateLimitError) await dormir(2 ** intento * 1000);
else if (err instanceof IdempotencyConflictError && err.title === "Idempotency conflict") await dormir(2 ** intento * 1000);
else if (err instanceof CompenseAPIError) return `REVISAR: ${err.status} ${err.detail}`;
else throw err;
}
}
return "REINTENTAR_DESPUES";
}
for (const c of pendientes) {
await erp.actualizarCuota(String(c.disbursement_id), await pagar(String(c.disbursement_id)));
}use Compense\Sdk\Exception\CompenseApiException;
use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\RateLimitException;
use Compense\Sdk\Exception\UpstreamException;
function pagar(\Compense\Sdk\CompenseClient $client, string $cuotaId): string
{
$llave = "horizonte-cuota-{$cuotaId}";
for ($intento = 0; $intento < 5; $intento++) {
try {
$client->monetaryAllowance->disburse($cuotaId, idempotencyKey: $llave);
return 'PAGADA';
} catch (UpstreamException | RateLimitException) {
sleep(2 ** $intento);
} catch (IdempotencyConflictException $e) {
if ($e->title !== 'Idempotency conflict') {
return "REVISAR: {$e->detail}";
}
sleep(2 ** $intento);
} catch (CompenseApiException $e) {
return "REVISAR: {$e->status} {$e->detail}";
}
}
return 'REINTENTAR_DESPUES';
}
foreach ($pendientes as $c) {
$erp->actualizarCuota($c['disbursement_id'], pagar($client, $c['disbursement_id']));
}async Task<string> PagarAsync(string cuotaId)
{
string llave = $"horizonte-cuota-{cuotaId}";
for (int intento = 0; intento < 5; intento++)
{
try
{
await client.MonetaryAllowance.DisburseAsync(cuotaId, idempotencyKey: llave);
return "PAGADA";
}
catch (Exception e) when (e is UpstreamException or RateLimitException
|| (e is IdempotencyConflictException c && c.Title == "Idempotency conflict"))
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, intento))); // misma llave
}
catch (CompenseApiException e)
{
return $"REVISAR: {e.Status} {e.Detail}";
}
}
return "REINTENTAR_DESPUES";
}
foreach (var c in pendientes)
{
string id = c["disbursement_id"]!.GetValue<string>();
await erp.ActualizarCuotaAsync(id, await PagarAsync(id));
}pagar := func(cuotaID string) string {
llave := compense.WithIdempotencyKey("horizonte-cuota-" + cuotaID)
for intento := 0; intento < 5; intento++ {
_, err := client.MonetaryAllowance.Disburse(ctx, cuotaID, llave)
var apiErr *compense.APIError
switch {
case err == nil:
return "PAGADA"
case compense.IsUpstreamError(err), compense.IsRateLimited(err):
time.Sleep(time.Duration(1<<intento) * time.Second) // transitorio: misma llave
case compense.IsIdempotencyConflict(err) && errors.As(err, &apiErr) && apiErr.Title == "Idempotency conflict":
time.Sleep(time.Duration(1<<intento) * time.Second) // sigue en curso: misma llave
case errors.As(err, &apiErr):
return fmt.Sprintf("REVISAR: %d %s", apiErr.StatusCode, apiErr.Detail)
default:
time.Sleep(time.Duration(1<<intento) * time.Second) // red: misma llave
}
}
return "REINTENTAR_DESPUES"
}
for _, c := range pendientes {
id := fmt.Sprint(c["disbursement_id"])
erp.ActualizarCuota(id, pagar(id))
}while read -r ID; do
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
"https://sandbox.dev.compense.co/gw/v1/gateway/subsidies/monetary-allowance/$ID/disburse/" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cuota-$ID")
echo "$ID $CODE" # 200 = pagada (o replay); 5xx/429 = reintenta con la MISMA llave
done < pendientes.txtRelanzar 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).
{
"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):
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)if (ev.event === "monetary_allowance.disbursed") {
const r = ev.resource as Record<string, string>;
await erp.marcarCuotaPagada(r.allowance_id, r.amount, r.disbursed_at, ev.eventId);
}if ($ev->event() === 'monetary_allowance.disbursed') {
$r = $ev->resource();
$erp->marcarCuotaPagada($r['allowance_id'], $r['amount'], $r['disbursed_at'], $ev->eventId);
}if (ev.Event == "monetary_allowance.disbursed")
{
var r = ev.Resource;
await erp.MarcarCuotaPagadaAsync(r["allowance_id"]!.GetValue<string>(),
r["amount"]!.GetValue<string>(), r["disbursed_at"]!.GetValue<string>(), ev.EventId);
}if ev.Event() == "monetary_allowance.disbursed" {
r := ev.Resource()
erp.MarcarCuotaPagada(ctx, fmt.Sprint(r["allowance_id"]), fmt.Sprint(r["amount"]), fmt.Sprint(r["disbursed_at"]), ev.ID)
}# Consulta tus entregas de este evento (p. ej. para ver si alguna falló):
curl "https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/deliveries/?event_type=monetary_allowance.disbursed" \
-H "X-API-Key: $COMPENSE_API_KEY"6. Cierra el período #
El período está cerrado cuando:
list(period="2026-10", status="pending")devuelvecount: 0, y- el número y el total de cuotas
PAGADAen tu ERP coinciden con los eventosmonetary_allowance.disbursedrecibidos y conlist(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 #
generaterespondeeligible: true,amount: 50000y un idmad-E-…(conextra={"document_number": "1000000002"}verás unNOT_ELIGIBLE).listdevuelve siempre una cuota de muestra del período en estadoGENERATED(el sandbox no guarda estado, así que no filtra porpending): para probar el bucle de pago, usa eldisbursement_idde esa fila o el que devolviógenerate.disbursede un idmad-E-…respondeDISBURSED; uno de un perfil no elegible (mad-I-…,mad-D-…),409.- Repetir
disbursecon la misma llave devuelveidempotent_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.