Cómo funciona el stock
Niveles por bodega, conteos, el margen de la API, salidas atómicas y stock negativo. Léelo antes de vender desde otro canal.
Posdata lleva el stock de cada producto (o variante) por bodega. Esta página explica de dónde sale el número que te da la API, qué pasa cuando tu tienda en línea vende y qué hace Posdata cuando una caja sin internet vende lo mismo.
El modelo: último conteo + movimientos posteriores
Cada combinación de (bodega, producto, variante) es un nivel de stock, y su cantidad es:
stock = último conteo físico + movimientos ocurridos después de ese conteo
- Un conteo dice «había N unidades en este instante». Lo registra alguien del negocio en la app, con
una hoja de conteo, o tu integración con
POST /inventory/counts. - Un movimiento es una diferencia: una venta resta, una entrada suma. Las cajas suben sus ventas como
movimientos; tú registras los tuyos con
POST /inventory/movements. - Lo que ocurrió antes del conteo ya está reflejado en él: no se vuelve a sumar ni a restar, aunque llegue después (por ejemplo, las ventas de una caja que estaba sin internet). Lo que ocurrió después se aplica encima. Una venta nunca se pierde ni se descuenta dos veces.
Un ejemplo con la camiseta talla M en la Bodega principal:
La hora de una venta es la del reloj de la caja que la hizo (nunca posterior a cuando llegó a la nube). Si un reloj está mal, el error se corrige con el siguiente conteo.
Dónde ves el stock:
GET /inventory/levels: un objetostock_levelpor nivel, con la cantidad, lo que la API puede comprometer y el último conteo. Detalle abajo.- El campo
stockde los productos (GET /products,GET /products/{id}): la suma de todos sus niveles (todas las bodegas y variantes). Cada variante trae su propiostock, la suma de sus niveles. Esnullen los productos que no llevan control de stock y en los que se arman con receta (el stock lo tienen sus insumos).
Dos modos: las cajas o la nube
Cada cuenta pasa del modo en el que cada caja lleva su propio stock (legacy) al modo en el que Posdata
lleva el stock oficial en la nube (baseline). Mientras la cuenta está en legacy, la
API no puede darte un número confiable, así que no te lo da.
El paso es automático: ocurre cuando todas las cajas principales del negocio que se han usado en los
últimos 14 días (las de kind: "primary" en GET /me, sin contar las revocadas) tienen la versión de Posdata
que lo soporta y subieron su stock de partida. Las cajas se actualizan solas al abrirlas con internet. Si una
caja que ya no se usa frena el cambio, el negocio puede revocarla en
cuenta.posdata.so → Dispositivos. Tu integración no tiene que hacer nada.
Cómo saber en qué modo está la cuenta: pide GET /inventory/levels?limit=1. Si responde 200, el stock
está en la nube; si responde 409 STOCK_MODE_LEGACY, todavía lo llevan las cajas. No lo deduzcas de un
stock: null en un producto: también es null en productos sin control de stock o con receta.
Al pasar a baseline, el punto de partida de cada nivel es el stock que tenían las cajas. Si un nivel trae
baseline_estimated: true, ese punto de partida es una estimación (el ítem ya tenía movimientos en la
nube que esa caja quizá no había visto): conviene que el negocio lo cuente.
Tu integración debe manejar stock: null y 409 STOCK_MODE_LEGACY en cualquier momento, no solo al
principio: el soporte de Posdata puede devolver una cuenta al modo anterior mientras resuelve un problema.
Consultar el stock
GET /inventory/levels (permiso inventory:read) lista los niveles, con los filtros product_id,
variant_id y warehouse_id, más la paginación y los filtros por fecha de todas las listas.
No trae niveles de bodegas eliminadas, de productos sin control de stock ni de variantes eliminadas.
Sincronización incremental del stock: usa GET /inventory/levels?updated_since=… con la
receta de siempre. Un cambio de stock no cambia el
updated_at del producto, así que GET /products?updated_since=… no te trae los productos cuyo stock cambió.
available es una foto del momento: entre tu consulta y tu siguiente petición, una caja puede vender. Úsalo
para mostrar disponibilidad; para comprometer unidades, registra la salida y deja que la API decida (abajo).
El margen de la API
Las cajas pueden vender sin internet, y esas ventas llegan a la nube cuando se reconectan. Para que tu canal
en línea no venda las últimas unidades que quizá una caja ya vendió, la API nunca compromete las últimas N
unidades de cada nivel: available = quantity − margen, nunca menor que 0.
- El margen lo fija el dueño del negocio en cuenta.posdata.so → Inventario → Configuración. Es un entero de 0 a 99 y por defecto es 2.
- Es uno por cuenta: aplica igual a todas las bodegas y todos los productos.
- Solo limita las salidas por la API. Las entradas no tienen tope y los conteos registran lo que se contó.
- Con margen
0, la API puede comprometer hasta la última unidad.
Salidas atómicas
POST /inventory/movements (permiso inventory:write, con Idempotency-Key) suma
(type: "in") o resta (type: "out") stock de un nivel:
Con la cuenta en baseline, una salida out es atómica: Posdata bloquea el producto, verifica que
quantity − cantidad ≥ margen y descuenta en la misma operación. Si no alcanza, responde
409 INSUFFICIENT_STOCK con available (lo que sí puedes sacar) y no registra nada. Si varias
peticiones piden a la vez la última unidad disponible, exactamente una gana; las demás reciben el 409.
Si no alcanza:
Qué hacer con cada respuesta:
201: la salida quedó registrada y el stock ya bajó. Si es un reintento de algo que ya se hizo, recibes la respuesta original con el headerIdempotent-Replayed: true.409 INSUFFICIENT_STOCK: es una respuesta final para ese momento. No la reintentes en bucle: vende como máximoavailableo marca el producto como agotado. Como los errores no se guardan en la idempotencia, si más tarde entra stock puedes reintentar la misma operación con el mismoIdempotency-Key.- Error de red, tiempo de espera,
429o5xx: reintenta con el mismoIdempotency-Key. Si la primera petición alcanzó a descontar, recibes su respuesta y nada se descuenta dos veces. 409 IDEMPOTENCY_IN_PROGRESS: espera unos segundos y reintenta con el mismo valor. Mira Idempotencia.409 STOCK_MODE_LEGACY(solo con variantes): la cuenta todavía no lleva el stock en la nube.
No consultes available para decidir y luego registres la salida: entre las dos peticiones otra caja u otro
canal puede llevarse el stock. La salida misma es la verificación.
Pedidos de varias líneas: cada línea es un movimiento aparte; no hay una operación que descuente varias
líneas a la vez o ninguna. Si una línea responde INSUFFICIENT_STOCK y decides cancelar el pedido, devuelve
lo que ya descontaste con un in (con su propio Idempotency-Key y la misma reference).
No hay reservas: la API no aparta stock con vencimiento. Una salida descuenta de inmediato. Si tu tienda
necesita apartar mientras el cliente paga, registra la salida al iniciar el pago y devuélvela con un in si
el pago falla.
Más sobre los movimientos:
- Una entrada
inno tiene tope y puede declarar su costo conunit_cost. - Con la cuenta en
legacy, una salida de un producto sin variantes se registra sin verificar existencias: la API no conoce el stock de cada caja. - Con la cuenta en
baseline, en un producto que no lleva control de stock el movimiento queda en el historial, pero no cambia ningún nivel ni se verifica. - Los movimientos de la API aparecen en
GET /inventory/movementsconorigin_type: "api", junto con los de las ventas que las cajas ya subieron. Cada uno emiteinventory.movement.created. Suunit_costes el costo que declaraste (onull) y sureason_codees el motivo catalogado cuando el movimiento viene de un ajuste hecho en la app (shrinkage,damage,expired,theft,internal_use,supplier_return,found,correction,otherotransfer_shortage); esnullen los demás. - Las compras a proveedores las registra el negocio desde la app y llegan como movimientos:
origin_type: "purchase"(entrada de la compra recibida),origin_type: "purchase_return"(salida de lo devuelto al proveedor) yorigin_type: "reversal"(la anulación de una compra, que deshace su entrada). La API no crea ni lee proveedores ni compras como documento. - Los traslados entre bodegas los registra el negocio desde la app y llegan como movimientos con
origin_type: "transfer": una salida (out) en la bodega de origen cuando se despacha y una entrada (in) en la de destino cuando se recibe (o en la de origen, si se devuelve). Un traslado no mueve el costo promedio. La API no crea ni lee traslados como documento: solo ves sus movimientos. Mientras la mercancía va en camino no está en ningún nivel (no cuenta enstockni entotal_value): su valor va aparte, enin_transit_valuedel valor del inventario. - Crear un producto con
initial_stock(POST /products) registra además un movimiento de entrada y, con la cuenta enbaseline, ese stock inicial es el primer conteo del nivel (baseline_source: "initial"). - Los movimientos llegan a las cajas por su sincronización normal.
Conteos
POST /inventory/counts (permiso inventory:write, con Idempotency-Key) registra un
conteo físico: «en esta bodega había N unidades en este instante». Solo funciona con la cuenta en
baseline; antes responde 409 STOCK_MODE_LEGACY.
Qué hace:
- El stock del nivel pasa a ser
counted_quantityen el instantecounted_at, y los movimientos ocurridos después se suman encima, también los que lleguen más tarde. Los ocurridos antes se consideran ya reflejados en el conteo. - Si el nivel ya tiene un conteo físico más reciente, el tuyo queda como historial
(
status: "historical") y no cambia el stock. La respuesta es201en los dos casos: revisastatus. expected_quantityes lo que Posdata creía que había en ese instante ydifference, la diferencia (counted_quantity − expected_quantity): tus faltantes o sobrantes.- Si el conteo queda como base de un producto que no llevaba control de stock, Posdata le vuelve a activar el control.
- Emite el webhook
inventory.count.created.
Por ejemplo, tu bodega contó 40 unidades a las 7:00 (antes de abrir) y lo envías a las 11:00, cuando las cajas ya vendieron 3. El stock queda en 37: el conteo más las ventas de después.
Usa un conteo solo cuando alguien contó de verdad. Para sumar o restar (una compra que llegó, una devolución, un pedido en línea) usa movimientos: un conteo afirma cuánto había en ese instante y reemplaza todo lo anterior a él.
Kardex y costo promedio
El kardex es el libro de un producto: cada entrada, salida, conteo y cambio de costo, en orden, con el
saldo que dejó cada fila. Posdata lo reproduce con las mismas reglas que usan las cajas y la cuenta, así
que el último saldo siempre coincide con el quantity de sus niveles.
GET /inventory/kardex (permiso inventory:read; los costos, con inventory:costs) responde el libro de un
producto:
Cada fila de rows:
Cosas que conviene saber:
- Sin
inventory:costsno hay costos. Con soloinventory:readobtienes cantidades y saldos; todo costo y valor viene ennullycosts_hiddenestrue. El permiso se pide aparte a propósito: un costo es un dato comercial sensible. - Un costo desconocido es
null, no0. Una entrada sinunit_costsuma las unidades pero no mueve el costo promedio; si el producto todavía no tiene ningún costo, sigue sin costo hasta que llegue una entrada conunit_costo el negocio lo registre. - Costo promedio ponderado móvil, por producto. Cada entrada con costo recalcula el promedio; las salidas
salen al promedio vigente y no lo mueven. Una venta hecha en negativo se costea de forma provisional y, al
entrar mercancía, el kardex agrega una fila
cost_adjustmentpor la diferencia; la línea de la venta nunca se reescribe. - El saldo del kardex es el stock oficial. El último
total_qtycoincide con la suma de losquantityde los niveles del producto. - Con la cuenta en
legacyresponde409 STOCK_MODE_LEGACY, igual que los niveles.
Valor del inventario
GET /inventory/valuation (permiso inventory:costs) responde cuánto vale el inventario hoy o a una
fecha de corte: para cada nivel, su existencia por el costo promedio del producto en ese instante.
total_valueson pesos enteros y suma solo las líneas delines. Cada línea es positiva y tiene costo.- Los niveles negativos no suman (no son stock): van aparte en
negatives, para que el negocio los cuente. - Un producto sin costo no se valora en
0: va aparte enmissing_cost, con su cantidad y sin valor. No suma al total; el negocio debe registrar su costo. computed_atdice cuándo se calculó. Información que llega tarde (una caja que estuvo sin internet, un conteo con fecha pasada) puede cambiar un corte que ya consultaste: si necesitas un número estable para un cierre, guardacomputed_atjunto a él.in_transit_valuees el valor de la mercancía en tránsito entre bodegas: la que se despachó en un traslado y todavía no se recibió. No está en ningún nivel, así que no suma atotal_value: va aparte, a pesos enteros, valorada al costo que quedó congelado en cada línea del traslado al despacharla. Es0si no hay nada en camino. Conwarehouse_idcuenta lo que viene hacia esa bodega; conat, lo que estaba en camino en ese instante.in_transitdetalla lo anterior:transfers(cuántos traslados tienen algo pendiente),missing_cost_linesylines(hasta 500, cada una contransfer_id,number,product_id,product_name,quantitypendiente,unit_cost,value, y las bodegasoriginydestination). Una línea cuyo costo se desconoce no se valora en0: viene conunit_costyvalueennull, se cuenta enmissing_cost_linesy no suma ain_transit_value.- Un
aten el futuro se toma como «ahora». Con la cuenta enlegacyresponde409 STOCK_MODE_LEGACY.
Stock negativo
Las ventas en las cajas nunca se rechazan: al cliente que está en el mostrador se le vende. Si una caja sin internet vendió unidades que ya se habían comprometido por otro canal, al subir esas ventas el nivel puede quedar por debajo de cero. Entonces:
- El nivel muestra
quantitynegativa,available: 0ynegative_alert_open: true. - Posdata avisa al negocio para que haga un conteo.
- Tu servidor recibe el webhook
inventory.stock.negativecon elstock_leveljusto después del cambio.
Hay una alerta por nivel: si el nivel sigue negativo con más ventas, no se repite. Se cierra cuando el
nivel vuelve a 0 o más (con un conteo o una entrada) y, si vuelve a bajar, se abre otra. Solo abren alerta
los niveles que tienen un conteo (baseline_at distinto de null).
Una salida out de la API nunca deja un nivel negativo: no baja de lo que reserva el margen. Si recibes
inventory.stock.negative, deja de ofrecer ese ítem (su available ya es 0) hasta que el negocio lo cuente.
Webhooks de inventario
No hay un evento por cada cambio de stock. El detalle de cada evento está en Webhooks.
Lo que esta versión todavía no hace (reservas, recetas, márgenes por producto…) está en Lo que la API no hace en la versión 1.