VerifikaDocs
Guías

Cuentas por cobrar

El cuaderno de fiados por API — crear clientes que deben, registrar abonos sin duplicarlos y saldar cuentas.

Un negocio de barrio fía: «Gloria se lleva $300.000 de mercado y me va abonando». Casi siempre eso vive en un cuaderno. Cuentas por cobrar es ese cuaderno dentro de Verifika: una cuenta por cliente, con su saldo, y un historial de movimientos — fiados, abonos y condonaciones. El saldo se calcula solo, y la dueña lo ve en el panel como un tablero por etapas.

La API es para que ese cuaderno se llene solo desde donde ya ocurren las cosas:

Si tienes…La usas para…
Un POS o una cajaAnotar el fiado cuando el cliente se lleva algo sin pagar, y el abono cuando vuelve.
Un ERP o software contableSincronizar la cartera: leer saldos, vencidos y el historial para cuadrar el mes.
Un bot de cobranzaSacar la lista de vencidos, escribirles por WhatsApp y registrar el abono cuando pagan.

Todo cuelga de /v1/receivables. La referencia completa está en Cuentas por cobrar.

Permisos: son cuatro, no dos

El módulo es una función del plan (receivables_module). Sin ella, cualquier llamada responde 403 PLAN_FEATURE_REQUIRED. Con ella, la llave necesita además el permiso de cada cosa:

PermisoQué abre
receivables.readLeer clientes, saldos, el resumen y el historial.
receivables.writeCrear y editar clientes, registrar fiados y abonos, saldar pagando, archivar y restaurar.
receivables.write_offCondonar: perdonar una parte o todo el saldo.
receivables.voidAnular movimientos.

¿Por qué no un simple leer/escribir? Porque la integración típica es una caja, y la llave de una caja no debería poder perdonar deudas ni hacer desaparecer historia. Si se filtra una llave con receivables.write, lo peor que deja es un movimiento de más, que una persona anula desde el panel. Con write_off o void se puede borrar lo que un cliente debe. Dale esos dos solo a quien los necesite.

No hay un permiso de exportar: leer por API ya es llevarse los datos. El Excel y el PDF son para personas, en el panel.

Tampoco hay alcance por sucursal: el cuaderno es del negocio, y una llave acotada a una sucursal lo ve entero. branchId al crear un cliente es informativo.

Todo lo que escribes queda firmado como API

Clientes y movimientos creados con una llave llevan source: "API" y createdBy: null: detrás de una llave no hay una persona. El rastro de quién fue está en la llave, que sí tiene dueño. El estado de cuenta del panel los muestra como «API».

Las etapas se mueven solas

Cada cliente está en una columna del tablero (stage):

stageEn el panelCómo se llega
OPENPor cobrarCliente nuevo con deuda, o un fiado nuevo sobre una cuenta que ya estaba saldada.
PARTIALAbonandoSolo, con el primer abono mientras quede saldo.
FOLLOW_UPEn seguimientoA mano (PATCH { "stage": "FOLLOW_UP" }): «hay que llamarlo». No se mueve hasta que salde.
PAIDSaldadaSolo, cuando el saldo llega a cero.

Después de cada movimiento o anulación se aplican estas reglas, en orden:

  1. Saldo en cero → PAID.
  2. Saldo mayor que cero y estaba PAID → vuelve a OPEN.
  3. Un abono sobre OPEN → PARTIAL.
  4. FOLLOW_UP se respeta hasta que el saldo llegue a cero.
  5. Anular el único abono de una cuenta PARTIAL la devuelve a OPEN.

Dos cosas que no puedes hacer moviendo la tarjeta:

  • Pasarla a PAID con saldo. Eso no es mover una tarjeta, es decir que pagó: responde 422 RECEIVABLE_STAGE_REQUIRES_PAYMENT. Para darla por pagada, salda la cuenta.
  • Mover de columna una cuenta en cero. No hay nada que cobrar: 422 RECEIVABLE_ALREADY_PAID. Para reabrirla se anota un fiado nuevo.

«Vencida» no es una etapa

Es un dato aparte, isOverdue: saldo mayor que cero y la fecha acordada de pago (dueDate) quedó antes de hoy, contando el día en Bogotá. Una cuenta en PARTIAL o en FOLLOW_UP puede estar vencida. Para la lista de vencidos: GET /v1/receivables?overdue=true.

Montos y fechas

  • Pesos con hasta 2 decimales. 300000 son $300.000 y 25000.2 son $25.000,20. Saldos y totales vuelven igual: con centavos solo si los tienen. Un monto con más de 2 decimales es 400 («El valor admite máximo 2 decimales.»), no se redondea.
  • amount siempre positivo. El signo lo da el tipo: CHARGE (fiado) sube el saldo; PAYMENT (abono) y WRITE_OFF (condonación) lo bajan.
  • Un día pelado es un día de Bogotá. En los filtros, createdFrom=2026-09-01 arranca a las 00:00 de Colombia y createdTo=2026-09-30 cierra a las 23:59:59 de Colombia — no a medianoche UTC, que serían las 7 de la noche del día anterior.
  • dueDate: "2026-10-15" se guarda a las 00:00 de ese día en Bogotá (2026-10-15T05:00:00.000Z), así que la cuenta se vence el 16.
  • occurredAt de un movimiento es opcional (sin él, ahora). Un día pelado ("2026-09-26") queda al mediodía de Bogotá, para que un abono «del 26» no caiga el 25 por ningún redondeo. Un ISO completo se respeta tal cual; si está en el futuro (con 5 minutos de tolerancia), es 400.

El saldo no se suma: se recalcula

Cada escritura bloquea la fila del cliente, anota el movimiento y relee todo el historial para recalcular saldo, totales y el balanceAfter de cada movimiento. Por eso un abono con fecha de ayer o la anulación de uno de hace dos semanas dejan el historial cuadrado, y dos abonos simultáneos —la caja y el panel a la vez— nunca pasan los dos la validación contra el mismo saldo.

El flujo completo

De cliente nuevo a cuenta saldada, en cuatro llamadas.

1. Crear el cliente con su deuda

Gloria se llevó $300.000 y dejó $50.000 de una vez. Con initialDebt e initialPaid la cuenta nace con los dos movimientos («Deuda inicial» y «Abono inicial») y ya en PARTIAL:

curl -X POST https://api.verifika.tech/v1/receivables \
  -H "Authorization: Bearer $VERIFIKA_API_KEY" \
  -H "Idempotency-Key: alta-gloria-restrepo-2026-09-22" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Gloria Restrepo",
    "phone": "310 456 7890",
    "notes": "Mercado de la semana. Paga los viernes.",
    "dueDate": "2026-10-15",
    "initialDebt": 300000,
    "initialPaid": 50000
  }'
201 Created (recortada)
{
  "object": "receivableAccount",
  "id": "cmu4a2k9x000b7qw1h3r5tz8e",
  "name": "Gloria Restrepo",
  "phone": "+573104567890",
  "stage": "PARTIAL",
  "dueDate": "2026-10-15T05:00:00.000Z",
  "isOverdue": false,
  "balance": 250000,
  "currency": "COP",
  "totalCharged": 300000,
  "totalPaid": 50000,
  "source": "API",
  "createdBy": null,
  "entries": {
    "object": "list",
    "data": [
      { "object": "receivableEntry", "type": "PAYMENT", "amount": 50000, "balanceAfter": 250000, "note": "Abono inicial" },
      { "object": "receivableEntry", "type": "CHARGE", "amount": 300000, "balanceAfter": 300000, "note": "Deuda inicial" }
    ],
    "hasMore": false,
    "nextCursor": null
  }
}

El teléfono se normaliza solo: un celular colombiano de 10 dígitos gana el +57. La API no deduplica por nombre: antes de crear, busca con GET /v1/receivables?q=gloria que no exista ya — dos «Gloria» en el cuaderno parten el saldo en dos.

2. Registrar un abono

El viernes Gloria abona $80.000 en la caja 2. La Idempotency-Key es lo que evita que un reintento lo anote dos veces (más abajo):

curl -X POST https://api.verifika.tech/v1/receivables/cmu4a2k9x000b7qw1h3r5tz8e/entries \
  -H "Authorization: Bearer $VERIFIKA_API_KEY" \
  -H "Idempotency-Key: caja2-recibo-004817" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "PAYMENT",
    "amount": 80000,
    "note": "Abono en efectivo, caja 2"
  }'

La respuesta es el movimiento con la cuenta como quedó en account, porque la pregunta que sigue a «registré un abono» es siempre «¿y cuánto debe ahora?»:

201 Created (recortada)
{
  "object": "receivableEntry",
  "id": "cmu4h7r2t0014bnw1y2e6kd9v",
  "type": "PAYMENT",
  "amount": 80000,
  "balanceAfter": 170000,
  "note": "Abono en efectivo, caja 2",
  "source": "API",
  "voided": false,
  "account": {
    "object": "receivableAccount",
    "id": "cmu4a2k9x000b7qw1h3r5tz8e",
    "stage": "PARTIAL",
    "balance": 170000,
    "totalPaid": 130000,
    "paymentsCount": 2
  }
}

Un fiado nuevo es la misma llamada con "type": "CHARGE".

3. Consultar el saldo

curl https://api.verifika.tech/v1/receivables/cmu4a2k9x000b7qw1h3r5tz8e \
  -H "Authorization: Bearer $VERIFIKA_API_KEY"

Devuelve la ficha con balance, isOverdue, los totales y los últimos 50 movimientos, recientes primero y con los anulados marcados voided: true. Si entries.hasMore es true, el resto del historial está en GET /v1/receivables/{id}/entries, paginado por cursor.

Para el cuaderno entero, GET /v1/receivables/summary da el total que le deben al negocio, lo vencido, el reparto por etapa y los cinco que más deben — sumado en la base. No sumes páginas de la lista para esto: está paginada.

4. Saldar la cuenta

Gloria paga todo lo que queda. En vez de mandar un abono por $170.000, pide que se salde:

curl -X POST https://api.verifika.tech/v1/receivables/cmu4a2k9x000b7qw1h3r5tz8e/settle \
  -H "Authorization: Bearer $VERIFIKA_API_KEY" \
  -H "Idempotency-Key: caja2-recibo-004902" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "PAYMENT" }'

El monto no se manda: el servidor lee el saldo con la fila bloqueada y registra un abono por exactamente eso. Si entre tu consulta y este POST entró otro abono desde el panel, se salda lo que de verdad queda, no lo que tú creías. La cuenta pasa a PAID y sale el evento receivable.account.paid.

201 Created (recortada)
{
  "object": "receivableEntry",
  "type": "PAYMENT",
  "amount": 170000,
  "balanceAfter": 0,
  "note": "Pagó el saldo",
  "account": { "id": "cmu4a2k9x000b7qw1h3r5tz8e", "stage": "PAID", "balance": 0, "totalPaid": 300000 }
}

Con "mode": "WRITE_OFF" el saldo se perdona en vez de cobrarse, y pide receivables.write_off.

Reintentar un abono sin duplicarlo

La red se corta después de que registramos el abono pero antes de que te llegue la respuesta. Tu caja reintenta. Sin protección, Gloria queda con dos abonos de $80.000 — y el saldo del cuaderno, $80.000 más bajo de lo que de verdad debe.

Por eso toda escritura acepta Idempotency-Key. La regla es una llave por operación de negocio, no por intento HTTP: el número de recibo de la caja es una llave perfecta, porque ya identifica ese abono y solo ese.

Reintentas con…Qué pasa
La misma llave y el mismo cuerpoTe devolvemos la respuesta original, con Idempotent-Replay: true. No se anota otro abono.
La misma llave mientras el primero sigue en curso409 IDEMPOTENCY_IN_PROGRESS: espera unos segundos y reintenta.
La misma llave con otro cuerpo409 IDEMPOTENCY_KEY_REUSED: es otra operación, usa otra llave.

Solo se guardan las respuestas exitosas: si el abono falló con 422, corriges y reintentas con la misma llave. Las llaves se recuerdan 24 horas. Detalles en Idempotencia.

Errores que vas a ver

Todos con el sobre de siempre ({ error: { code, message, details?, requestId, docUrl } }, ver Errores). Ramifica por code, nunca por message.

HTTPcodeCuándoQué hacer
422RECEIVABLE_OVERPAYMENTUn abono o una condonación por más de lo que debe. details.balance es el saldo real.No lo ajustes por tu cuenta: muéstrale a la persona el saldo y deja que decida si registra ese valor. Si pagó todo, usa /settle.
422RECEIVABLE_ALREADY_PAIDSaldar una cuenta que ya está en cero, o moverla de etapa.Nada que cobrar. Si se llevó algo nuevo, es un fiado (CHARGE).
422RECEIVABLE_STAGE_REQUIRES_PAYMENTPATCH { "stage": "PAID" } con saldo. details.balance dice cuánto debe.Usa POST …/settle.
422RECEIVABLE_NEGATIVE_BALANCEAnular un fiado que ya se había abonado: el cliente quedaría con saldo a favor.Anula primero el abono, después el fiado.
403SCOPE_REQUIREDLa llave no tiene el permiso. details.scope dice cuál.Ver abajo.
404NOT_FOUNDEl cliente o el movimiento no existen, no son de tu negocio, o el cliente está archivado y le intentas escribir.Si está archivado, restáuralo primero.
400BAD_REQUESTMonto con más de 2 decimales o en cero, teléfono que no cuadra, initialPaid mayor que initialDebt, occurredAt en el futuro.Corrige el cuerpo. details trae el campo.
422 RECEIVABLE_OVERPAYMENT
{
  "error": {
    "code": "RECEIVABLE_OVERPAYMENT",
    "message": "El monto supera lo que debe ($170.000).",
    "details": { "balance": 170000 },
    "requestId": "req_d20f6e…",
    "docUrl": "https://docs.verifika.tech/guias/errores"
  }
}

Condonar con la llave de la caja

La caja intenta perdonar el saldo ("type": "WRITE_OFF" o settle con "mode": "WRITE_OFF") con una llave que solo tiene receivables.write:

403 SCOPE_REQUIRED
{
  "error": {
    "code": "SCOPE_REQUIRED",
    "message": "Esta API key no tiene permiso para esta operación.",
    "details": { "scope": "receivables.write_off" },
    "requestId": "req_b0b926…",
    "docUrl": "https://docs.verifika.tech/guias/errores"
  }
}

Es lo esperado, no un fallo: condonar es una decisión de la dueña, no de la caja. No amplíes la llave de la caja para esquivarlo; que lo haga una persona desde el panel, o una llave aparte con ese permiso. Como el permiso depende del type o del mode, se revisa después de validar el cuerpo: un cuerpo inválido responde 400 antes que 403.

Anular, archivar o condonar

Las tres «quitan» algo, y no son intercambiables:

AnularArchivarCondonar
Para quéUn movimiento mal registrado (abono duplicado, monto errado).Un cliente al que ya no se le cobra (se mudó, cerró).Una deuda que el negocio decide perdonar.
LlamadaPOST …/entries/{entryId}/voidPOST …/archive · POST …/restorePOST …/entries con WRITE_OFF, o POST …/settle con mode: WRITE_OFF
Permisoreceivables.voidreceivables.writereceivables.write_off
El saldoSe recalcula sin ese movimiento.No cambia: sigue ahí, solo sale del tablero y de los totales.Baja. Queda en totalWrittenOff, no en lo abonado.
El historialEl movimiento sigue, con voided: true. Nada se borra.Intacto y legible. Mientras esté archivado no admite movimientos ni ediciones.Un movimiento más, de tipo WRITE_OFF.
¿Se deshace?No se «des-anula»: se registra de nuevo.Sí, con restore, tal como estaba.Anulando la condonación.

No hay forma de borrar un cliente ni un movimiento por API. Es a propósito: el cuaderno es la memoria de lo que se fió, y un historial que se puede reescribir deja de servir para cobrar.

Enterarte sin preguntar

Si tu sistema necesita saber cuándo alguien —la cajera en la app, la dueña en el panel, el asistente o tu propia llave— anota un fiado o salda una cuenta, suscríbete a los eventos receivable.* en vez de consultar cada minuto. Están en Eventos de webhook. El que casi todos quieren es receivable.account.paid.

Siguiente paso

En esta página