Case study
Punto Natural — POS/ERP en Go Punto Natural — POS/ERP in Go
Un punto de venta donde la plata y el stock no pueden estar mal: transacciones atómicas, idempotencia real y una auditoría de seguridad que cerró en 0 hallazgos altos. A point of sale where money and stock cannot be wrong: atomic transactions, real idempotency, and a security audit that closed with zero high findings.
Contexto Context
Punto Natural es un comercio de barrio que vendía con cuaderno: fiado anotado a mano, stock de memoria y cierre de caja al ojo. Me contrataron para reemplazar eso por un sistema propio, corriendo en su mostrador. Lo construí de cero en Go, con el frontend React embebido en el mismo binario para que el deploy sea un solo archivo. Punto Natural is a neighbourhood shop that ran on paper: store credit written by hand, stock from memory, and cash closing by eye. They hired me to replace that with a system of their own, running on their counter. I built it from scratch in Go, with the React frontend embedded in the same binary so deploying is a single file.
Problema Problem
Un POS es un sistema donde los bugs se pagan en pesos. Si dos ventas concurrentes descuentan el mismo stock, hay faltante real en la góndola. Si el vendedor hace doble clic en Cobrar, el cliente paga dos veces. Si el cierre de caja no da, alguien pone la diferencia de su bolsillo. El desafío no era hacer pantallas: era garantizar que esas tres cosas no pudieran pasar. A POS is a system where bugs are paid in cash. If two concurrent sales decrement the same stock, there is a real shortage on the shelf. If the clerk double-clicks Charge, the customer pays twice. If the cash count does not balance, someone covers the difference out of pocket. The challenge was not building screens: it was guaranteeing those three things could not happen.
Solución y decisiones técnicas Solution and technical decisions
- Stock con decremento atómico en la base (UPDATE ... WHERE stock >= cantidad), nunca read-modify-write. La venta entera va en una transacción: si algo falla, no queda media venta.
- Idempotencia con huella del payload: la misma clave con un carrito distinto devuelve 422 en vez de repetir el cobro. Verificado con requests concurrentes reales — una sola fila, un solo descuento de saldo.
- La plata siempre en centavos enteros, jamás en float. El reparto de descuentos por combo usa multiplicación de 128 bits para que un total grande no desborde int64, y reparte el residuo en trozos equivalentes para que ninguna línea quede negativa.
- El monto esperado del cierre de caja es una función pura con tests propios, separada de la base. El arqueo se puede probar sin levantar Postgres, y la diferencia da 0 en los cuatro medios de pago.
- Migraciones con goose embebido como librería (go:embed), ejecutadas por un subcomando del propio binario en vez de un CLI aparte. En el servidor migrar es un paso manual explícito, nunca automático al arrancar.
- sqlc para el acceso a datos: el SQL se escribe a mano y el tipado lo genera el compilador. El 100% de las consultas queda parametrizado por construcción.
- Performance medida, no supuesta: sembré 4 años sintéticos (~116 mil ventas) y ajusté los índices con EXPLAIN (ANALYZE, BUFFERS) hasta conseguir Index Only Scan en los caminos calientes.
Seguridad Security
- Auditoría de seguridad integral: 0 hallazgos altos, 0 medios y 5 bajos, los cinco corregidos y verificados end-to-end en la misma sesión.
- Mapeé las 54 rutas de la API para confirmar que ningún DTO filtrara datos según el rol: un empleado nunca ve el costo de un producto.
- Cacé una fuga de tiempo en el login: el hash señuelo usaba un costo de bcrypt distinto al real, así que el tiempo de respuesta delataba si el usuario existía.
- Sesiones del lado del servidor con expiración deslizante y tope de vida absoluto. La cookie no lleva Expires: la vigencia la decide el server, no el navegador.
- Headers de seguridad y chequeo de origen en las rutas que mutan, con HSTS solo en producción.
- Backup cifrado con ensayo de restore automatizado: la prueba no es que el archivo exista, es que sobre la base restaurada se pueda hacer una venta real.
Mi rol My role
Todo: relevamiento con el dueño, modelo de datos, backend, frontend, infraestructura y las revisiones. Trabajé con un agente de IA como ejecutor y yo como arquitecto y revisor, con un informe por sprint que después auditaba contra el código con verificación independiente. Varios de los hallazgos de arriba salieron de esas revisiones, no de los tests. Everything: discovery with the owner, data model, backend, frontend, infrastructure, and the reviews. I worked with an AI agent as the executor and myself as architect and reviewer, with a report per sprint that I then audited against the code with independent verification. Several of the findings above came out of those reviews, not from the tests.
Estado y resultado Status and result
La aplicación está completa y verificada, con una instancia de staging provisionada en mi propio VPS y CI corriendo migraciones up/down/up contra un Postgres real en cada push. La salida a producción está pendiente de la validación visual del cliente. Las tres invariantes de negocio que definí al principio — stock negativo, saldos descuadrados y caja abierta huérfana — dan 0 en cada corrida. The application is complete and verified, with a staging instance provisioned on my own VPS and CI running up/down/up migrations against a real Postgres on every push. Going live is pending the client visual validation. The three business invariants I defined up front — negative stock, unbalanced ledgers, and orphaned open registers — come back at 0 on every run.
Aprendizajes Learnings
- Los tests verdes no son el criterio de cierre. El criterio es la corrida real contra el sistema de verdad: un mock nunca me reprodujo el comportamiento que después me rompió.
- La incertidumbre se modela como estado explícito, no como un booleano. Una operación irreversible necesita poder decir "no sé si salió".
- Verificar el efecto observable y no el valor intermedio: el DOM puede estar perfecto y el total salir en $0.
- Sembrar datos a escala real antes de tocar índices evita optimizar lo que no dolía.