Conciliación contable
Cruzar ventas, pagos y movimientos de caja para cerrar el día y encontrar dónde está la diferencia
Actualizado el 2026-08-16Para: Desarrolladores · Perfiles no técnicos
Conciliar es responder una pregunta simple: lo que se vendió, lo que se cobró y lo que quedó en la caja, ¿dan lo mismo? Y cuando no dan, poder decir en qué local, en qué arqueo y por cuánto difieren.
Esta receta usa los tres endpoints por cursor juntos, que es exactamente para lo que están.
Las tres patas
| Endpoint | Qué aporta |
|---|---|
/api/v1.0/sales | Cuánto se vendió: el total facturado por venta |
/api/v1.0/payments | Con qué se cobró: efectivo, tarjeta, QR, cuenta corriente |
/api/v1.0/cash-movements | Qué pasó con el efectivo: apertura, retiros, ingresos, cierre |
La identidad que tiene que cerrar, por arqueo:
ventas del arqueo = suma de los pagos del arqueo
efectivo esperado = fondo inicial
+ pagos en efectivo
+ ingresos de caja
- retiros de caja
diferencia = efectivo declarado en el cierre - efectivo esperado
Paso 1. Bajar los tres conjuntos del mismo período
Usá la misma ventana para los tres. Si no, la conciliación no cierra por construcción y vas a perseguir un fantasma.
def fetch_all(session, host, token, resource, shop_codes, window_from, window_to):
params = {
"ShopCode": shop_codes,
"From": window_from,
"To": window_to,
"Limit": 500,
}
headers = {"Authorization": f"Bearer {token}"}
while True:
response = session.get(
f"https://{host}/api/v1.0/{resource}", params=params, headers=headers, timeout=60
)
response.raise_for_status()
payload = response.json(parse_float=Decimal)
yield from payload["data"]
cursor = payload.get("next_page")
if not cursor:
return
params["Page"] = cursor
sales = list(fetch_all(session, host, token, "sales", shops, desde, hasta))
payments = list(fetch_all(session, host, token, "payments", shops, desde, hasta))
movements = list(fetch_all(session, host, token, "cash-movements", shops, desde, hasta))
Una venta del cierre de anoche puede haberse cobrado o corregido esta mañana, y
las tres consultas filtran por updated_at. Pedí desde unas horas antes del
inicio del día contable y filtrá después por arqueo. Es más barato traer de más
que explicar un faltante que no existía.
Paso 2. Agrupar por arqueo, no por día calendario
Este es el paso que casi todas las conciliaciones fallidas se saltean. Un local que cierra a las 3 AM tiene ventas del sábado con fecha calendario del domingo. Para el negocio, esas ventas pertenecen al arqueo del sábado.
cash-movements te da las aperturas y los cierres de caja, y con eso armás los
intervalos reales:
from collections import defaultdict
def build_cashbox_windows(movements):
"""Devuelve, por local, los intervalos [apertura, cierre) de cada arqueo."""
windows = defaultdict(list)
opened = {}
for movement in sorted(movements, key=lambda m: m["occurred_at"]):
shop = movement["shop_code"]
if movement["movement_type"] == "OPEN":
opened[shop] = movement
elif movement["movement_type"] == "CLOSE" and shop in opened:
windows[shop].append({
"cashbox_id": movement["cashbox_id"],
"from": opened.pop(shop)["occurred_at"],
"to": movement["occurred_at"],
})
return windows
Una caja abierta que todavía no cerró no se concilia: se deja para la corrida siguiente.
Paso 3. Cruzar ventas contra pagos
sales_by_id = {sale["uuid"]: sale for sale in sales}
paid = defaultdict(Decimal)
for payment in payments:
paid[payment["sale_uuid"]] += payment["amount"]
descuadres = [
{
"uuid": uuid,
"shop_code": sale["shop_code"],
"vendido": sale["total"],
"cobrado": paid.get(uuid, Decimal("0")),
"diferencia": sale["total"] - paid.get(uuid, Decimal("0")),
}
for uuid, sale in sales_by_id.items()
if sale["total"] != paid.get(uuid, Decimal("0"))
]
Lo que se encuentra acá, y qué significa:
- Venta sin pago. Suele ser cuenta corriente o una venta anulada cuyo pago se revirtió. Cruzá contra el estado de la venta antes de gritar.
- Pago sin venta. Casi siempre es una venta que quedó fuera de la ventana:
revisá si su
updated_atcayó afuera. - Diferencia de uno o dos centavos. Es redondeo, no fraude. Ver
Paso 4. Conciliar el efectivo
def cash_expected(movements, payments, window):
en_ventana = lambda ts: window["from"] <= ts < window["to"]
fondo = sum(
m["amount"] for m in movements
if m["movement_type"] == "OPEN" and en_ventana(m["occurred_at"])
)
efectivo = sum(
p["amount"] for p in payments
if p["payment_method"] == "CASH" and en_ventana(p["occurred_at"])
)
ingresos = sum(
m["amount"] for m in movements
if m["movement_type"] == "DEPOSIT" and en_ventana(m["occurred_at"])
)
retiros = sum(
m["amount"] for m in movements
if m["movement_type"] == "WITHDRAWAL" and en_ventana(m["occurred_at"])
)
# Los retiros ya vienen en negativo: se suman, no se restan.
return fondo + efectivo + ingresos + retiros
Ojo con el signo: los retiros llegan negativos. Restarlos otra vez duplica el faltante y produce un descuadre que no existe.
Paso 5. Reportar la diferencia, no esconderla
Una conciliación útil no dice "no cierra". Dice dónde no cierra:
| Local | Arqueo | Vendido | Cobrado | Efectivo esperado | Declarado | Diferencia |
|---|---|---|---|---|---|---|
| 1001 | 8842 | 412 500,00 | 412 500,00 | 118 300,00 | 118 300,00 | 0,00 |
| 1002 | 8843 | 289 140,50 | 289 140,50 | 74 020,50 | 73 520,50 | −500,00 |
Ese −500,00 redondo es la firma típica de un retiro que se hizo y no se
registró. La conciliación no lo resuelve: lo hace visible, que es su trabajo.
Errores que producen descuadres falsos
- Cortar por día calendario en vez de por arqueo.
- Usar ventanas distintas para ventas, pagos y movimientos.
- Aplicar valor absoluto a los importes negativos.
- Recalcular el total de la venta sumando ítems en vez de usar
total. - No recorrer todas las páginas. Una conciliación sobre la primera página de 500 registros va a descuadrar siempre, y el error va a parecer contable.