Racoonity

La tienda ya puede vender

Racoonity ya puede completar su primera venta real: catálogo, sesión de caja, pagos, inventario, ventas e idempotencia trabajando como una sola operación.

Por El equipo de Racoonity •
8 min de lectura
  • #racoonity
  • #arquitectura
  • #desarrollo
  • #openspec
  • #backend

Hasta ahora Racoonity ya sabía muchas cosas.

Sabía que una mercancía existe.
Sabía que esa mercancía está en algún lugar.
Sabía quién está usando una caja y cuándo una tienda puede abrir.

Pero todavía faltaba algo bastante importante para un sistema que algún día pretende ayudar a operar una tienda:

vender.

Con la quinta iteración del primer MVP, eso acaba de cambiar.

La primera venta real

La nueva pieza se llama internamente deliver-pos-sale-confirmation.

Su objetivo parece sencillo: confirmar una venta.

En realidad, una venta toca casi todo lo que hemos construido hasta ahora.

Racoonity tiene que saber:

  • qué producto se está vendiendo;
  • cuál es su precio actual;
  • quién está operando la caja;
  • en qué sucursal ocurre la venta;
  • si existe suficiente inventario;
  • qué método de pago se está utilizando;
  • cuánto dinero se recibió;
  • qué cliente participa, si existe uno;
  • qué movimientos de inventario deben registrarse;
  • qué información debe conservarse históricamente;
  • y qué eventos deben quedar disponibles para el resto de la ciudad.

Todo eso tiene que ocurrir como una sola operación comercial.

Si algo falla a mitad del camino, Racoonity no puede quedarse con media venta.

No queremos una venta guardada sin descontar inventario.

Ni inventario descontado sin pago.

Ni un pago registrado sin venta.

Ni eventos publicados sobre algo que finalmente nunca ocurrió.

La venta se completa entera o no se completa.

Cada mapache sigue cuidando su territorio

Una de las decisiones más importantes de esta iteración fue no convertir Sales en un módulo omnipotente.

Sales coordina la venta, pero no se apropia de todo.

Catalog continúa siendo dueño de los productos y sus precios.

Inventory continúa siendo dueño de las existencias y movimientos.

Payments continúa siendo dueño de los métodos y registros de pago.

Registers continúa siendo dueño de las cajas y sesiones.

Customers continúa siendo dueño de los clientes.

Sales únicamente conserva aquello que necesita para representar históricamente una venta: la venta misma, sus líneas y los snapshots necesarios para que dentro de varios años podamos seguir entendiendo exactamente qué ocurrió.

La arquitectura sigue creciendo sin abandonar una de las reglas que más nos importan:

cada dominio modifica únicamente aquello que le pertenece.

Una sola transacción

Confirmar una venta involucra varios módulos, pero todos participan dentro de la misma transacción coordinada por Foreman.

El flujo, simplificado, se parece a esto:

sesión de caja
→ productos y precios
→ cliente opcional
→ método de pago
→ inventario
→ folio
→ venta
→ pago
→ eventos
→ commit

Los módulos internos no abren transacciones independientes.

Reciben la sesión transaccional existente y hacen su trabajo dentro de ella.

Así, si cualquier pieza falla, PostgreSQL puede regresar todo al estado anterior.

Durante las pruebas forzamos fallos deliberadamente en Sales, Inventory, Payments y Outbox.

En todos los casos el resultado fue el mismo:

cero ventas parciales.

Dinero sin aproximaciones

Esta iteración también obligó a resolver otra cosa que tarde o temprano tenía que quedar seria: el dinero.

Los precios y totales utilizados por Sales ya no pasan por float64.

Los importes viajan como decimales exactos de dos posiciones y se almacenan como NUMERIC.

Algo tan cotidiano como:

"25.00"

debe seguir siendo exactamente 25.00.

No aproximadamente 24.9999999997 porque una computadora decidió ponerse creativa.

También quedaron definidas las reglas de descuentos y redondeo que utilizará esta primera versión.

Por ahora los cajeros no pueden aplicar descuentos distintos de cero.

Los administradores sí pueden hacerlo dentro de los límites permitidos.

No existe todavía un motor de promociones ni políticas comerciales complejas. Eso vendrá después.

El precio también puede cambiar mientras vendes

Hay un problema interesante que aparece cuando comienzas a pensar en tiendas reales.

¿Qué ocurre si un cajero está confirmando una venta exactamente al mismo tiempo que alguien cambia el precio del producto?

Racoonity bloquea el producto durante la parte crítica de la confirmación.

Si la venta obtuvo el bloqueo primero, termina usando el precio que acaba de validar.

Si el cambio de precio ocurrió primero, la venta detecta que el precio esperado ya no coincide y responde con:

SALE_PRICE_CHANGED

Nada de vender silenciosamente con un precio que la pantalla ya no representa.

Una caja, un folio

Cada Register mantiene su propia secuencia de folios.

Dos cajas distintas pueden tener el mismo número de folio.

Dos ventas del mismo Register no.

La asignación del número se serializa bloqueando la caja durante ese pequeño momento crítico.

También probamos varias ventas concurrentes.

Ambas pueden completarse correctamente y reciben números distintos.

Y después de reiniciar completamente Racoonity, la siguiente venta continúa desde el folio anterior.

La memoria del proceso no es autoridad.

PostgreSQL sí.

Inventario de verdad

Una venta confirmada genera salidas reales de inventario.

Inventory conserva el modelo que ya construimos previamente:

  • se modifica el balance;
  • se registra un Movement;
  • se publica inventory.movement_created;
  • se publica inventory.stock_changed.

Sales no fabrica eventos de Inventory.

Le pide a Inventory que haga su trabajo y Inventory continúa siendo responsable de explicar lo que ocurrió dentro de su dominio.

También pusimos dos ventas a competir por la última unidad disponible.

Con stock igual a uno:

venta A → éxito
venta B → INSUFFICIENT_STOCK
stock final → 0

Nunca negativo.

Idempotencia de extremo a extremo

Las ventas utilizan el mismo mecanismo de idempotencia que Racoonity ya tenía desde su fundación.

Si una terminal pierde la respuesta y vuelve a enviar exactamente la misma venta con la misma clave, Racoonity no genera una segunda operación.

Devuelve el resultado de la venta original.

Probamos también solicitudes concurrentes con la misma clave.

El resultado durable sigue siendo:

1 venta
1 pago
1 efecto de inventario
1 conjunto de eventos

Nada duplicado.

Y esta garantía también sobrevive a un reinicio completo del runtime.

Una venta que sobrevive al reinicio

Una de las últimas pruebas de esta iteración fue intencionalmente sencilla:

  1. iniciar Racoonity;
  2. realizar una venta real;
  3. destruir completamente ese runtime;
  4. iniciar otro runtime sobre la misma base de datos;
  5. consultar la venta;
  6. repetir la petición idempotente;
  7. realizar una segunda venta.

El segundo runtime reconstruyó todo desde PostgreSQL.

La venta seguía ahí.

El pago seguía ahí.

El inventario seguía descontado.

Los eventos seguían ahí.

La misma petición idempotente seguía devolviendo la misma venta.

Y la segunda venta recibió el siguiente folio.

Eso es exactamente lo que queríamos demostrar.

Una venta no imprime

También tomamos una decisión deliberadamente poco emocionante.

sale.confirm no imprime.

Confirmar una venta responde:

ticket.printable = true

y termina.

La impresión pertenece a otra etapa.

Si algún día una impresora decide convertirse en el aparato infernal que todas las impresoras eventualmente deciden ser, eso no debe tener el poder de deshacer una venta que ya ocurrió correctamente.

Cinco checkpoints

La implementación terminó pasando cinco checkpoints formales.

Checkpoint A — Foundation
Demostró que módulos, permisos, persistencia y puertos internos estaban correctamente conectados mediante la composición real.

Checkpoint B — First real sale
Ejecutó una venta completa atravesando el HTTP productivo y verificó su estado durable.

Checkpoint C — Atomicity
Forzó fallos de infraestructura y errores comerciales para comprobar rollback y códigos HTTP.

Checkpoint D — Concurrency
Probó carreras entre venta y cierre de sesión, actualización de precio, desactivación de método de pago, agotamiento de stock, idempotencia y asignación de folios.

Checkpoint E — Read side
Verificó las consultas de Sales y el resumen comercial de la sesión de caja.

La matriz de concurrencia terminó ejecutando 180 carreras sin flakes.

La superficie sigue siendo pequeña

A pesar de todo lo que ocurrió internamente, la superficie pública nueva sigue siendo deliberadamente pequeña.

Una nueva Action:

sale.confirm

Y tres nuevas consultas de Sales:

sales.get_sale
sales.list_current_session_sales
sales.list_my_recent_sales

Además de catalog.get_sellable_product, que completa el flujo necesario para preparar la venta.

Todavía no existen:

sale.cancel
refunds
split payments
cash reconciliation
tax engine
promotion engine
POS UI

No porque no vayan a existir.

Porque todavía no les toca.

La tienda ya puede vender

Esta iteración comenzó como “la primera venta real”.

Terminó siendo una prueba bastante completa de la arquitectura que hemos construido durante los últimos meses.

Catalog, Inventory, Customers, Registers, Payments y Sales pueden colaborar sin dejar de ser dominios separados.

Foreman puede coordinar una operación que cruza varios de ellos.

La idempotencia sobrevive reinicios.

Los locks soportan carreras reales.

Los errores dejan la base de datos limpia.

Y la venta puede volver a consultarse mucho después de que el proceso que la creó desapareció.

Racoonity todavía está muy lejos de ser el sistema que queremos construir.

Pero hoy cruzó una frontera importante.

La tienda ya puede vender.