Saltar a contenido

El agregado Ticket y su cómputo

El ticket es el agregado raíz del sistema: todo —itemización, promociones, envases, impuestos, pago y fiscalización— gira alrededor de él. Esta página describe su estructura, su ciclo de vida y, sobre todo, el orden exacto en que se computa. Es la página más importante de la referencia: si vas a tocar la lógica de negocio, leela entera.

El código es la fuente de verdad

Reproducimos fórmulas, nombres de clase y fixes tal como están en el código a la fecha. Algunas firmas exactas de método pueden cambiar; ante cualquier discrepancia, mandan las clases en src/main/java/com/tipre/autocompras/tickets/.

El agregado: Trx

El ticket se persiste como la entidad Trx (tickets/domain/Trx.java) en la base TsAutoCompra. Campos principales:

Campo Tipo Rol
id Long PK autogenerada.
fechaCreacion / fechaModificacion LocalDateTime Apertura y última modificación.
codComercio, nroSucursal, nroPos, codTerminal Identidad de origen.
nroTicketPos int Número secuencial del ticket en la terminal.
total BigDecimal Monto total.
estado TrxEstado Estado actual (ver abajo).
jsonticket TEXT El TicketDTO completo serializado a JSON. Acá vive el detalle (ítems, movimientos, promos, núcleo impositivo).
idempotencyKey String(64) Clave de deduplicación (fix TSTK-006).
errorMessage String Mensaje si terminó en error.

El detalle vive en el JSON, no en columnas

Trx guarda los campos de cabecera en columnas y el cuerpo completo del ticket en jsonticket como un TicketDTO serializado. Esto mantiene el modelo relacional simple y deja el agregado rico en el JSON. Cuando el POS pide un ticket, recibe ese TicketDTO.

Estados del ticket: TrxEstado

stateDiagram-v2
    [*] --> OPEN
    OPEN --> PAGADO: pago aprobado
    OPEN --> CANCELED_USER: cancela el cliente
    OPEN --> CANCELED_INACTIVITY: timeout de inactividad
    PAGADO --> CLOSE: CAE/CAEA inmediato
    PAGADO --> VOUCHERPENDING: falla/timeout fiscal
    VOUCHERPENDING --> CLOSE: reconciliador reintenta y autoriza
    CLOSE --> ANULADO: anulación post-cierre
    OPEN --> ERROR
Estado Significa
OPEN Abierto; se agregan/quitan ítems.
PAGADO Pago aprobado, en tránsito a cierre fiscal.
VOUCHERPENDING Pagado pero el comprobante aún no fue autorizado por AFIP. Durable; lo resuelve el reconciliador. Ver Facturación fiscal.
CLOSE Cerrado y autorizado fiscalmente (comprobante emitido).
CANCELED_USER Cancelado por el cliente (desde OPEN).
CANCELED_INACTIVITY Cancelado automáticamente por inactividad (desde OPEN).
ERROR Terminó en error.
ANULADO Anulado después del cierre.
TEST Ticket de prueba.

Regla de oro: el ticket no se cierra hasta que el pago esté APROBADO

Trx.estado nunca avanza a PAGADO/CLOSE mientras el PaymentAttempt no esté APROBADO. El dominio de pago manda sobre el de ticket. El detalle está en El ciclo de pago y fiscalización.

Itemización

El TicketDTO no es una lista plana de líneas: separa ítems (lo que el cliente ve) de movimientos (las anotaciones contables que componen el total). Esta separación es la que permite que una misma línea reciba una venta, un descuento de promo y un crédito de envase sin perder trazabilidad.

  • Ítem (ItemDTO): una línea del ticket. Tiene su artículo, cantidad, tipo (ItemTicketTipo: venta normal, ENVASE, etc.) y estado (ItemTicketEstado: VENTA, SALDADO, ANULADO).
  • Movimiento (MovimientoDTO): una anotación sobre un ítem. Una venta es un movimiento; un descuento de promo es otro movimiento ligado al mismo ítem; un crédito de envase, otro. El total del ticket es la suma de los movimientos.

Operaciones (todas mutaciones REST, ver API REST):

Operación Qué hace
openTicket Crea el Trx en OPEN.
agregarArticulo Resuelve el EAN en catalogo, arma el ítem y su movimiento de venta, recalcula promos y total.
removeItem Quita un ítem (o lo marca ANULADO) y recalcula.
changeStatus Cambia el estado del ticket.
validateTicket Valida el ticket antes de pagar.
closeTicket Cierra (dispara fiscalización).

Solo los ítems en estado VENTA se facturan

Al armar la solicitud fiscal, CaeService.resolveFacturableItems toma solo los ítems en VENTA. Los SALDADO (saldados por envase/crédito) y ANULADO no van al comprobante.

El orden de cálculo

Este es el corazón. Cuando se agrega o quita un artículo, el total se recompone siguiendo un orden fijo. Saltearse un paso o cambiarlo de lugar produce totales mal calculados.

graph TD
    A["1. Resolver artículo (EAN → catalogo)"] --> B["2. Decodificar EAN especial<br/>(peso / precio embebido)"]
    B --> C["3. Armar la línea: precio vigente × cantidad<br/>→ NucleoImpositivoDto por ítem"]
    C --> D["4. Calcular promociones<br/>(MAYORISTA, BULTO, COMBO, CANTIDAD)"]
    D --> E["5. Computar envases<br/>(consumo de vales, envases a cobrar)"]
    E --> F["6. Sumar movimientos → total del ticket"]
    F --> G["7. Al pagar/cerrar: promos MODO PAGO + fiscalización"]
  1. Resolución del artículo: el EAN escaneado se busca en catalogo (cache Caffeine ~100k). Devuelve precio, alícuota (iTax), flags (bPeso, bRandomPrice, bEnvase), impuesto interno (fImpinterno).
  2. Decode de EAN especial: si el EAN codifica peso o precio (productos pesables, random price, DUN de bulto), se extrae. Ver Decodificación de EAN.
  3. Armado de la línea: se calcula el NucleoImpositivoDto del ítem (neto + IVA + impuestos internos) a partir del precio vigente y la cantidad. Ver Núcleo impositivo.
  4. Promociones: se invoca el módulo promos. Primero precios MAYORISTA y BULTO (por ítem), luego COMBO/CANTIDAD/CUPONES. El beneficio se distribuye proporcionalmente entre las líneas. Ver Promociones.
  5. Envases: se computa el consumo de vales contra los ítems de tipo ENVASE y se determina cuántos envases hay que cobrar (cantEnvasesCobrar = max(0, cantEnvases − consumidos)). Ver Envases y vales.
  6. Total: la suma de todos los movimientos da el total del ticket.
  7. Al pagar/cerrar: se recalculan las promos que dependen del medio de pago (MODO PAGO, ver Promociones) y se dispara la fiscalización.

Redondeo y escala

Todo el dinero usa BigDecimal con reglas consistentes (definidas en NucleoImpositivoDto):

  • Escala de moneda: 2 decimales, RoundingMode.HALF_UP.
  • Escala de cálculo intermedio: mayor (≈10 decimales) para no acumular error antes del redondeo final.
  • En la distribución proporcional de descuentos, la última línea absorbe el residuo para que la suma de las porciones sea exactamente el total (evita el centavo perdido por truncamiento).

Nunca redondees a mano en pasos intermedios

El error clásico es redondear a 2 decimales en cada paso. Eso descuadra el ticket. Se calcula con escala alta y se redondea una sola vez al final. La aritmética vive en NucleoImpositivoDto justamente para centralizar esto.

El comprobante (voucher)

Cuando el ticket cierra y tiene CAE/CAEA, VoucherService genera el comprobante imprimible:

  • Impresión térmica: ESC/POS vía escpos-coffee.
  • PDF: vía OpenPDF (reemplazó iText 5 AGPL — plan 011).
  • QR fiscal de AFIP: generado con ZXing a partir de la URL fiscal (AfipUtil.getQrAfip). Ver Facturación fiscal.
  • Templates: gestionados por TemplateController.

VoucherType distingue: TICKET, TICKET_PDF, ENVASE, ENVASE_TOMRA, CUPON, ANULACION_PAGO.

Dos issues conocidos en VoucherService

  • Thread-safety (plan 007): un campo de config mutable compartido entre requests puede hacer que un thread pise la config de otro mientras imprime.
  • getFirst() sin guarda (plan 008): un .getFirst() sobre lista vacía tira NoSuchElementException opaca si falta config.

Idempotencia y casos especiales

  • idempotencyKey (TSTK-006): deduplica mutaciones reenviadas. La misma operación repetida no duplica el efecto.
  • Cierre por inactividad: la terminal tiene timers (InactividadBloc); un ticket abandonado pasa a CANCELED_INACTIVITY.
  • Zona horaria: las fechas con efecto fiscal se calculan en America/Argentina/Buenos_Aires, no en la zona de la JVM (fix TSTK-024).
  • Logging por ítem (plan 010): calcularTotalTicket logueaba 2× INFO por ítem (1800 líneas en un carrito de 30); se baja a DEBUG.

Por dónde seguir