Ventas
Ventas
Prefijo: /api/ventas
Requiere Auth: Sí
Módulo principal de ventas del POS. Incluye CRUD completo, agregaciones por fecha/medio de pago, gestión de detalles DIAN y pendientes de facturación electrónica.
Nota: Las ventas utilizan soft delete — al eliminar una venta se marca como eliminada sin borrarse físicamente.
Idempotencia y Prevención de Duplicados
NeoPOS implementa idempotencia robusta en la creación de ventas para prevenir duplicados causados por retries de red, doble clic, timeouts o reconexiones offline-first.
Cómo funciona (UUID por ciclo de vida del carrito)
- El cliente genera UNA key UUID al abrir la pantalla de ventas (
initCartPersistence()). - Reutiliza la misma key en cada clic de “Cobrar” y en cada retry de timeout.
- Al limpiar el carrito (venta exitosa o cancelación), genera una key nueva para la siguiente venta.
- El backend recibe la key y verifica si ya existe en BD.
- Si la key ya existe → retorna HTTP 409 con la venta existente (no duplica).
- Si el cliente no envía key → el backend genera un UUID4 como fallback (sin protección de duplicados).
Triple protección contra duplicados
| Capa | Mecanismo | Cuándo actúa |
|---|---|---|
| UI | Flag volatile isProcessing | Bloquea doble clic instantáneo |
| Red | idempotency_key (UUID por carrito) | Detecta retries con la misma key |
| BD | UNIQUE (tenant_id, idempotency_key) | Última línea de defensa contra race conditions |
Header de trazabilidad
Todas las respuestas incluyen el header X-Request-ID (UUID). Útil para correlacionar logs del backend con peticiones del cliente.
Cuándo enviar la misma key vs. key nueva
| Escenario | Acción |
|---|---|
| Primer clic en “Cobrar” | Enviar la key del carrito |
| Timeout del servidor (500, 503) | Reintentar con la misma key |
| Conexión cortada post-commit | Reintentar con la misma key → recibirá 409 |
| Doble clic rápido | El segundo clic es bloqueado por isProcessing |
| Venta exitosa → nueva venta | Generar key nueva (nuevo carrito = nueva key) |
| Cancelar venta → nueva venta | Generar key nueva |
Paginación y Total Count
Todos los endpoints de listado y búsqueda que retornan arrays incluyen el header HTTP X-Total-Count con el número total de registros que cumplen los filtros sin paginación.
Cómo funciona
GET /api/ventas?skip=0&limit=20&fecha_desde=2026-01-01&fecha_hasta=2026-01-31HTTP/1.1 200 OKX-Total-Count: 342 ← Total de ventas en ese rango de fechas (sin paginar)Content-Type: application/json
[{"id":1,"vendedor":"Juan",...}, ... 20 items ...]El frontend calcula:
totalPages = Math.ceil(X-Total-Count / limit)currentPage = (skip / limit) + 1hasNext = currentPage < totalPages
Filtros que afectan X-Total-Count
| Endpoint | Qué cuenta X-Total-Count |
|---|---|
GET /api/ventas?fecha_desde=...&fecha_hasta=... | Ventas en ese rango de fechas |
GET /api/ventas/search?q=juan&tipo=vendedor | Ventas donde vendedor/cliente matchea “juan” |
CRUD Ventas
GET /api/ventas
Descripción: Lista todas las ventas con paginación y filtro por fechas.
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
skip | int | ❌ | Registros a saltar (default 0) |
limit | int | ❌ | Máximo de registros (default 100, máx 1000) |
fecha_desde | date | ❌ | Filtro fecha inicial (YYYY-MM-DD) |
fecha_hasta | date | ❌ | Filtro fecha final (YYYY-MM-DD) |
fecha | date | ❌ | Fecha exacta (alias para fecha_desde y fecha_hasta) |
mes | int | ❌ | Mes (1-12) |
anio | int | ❌ | Año |
Respuesta exitosa: 200 OK
Headers de respuesta:
| Header | Tipo | Descripción |
|---|---|---|
X-Total-Count | integer | Total de ventas que cumplen los filtros (fecha_desde, fecha_hasta, mes, anio). No considera skip/limit. |
Response:
[ { "id": 1, "tenant_id": "uuid-tenant", "id_cliente": 1, "vendedor": "Juan Pérez", ... }]cURL:
curl "http://localhost/api/ventas?skip=0&limit=10&fecha_desde=2025-01-01" -H "Authorization: Bearer <token>"Python:
import httpxresp = httpx.get("http://localhost/api/ventas", params={"skip": 0, "limit": 10, "fecha_desde": "2025-01-01"}, headers={"Authorization": f"Bearer :token"})ventas = resp.json()total = int(resp.headers.get("X-Total-Count", "0"))total_pages = (total + 10 - 1) // 10 # ceil divisionJavaScript:
const resp = await fetch('http://localhost/api/ventas?skip=0&limit=10&fecha_desde=2025-01-01', { headers: {'Authorization': `Bearer $:token`}});const ventas = await resp.json();const totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10);const totalPages = Math.ceil(totalCount / 10);GET /api/ventas/:venta_id
Descripción: Obtiene una venta por ID
Requiere Auth: Sí
Permiso requerido: ventas, leer
Respuesta exitosa: 200 OK
Errores: 404 (no encontrada)
cURL:
curl http://localhost/api/ventas/1 -H "Authorization: Bearer <token>"POST /api/ventas
Descripción: Crea una nueva venta con detalles y pagos. Soporta idempotencia para prevenir duplicados en retries.
Requiere Auth: Sí
Permiso requerido: ventas, crear
Parámetros body:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
id_cliente | int | ✅ | ID del cliente |
vendedor | string | ✅ | Nombre del vendedor |
montoRecibido | float | ✅ | Monto recibido del cliente |
total | float | ✅ | Total de la venta |
descuento | float | ❌ | Descuento aplicado |
medioPago | string | ✅ | Efectivo, Transferencia, Credito |
tipoPago | string | ✅ | CONTADO o CREDITO |
detalles | array | ✅ | Líneas de la venta |
pagos | array | ✅ | Pagos asociados |
idempotency_key | string | ❌ | UUID único por ciclo de vida del carrito. Se genera UNA vez al abrir la vista de ventas y se reutiliza en cada clic/retry. Si se omite, el backend genera un UUID4 (sin protección de duplicados). |
terminal_id | string | ❌ | ID del terminal POS que originó la venta. Se obtiene del JWT automáticamente si no se envía. |
Comportamiento de idempotencia:
- Si
idempotency_keyse envía y ya existe → retorna HTTP 409 con{"mensaje": "Venta ya procesada", "venta_id": N}(no duplica). - Si
idempotency_keyse omite → el backend genera un UUID4 como fallback. Sin protección de duplicados. - Si
idempotency_keyes nueva → crea la venta normalmente. - Doble clic → bloqueado por flag
isProcessingen el cliente (nunca llega al backend).
Respuesta exitosa: 201 Created
Respuesta duplicado: 409 Conflict — la venta con esa key ya existe
Request:
{ "id_cliente": 1, "vendedor": "Juan Pérez", "montoRecibido": 12000, "total": 10000, "descuento": 0, "vueltos": 2000, "totalf": "10000", "medioPago": "Efectivo", "tipoPago": "CONTADO", "fecha": "2025-01-15", "hora": "14:30:00", "tipo_pago": "CONTADO", "detalles": [ { "cod_pro": "PROD001", "nombre": "Café Latte", "cantidad": 2, "cliente_id": "1", "precio": 3500, "medioPago": "Efectivo", "id_producto": 1, "tipo_impuesto": "IVA", "valor_base": 7000, "valor_impuesto": 1330, "codigo_impuesto": "01" } ], "pagos": [ { "id_venta": 0, "medio_pago": "Efectivo", "monto": 12000 } ], "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"}Response 201 (exitoso):
{ "id": 1, "tenant_id": "uuid-tenant", "vendedor": "Juan Pérez", "total": 10000, "medioPago": "Efectivo", "tipoPago": "CONTADO", "fecha": "2025-01-15", "hora": "14:30:00", "fecha_hora": "2025-01-15T14:30:00", "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"}Response 409 (duplicado):
{ "detail": { "mensaje": "Venta ya procesada", "venta_id": 366 }}Errores: 400 (datos inválidos), 409 (venta duplicada por idempotency_key)
cURL:
curl -X POST http://localhost/api/ventas \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"id_cliente":1,"vendedor":"Juan Pérez","montoRecibido":12000,"total":10000,"medioPago":"Efectivo","tipoPago":"CONTADO","detalles":[{"cod_pro":"PROD001","nombre":"Café Latte","cantidad":2,"precio":3500,"id_producto":1,"tipo_impuesto":"IVA","valor_base":7000,"valor_impuesto":1330,"codigo_impuesto":"01"}],"pagos":[{"medio_pago":"Efectivo","monto":12000}]}'Python:
import httpxresp = httpx.post("http://localhost/api/ventas", json={"id_cliente": 1, "vendedor": "Juan Pérez", "montoRecibido": 12000, "total": 10000, "medioPago": "Efectivo", "tipoPago": "CONTADO", "detalles": [{"cod_pro": "PROD001", "nombre": "Café Latte", "cantidad": 2, "precio": 3500, "id_producto": 1, "tipo_impuesto": "IVA", "valor_base": 7000, "valor_impuesto": 1330, "codigo_impuesto": "01"}], "pagos": [{"medio_pago": "Efectivo", "monto": 12000}]}, headers={"Authorization": f"Bearer :token"})venta = resp.json()JavaScript:
const resp = await fetch('http://localhost/api/ventas', { method: 'POST', headers: {'Content-Type': 'application/json', 'Authorization': `Bearer $:token`}, body: JSON.stringify({id_cliente: 1, vendedor: 'Juan Pérez', montoRecibido: 12000, total: 10000, medioPago: 'Efectivo', tipoPago: 'CONTADO', detalles: [{cod_pro: 'PROD001', nombre: 'Café Latte', cantidad: 2, precio: 3500, id_producto: 1, tipo_impuesto: 'IVA', valor_base: 7000, valor_impuesto: 1330, codigo_impuesto: '01'}], pagos: [{medio_pago: 'Efectivo', monto: 12000}]})});const venta = await resp.json();PUT /api/ventas/:venta_id
Descripción: Actualiza una venta existente
Requiere Auth: Sí
Permiso requerido: ventas, actualizar
Respuesta exitosa: 200 OK
Errores: 404 (no encontrada)
cURL:
curl -X PUT http://localhost/api/ventas/1 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"total":11000,"montoRecibido":15000}'DELETE /api/ventas/:venta_id
Descripción: Elimina (soft delete) una venta
Requiere Auth: Sí
Permiso requerido: ventas, eliminar
Respuesta exitosa: 204 No Content
Errores: 404 (no encontrada)
cURL:
curl -X DELETE http://localhost/api/ventas/1 -H "Authorization: Bearer <token>"PUT /api/ventas/:venta_id/abono
Descripción: Alias de compatibilidad — acepta {"abono": N} y lo mapea internamente a montoRecibido. Existe para clientes legacy (Java) que envían campo abono. Funcionalmente idéntico a PATCH /abono.
Requiere Auth: Sí
Permiso requerido: ventas, actualizar
Request:
{ "abono": 5000}Campo:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
abono | int | ✅ | Monto a sumar a montoRecibido (se mapea a montoRecibido) |
Comportamiento / Casos de uso:
- Cliente Java legacy que usa campo
abonoen lugar demontoRecibido - Suma el valor al
montoRecibidoactual de la venta - No resta — solo incrementa lo ya recibido
Respuesta exitosa: 200 OK
Response:
{ "ok": true}Errores:
| Código | Causa | Solución |
|---|---|---|
| 404 | Venta no encontrada | Verificar venta_id |
| 400 | abono ≤ 0 | Enviar valor positivo |
| 403 | Sin permiso ventas, actualizar | Solicitar a admin |
cURL:
curl -X PUT http://localhost/api/ventas/1/abono \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"abono":5000}'Python:
import httpxresp = httpx.put("http://localhost/api/ventas/1/abono", json={"abono": 5000}, headers={"Authorization": f"Bearer :token"})assert resp.json()["ok"] == TrueJavaScript:
const resp = await fetch('http://localhost/api/ventas/1/abono', { method: 'PUT', headers: {'Content-Type': 'application/json', 'Authorization': `Bearer $:token`}, body: JSON.stringify({abono: 5000})});const result = await resp.json();GET /api/ventas/cliente/:cliente_id
Descripción: Obtiene todas las ventas de un cliente
Requiere Auth: Sí
Permiso requerido: ventas, leer
Respuesta exitosa: 200 OK
cURL:
curl http://localhost/api/ventas/cliente/1 -H "Authorization: Bearer <token>"GET /api/ventas/tipo-pago/:tipo_pago
Descripción: Obtiene ventas por tipo de pago (CONTADO, CREDITO)
Requiere Auth: Sí
Permiso requerido: ventas, leer
Respuesta exitosa: 200 OK
cURL:
curl http://localhost/api/ventas/tipo-pago/CONTADO -H "Authorization: Bearer <token>"Agregaciones
GET /api/ventas/total-por-medio-pago
Descripción: Obtiene el total de ventas agrupado por medio de pago en una fecha
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
medio_pago | string | ✅ | Efectivo, Transferencia, Credito, VARIOS |
fecha | date | ❌ | Fecha (default: hoy) |
Respuesta exitosa: 200 OK
Response:
{ "total": 500000}cURL:
curl "http://localhost/api/ventas/total-por-medio-pago?medio_pago=Efectivo&fecha=2025-01-15" -H "Authorization: Bearer <token>"GET /api/ventas/total-vueltos-dia
Descripción: Obtiene el total de vueltos del día
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
fecha | date | ❌ | Fecha (default: hoy) |
Respuesta exitosa: 200 OK
Response:
{ "total": 15000}cURL:
curl "http://localhost/api/ventas/total-vueltos-dia?fecha=2025-01-15" -H "Authorization: Bearer <token>"GET /api/ventas/total-transacciones-dia
Descripción: Obtiene el número total de transacciones del día
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
fecha | date | ❌ | Fecha (default: hoy) |
Respuesta exitosa: 200 OK
Response:
{ "total": 45}cURL:
curl "http://localhost/api/ventas/total-transacciones-dia?fecha=2025-01-15" -H "Authorization: Bearer <token>"GET /api/ventas/diario-por-usuario [NUEVO]
Descripción: Resumen diario de ventas discriminadas por vendedor para una fecha específica. Retorna una lista donde cada elemento representa un vendedor con su desglose por canal de venta (Local/Offline y Electrónico) y por medio de pago (Efectivo, Transferencia). Diseñado para el dashboard del admin que necesita ver qué vendedores están operando y cuánto factura cada uno.
Caso de uso: el admin abre el dashboard y ve la tabla “Ventas por vendedor hoy” con columnas: vendedor, nº ventas, total, local, electrónico, efectivo, transferencia. También útil para reportes diarios por cajero.
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
fecha | date | ❌ | Fecha del reporte (por defecto hoy, formato YYYY-MM-DD) |
Campos de la respuesta:
| Campo | Tipo | Descripción |
|---|---|---|
vendedor | string | Nombre del vendedor/cajero |
fecha | date | null | Fecha del reporte |
n_ventas | int | Nº de ventas no eliminadas del vendedor en la fecha |
total | int | Suma de Venta.total de todas las ventas del vendedor |
local | int | Suma de ventas con tipoPago = “Local” o “Offline/Pendiente” (canal local) |
electronico | int | Suma de ventas con tipoPago = “Electrónico” (facturación DIAN) |
efectivo | int | Suma de ventas con medioPago = “Efectivo” |
transferencia | int | Suma de ventas con medioPago = “Transferencia” |
otros | int | Residuo del total menos local, electrónico, efectivo y transferencia |
Nota: los totales local + electronico + otros = total por vendedor. Los campos efectivo y transferencia son un corte diferente (por medio de pago, no por canal).
Respuesta exitosa: 200 OK
Response:
[ { "vendedor": "ana", "fecha": "2026-08-25", "n_ventas": 18, "total": 450000, "local": 380000, "electronico": 70000, "efectivo": 300000, "transferencia": 100000, "otros": 0 }, { "vendedor": "carlos", "fecha": "2026-08-25", "n_ventas": 12, "total": 280000, "local": 280000, "electronico": 0, "efectivo": 200000, "transferencia": 80000, "otros": 0 }]cURL:
# Ventas de hoy por vendedorcurl "http://localhost/api/ventas/diario-por-usuario" -H "Authorization: Bearer <token>"
# Ventas de una fecha específicacurl "http://localhost/api/ventas/diario-por-usuario?fecha=2026-08-25" -H "Authorization: Bearer <token>"Python:
import httpxresp = httpx.get("http://localhost/api/ventas/diario-por-usuario", params={"fecha": "2026-08-25"}, headers={"Authorization": f"Bearer :token"})ventas_por_vendedor = resp.json()JavaScript:
const resp = await fetch('http://localhost/api/ventas/diario-por-usuario?fecha=2026-08-25', { headers: {'Authorization': `Bearer $:token`}});const ventasPorVendedor = await resp.json();GET /api/ventas/total-rango
Descripción: Obtiene el total de ventas en un rango de fechas
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
fecha_inicio | date | ✅ | Fecha inicial |
fecha_fin | date | ✅ | Fecha final |
Respuesta exitosa: 200 OK
Response:
{ "total": 2500000.0}cURL:
curl "http://localhost/api/ventas/total-rango?fecha_inicio=2025-01-01&fecha_fin=2025-01-31" -H "Authorization: Bearer <token>"Búsqueda y Conteo
GET /api/ventas/search
Descripción: Busca ventas por texto (cliente, vendedor, factura) y tipo con paginación.
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
q | string | ❌ | Texto a buscar (cliente, vendedor, factura) |
tipo | string | ❌ | Tipo de pago (CONTADO, CREDITO) |
skip | int | ❌ | Registros a saltar (default 0) |
limit | int | ❌ | Máximo de registros (default 50, máx 1000) |
Respuesta exitosa: 200 OK
Headers de respuesta:
| Header | Tipo | Descripción |
|---|---|---|
X-Total-Count | integer | Total de ventas que matchean la búsqueda (texto q y/o tipo). No considera skip/limit. |
cURL:
curl "http://localhost/api/ventas/search?q=Cliente&tipo=CONTADO&skip=0&limit=20" -H "Authorization: Bearer <token>"Python:
import httpxresp = httpx.get("http://localhost/api/ventas/search", params={"q": "Cliente", "tipo": "CONTADO", "skip": 0, "limit": 20}, headers={"Authorization": f"Bearer :token"})ventas = resp.json()total = int(resp.headers.get("X-Total-Count", "0"))GET /api/ventas/search/count
Descripción: Cuenta las ventas que coinciden con la búsqueda
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
q | string | ❌ | Texto a buscar |
tipo | string | ❌ | Tipo de pago |
Respuesta exitosa: 200 OK
Response:
{ "count": 12}cURL:
curl "http://localhost/api/ventas/search/count?q=Cliente" -H "Authorization: Bearer <token>"GET /api/ventas/count
Descripción: Cuenta ventas con filtros de fecha, mes, texto o tipo
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
fecha | date | ❌ | Fecha exacta |
mes | int | ❌ | Mes (1-12) |
anio | int | ❌ | Año |
texto | string | ❌ | Texto a buscar |
tipo | string | ❌ | Tipo de pago |
Respuesta exitosa: 200 OK
Response:
{ "count": 45}cURL:
curl "http://localhost/api/ventas/count?fecha=2025-01-15" -H "Authorization: Bearer <token>"GET /api/ventas/next-id
Descripción: Obtiene el siguiente ID de venta disponible (útil para pre-numeración de facturas)
Requiere Auth: Sí
Permiso requerido: ventas, crear
Respuesta exitosa: 200 OK
Response:
{ "next_id": 102}cURL:
curl http://localhost/api/ventas/next-id -H "Authorization: Bearer <token>"Integración Cliente Java
POST /api/ventas/:venta_id/revert-stock
Descripción: Revierte el stock descontado por una venta (devolución)
Requiere Auth: Sí
Permiso requerido: ventas, actualizar
Respuesta exitosa: 200 OK
Response:
{ "mensaje": "Stock revertido exitosamente"}Errores: 404 (venta no encontrada)
cURL:
curl -X POST http://localhost/api/ventas/1/revert-stock -H "Authorization: Bearer <token>"GET /api/ventas/:venta_id/numero-factura
Descripción: Obtiene el número de factura asociado a una venta
Requiere Auth: Sí
Permiso requerido: ventas, leer
Respuesta exitosa: 200 OK
Response:
{ "numero_factura": "FAC-001"}Errores: 404 (venta o detalle no encontrado)
cURL:
curl http://localhost/api/ventas/1/numero-factura -H "Authorization: Bearer <token>"PUT /api/ventas/:venta_id/post-emision
Descripción: Registra los datos de emisión DIAN después de facturar electrónicamente (número de factura, CUFE, fecha de validación, resolución)
Requiere Auth: Sí
Permiso requerido: ventas, actualizar
Request:
{ "numero_factura": "FAC-001", "cufe": "a1b2c3d4e5f6...", "fecha_validacion": "2025-01-15T14:35:00", "resolucion": 123456789}Respuesta exitosa: 200 OK
Response:
{ "mensaje": "Emisión DIAN registrada exitosamente"}Errores: 404 (venta no encontrada)
cURL:
curl -X PUT http://localhost/api/ventas/1/post-emision \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"numero_factura":"FAC-001","cufe":"a1b2c3d4e5f6...","fecha_validacion":"2025-01-15T14:35:00","resolucion":123456789}'GET /api/ventas/:venta_id/tipo-pago
Descripción: Obtiene el tipo de pago de una venta (CONTADO, CREDITO)
Requiere Auth: Sí
Permiso requerido: ventas, leer
Respuesta exitosa: 200 OK
Response:
{ "tipo_pago": "CONTADO"}Errores: 404 (venta no encontrada)
cURL:
curl http://localhost/api/ventas/1/tipo-pago -H "Authorization: Bearer <token>"GET /api/ventas/:venta_id/medio-pago
Descripción: Obtiene el medio de pago de una venta (Efectivo, Transferencia, Credito)
Requiere Auth: Sí
Permiso requerido: ventas, leer
Respuesta exitosa: 200 OK
Response:
{ "medio_pago": "Efectivo"}Errores: 404 (venta no encontrada)
cURL:
curl http://localhost/api/ventas/1/medio-pago -H "Authorization: Bearer <token>"Detalles y Pagos
GET /api/ventas/:venta_id/detalles
Descripción: Obtiene los detalles (líneas) de una venta
Requiere Auth: Sí
Permiso requerido: ventas, leer
Respuesta exitosa: 200 OK
cURL:
curl http://localhost/api/ventas/1/detalles -H "Authorization: Bearer <token>"PATCH /api/ventas/:venta_id/detalles
Descripción: Actualiza campos DIAN de los detalles (líneas) de una venta tras facturación electrónica exitosa. Recibe array de objetos con id del detalle + campos a actualizar. Útil cuando la facturación es asíncrona y el CUFE/NumeroFact llegan después.
Requiere Auth: Sí
Permiso requerido: ventas, actualizar
Request (array de actualizaciones):
[ { "id": 1, "cufe": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "NumeroFact": "SETP990000123", "estadoFact": 1, "fechaCreacion": "2025-01-15T14:30:00", "fechaValidacion": "2025-01-15T14:35:00" }]Campos actualizables por detalle:
| Campo | Tipo | Descripción | Valores |
|---|---|---|---|
id | int | Obligatorio - ID del detalle | Debe existir en la venta |
cufe | string | CUFE devuelto por DIAN | 96 chars hex |
NumeroFact | string | Número de factura autorizado | Formato: SETP + 9 dígitos |
estadoFact | int | Estado facturación | 0=Pendiente, 1=Facturado, 2=Anulado |
fechaCreacion | datetime | Timestamp creación factura | ISO 8601 |
fechaValidacion | datetime | Timestamp validación DIAN | ISO 8601 |
Comportamiento / Casos de uso:
- Facturación offline: venta se registra, luego sincroniza CUFE cuando hay internet
- Facturación por lote: enviar múltiples detalles en una request
- Corrección: re-enviar CUFE si DIAN rechazó inicial
Respuesta exitosa: 200 OK
Response:
{ "ok": true}Errores:
| Código | Causa | Solución |
|---|---|---|
| 400 | Detalle ID no pertenece a la venta | Verificar IDs |
| 404 | Venta no encontrada | Verificar venta_id |
| 403 | Sin permiso ventas, actualizar | Solicitar a admin |
cURL:
curl -X PATCH http://localhost/api/ventas/1/detalles \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '[{"id":1,"cufe":"a1b2c3d4e5f6...","NumeroFact":"FAC-001","estadoFact":1}]'Python:
import httpxresp = httpx.patch("http://localhost/api/ventas/1/detalles", json=[{"id": 1, "cufe": "a1b2c3d4e5f6...", "NumeroFact": "FAC-001", "estadoFact": 1}], headers={"Authorization": f"Bearer :token"})assert resp.json()["ok"] == TrueJavaScript:
const resp = await fetch('http://localhost/api/ventas/1/detalles', { method: 'PATCH', headers: {'Content-Type': 'application/json', 'Authorization': `Bearer $:token`}, body: JSON.stringify([{id: 1, cufe: 'a1b2c3d4e5f6...', NumeroFact: 'FAC-001', estadoFact: 1}])});const result = await resp.json();GET /api/ventas/:venta_id/pagos
Descripción: Obtiene todos los pagos (abonos, medios de pago) asociados a una venta. Una venta puede tener múltiples pagos (ej: parcial en efectivo + parcial en transferencia). Para conciliación de caja y verificación de abonos en ventas a crédito.
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros path:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
venta_id | int | ✅ | ID de la venta |
Parámetros query:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
skip | int | ❌ | Paginación (default 0) |
limit | int | ❌ | Paginación (default 500) |
Comportamiento / Casos de uso:
- Verificar que suma de pagos = total de venta (cuadre)
- Ventas a crédito: ver historial de abonos
- Conciliación: cruzar con extracto bancario (transferencias)
Respuesta exitosa: 200 OK
Response:
[ { "id": 1, "id_venta": 1, "medio_pago": "Efectivo", "monto": 12000, "fecha": "2025-01-15" }, { "id": 2, "id_venta": 1, "medio_pago": "Transferencia", "monto": 50000, "fecha": "2025-01-15" }]Errores:
| Código | Causa | Solución |
|---|---|---|
| 404 | Venta no encontrada | Verificar venta_id |
| 403 | Sin permiso ventas, leer | Solicitar a admin |
cURL:
curl "http://localhost/api/ventas/1/pagos?skip=0&limit=50" -H "Authorization: Bearer <token>"Python:
import httpxresp = httpx.get("http://localhost/api/ventas/1/pagos", params={"skip": 0, "limit": 50}, headers={"Authorization": f"Bearer :token"})pagos = resp.json()JavaScript:
const resp = await fetch('http://localhost/api/ventas/1/pagos?skip=0&limit=50', { headers: {'Authorization': `Bearer $:token`}});const pagos = await resp.json();GET /api/ventas/:venta_id/pendiente
Descripción: Verifica si una venta electrónica (tipoPago = “Electronico”) quedó en estado pendiente por fallo de red o error en API DIAN. Significado: la venta se ejecutó localmente pero NO se pudo enviar a DIAN para obtener CUFE/NumeroFact. El POS debe reintentar automáticamente cuando recupere conexión.
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros path:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
venta_id | int | ✅ | ID de la venta |
Comportamiento / Casos de uso:
- POS offline: venta electrónica se guarda local,
pendiente: true - POS online: al detectar
pendiente: true, reintenta facturación automática - Admin: consultar ventas atascadas para forzar reintento manual
- Solo aplica a
tipoPago = "Electronico"— ventas CONTADO/CREDITO siemprefalse
Respuesta exitosa: 200 OK
Response:
{ "pendiente": true}Valores:
| Valor | Qué significa |
|---|---|
true | Venta electrónica sin CUFE/NumeroFact (falló envío a DIAN) |
false | Venta facturada OK, o no es electrónica (CONTADO/CREDITO) |
Errores:
| Código | Causa | Solución |
|---|---|---|
| 404 | Venta no encontrada | Verificar venta_id |
| 403 | Sin permiso ventas, leer | Solicitar a admin |
cURL:
curl http://localhost/api/ventas/1/pendiente -H "Authorization: Bearer <token>"Python:
import httpxresp = httpx.get("http://localhost/api/ventas/1/pendiente", headers={"Authorization": f"Bearer :token"})if resp.json()["pendiente"]: print("Reintentar facturación DIAN")JavaScript:
const resp = await fetch('http://localhost/api/ventas/1/pendiente', { headers: {'Authorization': `Bearer $:token`}});const :pendiente = await resp.json();if (pendiente) alert('Venta pendiente DIAN - reintentar');PATCH /api/ventas/:venta_id/abono
Descripción: Endpoint principal para actualizar montoRecibido (abono) de una venta. Acepta directamente {"montoRecibido": N} que suma al valor actual. Diferencia con PUT /abono: este usa campo nativo montoRecibido; PUT usa alias abono para compatibilidad Java.
Requiere Auth: Sí
Permiso requerido: ventas, actualizar
Request:
{ "montoRecibido": 5000}Campo:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
montoRecibido | int | ✅ | Monto a sumar al montoRecibido actual de la venta |
Comportamiento / Casos de uso:
- Cliente paga cuota de crédito: sumar abono a
montoRecibido - Corrección: ajustar monto recibido si se digitó mal
- Incremental: siempre suma al valor existente, no reemplaza
Respuesta exitosa: 200 OK
Response:
{ "ok": true}Errores:
| Código | Causa | Solución |
|---|---|---|
| 404 | Venta no encontrada | Verificar venta_id |
| 400 | montoRecibido ≤ 0 | Enviar valor positivo |
| 403 | Sin permiso ventas, actualizar | Solicitar a admin |
Diferencia PUT vs PATCH /abono:
| Endpoint | Campo entrada | Uso recomendado |
|---|---|---|
PUT /abono | {"abono": N} | Clientes legacy (Java) |
PATCH /abono | {"montoRecibido": N} | Nuevos desarrollos (nativo) |
cURL:
curl -X PATCH http://localhost/api/ventas/1/abono \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"montoRecibido":5000}'Python:
import httpxresp = httpx.patch("http://localhost/api/ventas/1/abono", json={"montoRecibido": 5000}, headers={"Authorization": f"Bearer :token"})assert resp.json()["ok"] == TrueJavaScript:
const resp = await fetch('http://localhost/api/ventas/1/abono', { method: 'PATCH', headers: {'Content-Type': 'application/json', 'Authorization': `Bearer $:token`}, body: JSON.stringify({montoRecibido: 5000})});const result = await resp.json();Pendientes DIAN
Flujo completo: Cuando una venta electrónica falla al facturar (sin internet, error DIAN), se crea un registro en
ventas_pendientes_dian. El POS consulta/pendientes-dian/, reintenta facturación, y al éxito actualiza con CUFE/NumeroFact viaPUT. Finalmente elimina el pendiente.
GET /api/ventas/pendientes-dian/
Descripción: Lista todas las ventas pendientes de facturación electrónica DIAN. Incluye venta_id, CUFE parcial, NumeroFact si ya se generó, y fecha. Para que el POS/admon sepa qué facturas reintentar.
Requiere Auth: Sí
Permiso requerido: ventas, leer
Comportamiento / Casos de uso:
- POS inicia: consulta pendientes → reintenta facturación automática
- Admin: revisa cola de facturas atascadas
- Filtro implícito: solo ventas con
tipoPago = "Electronico"y sin CUFE válido
Respuesta exitosa: 200 OK
Response:
[ { "id": 1, "id_venta": 15, "cufe": "", "NumeroFact": "", "fecha": "2025-01-15T14:30:00" }, { "id": 2, "id_venta": 16, "cufe": "a1b2c3d4...", "NumeroFact": "SETP990000124", "fecha": "2025-01-15T15:00:00" }]cURL:
curl http://localhost/api/ventas/pendientes-dian/ -H "Authorization: Bearer <token>"Python:
import httpxresp = httpx.get("http://localhost/api/ventas/pendientes-dian/", headers={"Authorization": f"Bearer :token"})pendientes = resp.json()for p in pendientes: if not p["cufe"]: print(f"Reintentar venta {p['id_venta']}")GET /api/ventas/pendientes-dian/:pendiente_id
Descripción: Obtiene el detalle de un pendiente DIAN específico por ID. Para ver qué venta está atascada y su estado actual.
Requiere Auth: Sí
Permiso requerido: ventas, leer
Parámetros path:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
pendiente_id | int | ✅ | ID del pendiente DIAN |
Respuesta exitosa: 200 OK
Response:
{ "id": 1, "id_venta": 15, "cufe": "", "NumeroFact": "", "fecha": "2025-01-15T14:30:00"}Errores: 404 (no encontrado), 403 (sin permiso)
cURL:
curl http://localhost/api/ventas/pendientes-dian/1 -H "Authorization: Bearer <token>"POST /api/ventas/pendientes-dian/
Descripción: Crea un registro de venta pendiente de facturación DIAN. Se usa cuando la venta electrónica se ejecuta pero falla el envío a DIAN (offline, timeout, error API). El POS crea el pendiente para reintentar luego.
Requiere Auth: Sí
Permiso requerido: ventas, crear
Request:
{ "id_venta": 15, "cufe": "", "NumeroFact": ""}Campos:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
id_venta | int | ✅ | ID de la venta electrónica |
cufe | string | ❌ | CUFE si ya se generó (raro en creación) |
NumeroFact | string | ❌ | Número factura si ya se asignó |
Comportamiento / Casos de uso:
- Venta electrónica offline: POS crea venta + pendiente DIAN
- Al reconectar: POS consulta pendientes, factura, actualiza con CUFE
Respuesta exitosa: 201 Created
Response:
{ "id": 3, "id_venta": 15, "cufe": "", "NumeroFact": "", "fecha": "2025-08-28T10:00:00"}Errores: 404 (venta no existe), 409 (ya existe pendiente para esa venta), 403 (sin permiso)
cURL:
curl -X POST http://localhost/api/ventas/pendientes-dian/ \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"id_venta":15,"cufe":"","NumeroFact":""}'PUT /api/ventas/pendientes-dian/:pendiente_id
Descripción: Actualiza un pendiente DIAN con el CUFE y NumeroFact devueltos por DIAN tras facturación exitosa. Marca la venta como facturada.
Requiere Auth: Sí
Permiso requerido: ventas, actualizar
Parámetros path:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
pendiente_id | int | ✅ | ID del pendiente DIAN |
Request:
{ "cufe": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "NumeroFact": "SETP990000125"}Campos:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
cufe | string | ✅ | CUFE de 96 chars hex devuelto por DIAN |
NumeroFact | string | ✅ | Número de factura autorizado (formato SETP + 9 dígitos) |
Comportamiento / Casos de uso:
- Facturación exitosa: POS recibe CUFE/NumeroFact → PUT al pendiente
- También actualiza
detalle.cufe,detalle.NumeroFact,detalle.estadoFact = 1 - El pendiente puede eliminarse después (opcional, para limpieza)
Respuesta exitosa: 200 OK
Response:
{ "id": 1, "id_venta": 15, "cufe": "a1b2c3d4e5f6...", "NumeroFact": "SETP990000125", "fecha": "2025-01-15T14:30:00"}Errores: 404 (pendiente no encontrado), 400 (CUFE/NumeroFact inválidos), 403 (sin permiso)
cURL:
curl -X PUT http://localhost/api/ventas/pendientes-dian/1 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"cufe":"a1b2c3d4e5f6...","NumeroFact":"SETP990000125"}'DELETE /api/ventas/pendientes-dian/:pendiente_id
Descripción: Elimina un pendiente DIAN tras facturación exitosa y actualización de la venta. Limpieza opcional — no obligatoria, pero recomendada para no acumular basura.
Requiere Auth: Sí
Permiso requerido: ventas, eliminar
Parámetros path:
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
pendiente_id | int | ✅ | ID del pendiente DIAN |
Comportamiento / Casos de uso:
- Después de PUT con CUFE/NumeroFact exitoso, limpiar cola
- Admin: borrar pendientes huérfanos (venta anulada)
- No elimina la venta — solo el registro de cola DIAN
Respuesta exitosa: 204 No Content
Errores: 404 (no encontrado), 403 (sin permiso)
cURL:
curl -X DELETE http://localhost/api/ventas/pendientes-dian/1 -H "Authorization: Bearer <token>"Python:
import httpxresp = httpx.delete("http://localhost/api/ventas/pendientes-dian/1", headers={"Authorization": f"Bearer :token"})assert resp.status_code == 204