Saltar a contenido

El ciclo de pago y fiscalización

Esta es la conversación más difícil del sistema, y la que más plata pone en juego. Cuando un cliente paga en la terminal, pasan dos cosas que pueden fallar por separado: el cobro (¿el procesador aprobó?) y la fiscalización (¿AFIP autorizó el comprobante?). La red se cae justo en el medio más seguido de lo que uno quisiera. Esta página explica cómo el sistema atraviesa esa incertidumbre sin perder plata ni emitir comprobantes truchos. Para el detalle de cada pieza, está la referencia de facturación fiscal y la de pagos.

Dos dominios, dos verdades

Hay que separar tajantemente dos cosas que la intuición tiende a mezclar:

Dominio Pregunta Estado Dueño
Pago ¿Se cobró la plata? EstadoPagoIntento (PaymentAttempt) el procesador (MercadoPago / ApiCard)
Fiscalización ¿AFIP autorizó el comprobante? TrxEstado (Trx) AFIP

Son independientes. Podés tener la plata cobrada y el comprobante sin autorizar (eso es VOUCHERPENDING). Podés tener un pago indeterminado mientras el ticket sigue esperando. La regla que ordena todo: el ticket no se cierra hasta que el pago esté APROBADO. El dominio de pago manda sobre el de ticket.

graph TD
    OPEN["Trx: OPEN"] -->|pago aprobado| PAGADO["Trx: PAGADO"]
    PAGADO -->|CAE/CAEA al toque| CLOSE["Trx: CLOSE"]
    PAGADO -->|AFIP no responde| VP["Trx: VOUCHERPENDING"]
    VP -->|reconciliador autoriza| CLOSE

    subgraph Pago["Dominio Pago (PaymentAttempt)"]
        INI["INICIADO"] --> CI["CON_INTENTO"]
        CI --> APR["APROBADO ✓"]
        CI --> DEN["DENEGADO ✗"]
        CI --> IND["INDETERMINADO ⟳"]
        IND --> APR
        IND --> DEN
        IND --> REV["REQUIERE_REVISION 🙋"]
    end

El enemigo: el estado indeterminado

El caso que rompe los sistemas ingenuos es este: la terminal le pide al procesador que cobre, el procesador cobra, y justo ahí se corta la red antes de que la terminal reciba el "aprobado". ¿Qué sabe la terminal? Nada. ¿Se cobró? No sabe. Si asume que no y reintenta, cobra dos veces. Si asume que sí y no se cobró, regala mercadería.

La respuesta del sistema tiene tres patas:

1. Idempotencia: reintentar es seguro

Cada intento de pago tiene un paymentAttemptId que la terminal genera una sola vez por intención y reusa en cada reintento. El backend deduplica por ese id. Así, "cobrar" el mismo intento N veces produce un solo cobro. Esto convierte el reintento de algo peligroso a algo seguro: ante la duda, se reintenta el mismo intento, no uno nuevo.

El error que nunca hay que cometer

Generar un paymentAttemptId nuevo en un reintento. Eso rompe la dedup y habilita el doble cobro. El id es la identidad del intento, no del request.

2. Durabilidad: el pago in-flight sobrevive

La terminal persiste el intento en Isar (IsarPendingPayment) antes de cualquier I/O de red. Si la terminal se apaga (corte de luz, crash) en medio del cobro, al arrancar lee esos pendientes y los reconcilia. La plata no queda en el limbo del lado del cliente.

3. Reconciliación: el backend resuelve solo

Del lado del backend, el PaymentReconciliador (un job @Scheduled) levanta los PaymentAttempt que quedaron INDETERMINADO y le pregunta al procesador "¿este pago, al final, se cobró?" (CHECK_STATUS). Según la respuesta:

  • Aprobado → finaliza el ticket (cierre fiscal, CLOSE).
  • Denegado → marca el intento terminal.
  • Sigue sin saberse → reintenta, hasta un máximo; si se agota, escala a REQUIERE_REVISION.

Lo importante: esto pasa aunque la terminal esté apagada. El backend no depende de que el POS esté poleando para cerrar la incertidumbre. Esa es la diferencia entre un pago que se resuelve solo y uno que queda colgado para siempre.

REQUIERE_REVISION: cuando la máquina se rinde

Si tras todos los reintentos el sistema todavía no puede decidir si se cobró, no inventa una respuesta: escala a una persona. El Cockpit lo muestra en el panel fiscal. Es la admisión honesta de que algunos casos necesitan ojos humanos — y es mucho mejor que adivinar con la plata de por medio.

La segunda incertidumbre: AFIP

Resuelto el cobro, falta el comprobante. Y AFIP también se cae. Por eso hay dos vías de autorización:

  • CAE (online): se pide un código por comprobante, por SOAP, en el momento. Es lo normal cuando AFIP responde.
  • CAEA (anticipado/local): un código que AFIP otorga por adelantado para toda la quincena. Si AFIP está caído justo en la venta, el comercio igual puede facturar con el CAEA que ya tenía guardado.

Si ninguna vía funciona en el momento (AFIP caído y sin CAEA vigente), el ticket queda en VOUCHERPENDING: cobrado, pero sin papel fiscal. Es un estado durable y esperado, no un error. El reconciliador (o un reintento posterior) lo resuelve cuando AFIP vuelve.

sequenceDiagram
    participant POS as Terminal
    participant API as Backend (tickets)
    participant PROC as Procesador
    participant AFIP as AFIP

    POS->>API: payTicket (paymentAttemptId)
    API->>PROC: cobrar
    PROC-->>API: aprobado
    API->>API: Trx → PAGADO
    API->>AFIP: solicitar CAE (SOAP)
    alt AFIP responde
        AFIP-->>API: CAE + vto
        API->>API: Trx → CLOSE
    else AFIP no responde
        API->>API: ¿CAEA vigente?
        alt hay CAEA
            API->>API: autoriza local → CLOSE
        else sin CAEA
            API->>API: Trx → VOUCHERPENDING
            Note over API: el reconciliador reintentará
        end
    end
    API-->>POS: TicketDto (estado real)

Por qué está diseñado así

Podría parecer sobre-ingeniería. No lo es: es la diferencia entre un autoservicio que se puede dejar operando solo y uno que necesita un humano vigilando cada pago. Las propiedades que el diseño garantiza:

  • Nunca se cobra dos veces (idempotencia por paymentAttemptId).
  • Ningún pago queda en el limbo (durabilidad en Isar + reconciliador en el backend).
  • Nunca se emite un comprobante sin respaldo de cobro (el ticket no cierra hasta APROBADO).
  • Se puede facturar con AFIP caído (CAEA anticipado).
  • Cuando la máquina no puede decidir, lo admite (REQUIERE_REVISION), en vez de adivinar.

Si vas a tocar pagos o fiscalización

Leé esta página y las dos referencias (pagos, facturación fiscal) enteras antes. Es el código donde un bug no se ve en una demo pero aparece como un descuadre de caja a fin de mes. Acá la prudencia no es opcional.