Saltar a contenido

Facturación fiscal (AFIP)

El dominio más crítico y subdocumentado del sistema. Acá se decide cómo un ticket cobrado se convierte en un comprobante fiscal autorizado por AFIP, qué pasa cuando AFIP no responde, y cómo el sistema garantiza que ningún pago quede en el limbo. Vive en el módulo tickets. Es el paso 7 del cómputo del ticket.

El código es la fuente de verdad — y algunas cosas están planeadas

Reproducimos el flujo real a la fecha, incluidos fixes (TSTK-016, TSTK-024, P0-1, P0-3, INT-002). Donde algo está modelado pero no implementado todavía, lo marcamos explícitamente.

Tipos de comprobante

TipoComprobante modela 9 comprobantes (3 letras × factura / nota de débito / nota de crédito), cada uno con su código AFIP:

Letra Factura Nota de débito Nota de crédito Para
A FACA (001) NDBA (003) NCRA (002) Comprador Responsable Inscripto.
B FACB (006) NDBB (008) NCRB (007) Consumidor final, monotributo, exento.
C FACC (011) NDBC (013) NCRC (012) Operaciones no gravadas / sujeto monotributo emisor.

La numeración fiscal es por POS y por tipo: la entidad Pos guarda contadores separados (nroFactA, nroFactB, nroFactC, nroNcr*, nroNdb*) más el punto de venta AFIP (ptoVta, y opcionalmente ptoVtaCae para CAEA). El número se obtiene bajo lock pesimista (SELECT … FOR UPDATE) e incremento transaccional (fix P0-1).

Condición IVA del comprador

TipoResponsableIva modela 11 categorías AFIP (con su id): RI (Responsable Inscripto), CF (Consumidor Final), MN (Monotributo), EX (Exento), MS, NC, PE, CE, LI, NA, MP. La condición del comprador determina la letra del comprobante (A requiere RI), si se calcula IVA y el id de receptor que se manda a AFIP.

CAE vs CAEA: las dos vías de autorización

graph TD
    PAGO["Ticket PAGADO"] --> CAE{¿AFIP online?}
    CAE -->|sí| SOAP["CAE por SOAP<br/>(por comprobante, online)"]
    CAE -->|no / timeout| CAEA["CAEA local<br/>(quincenal, anticipado)"]
    SOAP --> CLOSE["CLOSE (comprobante autorizado)"]
    CAEA --> CLOSE
    SOAP -->|falla y sin CAEA| VP["VOUCHERPENDING<br/>(durable)"]

CAE — Código de Autorización Electrónico (online, SOAP)

CaeService + CaeSoapClient autorizan un comprobante a la vez contra AFIP por SOAP 1.2:

  1. Consulta el último autorizado (feCompUltimoAutorizado) del punto de venta + tipo.
  2. Reuso (TSTK-016): si el último autorizado coincide con este ticket (importe + fecha), reutiliza su CAE en vez de pedir uno nuevo. Evita comprobantes huérfanos cuando una respuesta SOAP previa se perdió por timeout.
  3. Solicita el CAE (fecaeSolicitar) con el XML del comprobante: ente facturador (CUIT, razón social, condición IVA del vendedor), cliente, detalles de cada línea (cantidad, EAN, neto, impuesto interno, tipo IVA), resumen de alícuotas IVA y tributos.
  4. AFIP devuelve cae (14 dígitos) y caeFchVto (vencimiento del CAE).

Cada llamada SOAP se persiste completa (request + response) en TrxDetalle para auditoría.

CAEA — Autorización Electrónica Anticipada (local, quincenal)

El CAEA es un código que AFIP otorga por adelantado para una quincena (días 1–15 → orden 1; 16–fin → orden 2). Se obtiene en la madrugada (fuera de horario de venta) vía CaeaApiClient y se guarda en la entidad Comercio (caea1, caea1desde, caea1hasta, caea1topeinfo, caea1proceso).

Ventaja: no depende de que AFIP esté online en el momento de la venta. getValidCAEA valida que la fecha esté dentro de la vigencia de la quincena. El tope de informe (fchTopeInf) se chequea (fix TSTK-015): si venció, se alerta por log pero no se frena la venta.

Atomicidad del CAEA (P0-3)

La persistencia del CAEA para todos los comercios es una sola transacción (@Transactional(rollbackFor=Exception), invocada vía proxy con self-injection). Un fallo a mitad dejaría comercios con CAEA viejo y la cache desincronizada — y el CAEA autoriza todas las facturas de la quincena.

VOUCHERPENDING: pagado pero sin comprobante

Un ticket entra en VOUCHERPENDING cuando el pago ya fue aprobado (la plata está cobrada) pero el comprobante no se pudo autorizar todavía: timeout SOAP, AFIP caído sin CAEA disponible, o error de datos que requiere revisión.

Un VOUCHERPENDING es una transacción durable que espera reconciliación. La plata está; falta el papel fiscal.

Sale de ese estado por dos caminos: el reconciliador reintenta y autoriza (→ CLOSE), o un operador corrige y reintenta manualmente.

El reconciliador de pagos

PaymentReconciliador es un job @Scheduled que resuelve los pagos que quedaron indeterminados (INT-002) sin depender de que el POS esté poleando.

graph TD
    TICK["tick() cada ~30s"] --> FIND["busca PaymentAttempt INDETERMINADO<br/>(> recheck-delay, < max-intentos)"]
    FIND --> CHECK["CHECK_STATUS al procesador"]
    CHECK -->|APROBADO| FIN["finaliza ticket → CLOSE"]
    CHECK -->|DENEGADO/CANCELADO| DEN["marca DENEGADO (terminal)"]
    CHECK -->|aún desconocido| RETRY["incrementa intentos, reintenta"]
    RETRY -->|agota intentos| REV["REQUIERE_REVISION (humano)"]

Estados del intento (EstadoPagoIntento): INICIADO, CON_INTENTO, APROBADO, DENEGADO, INDETERMINADO, REQUIERE_REVISION. Solo APROBADO y DENEGADO son terminales.

La identidad del intento es el paymentAttemptId (PK, 64 chars), el mismo que genera la terminal para idempotencia de pago. Reintentar el mismo pago no crea una fila nueva: actualiza la existente.

Configuración: app.reconciler.delay-ms (cada cuánto corre), recheck-delay-ms (cuánto espera antes de reconciliar un intento), max-intentos, check-timeout-ms.

REQUIERE_REVISION es alerta humana

Un intento que agota los reintentos pasa a REQUIERE_REVISION: el sistema no pudo decidir solo si se cobró o no. Eso necesita una persona. El Cockpit lo muestra en el panel fiscal; no lo ignores.

QR fiscal

AfipUtil.getQrAfip arma la URL del QR fiscal de AFIP (escaneable, validable por cualquiera con el teléfono). Codifica en Base64 un JSON con: versión, fecha, CUIT del comercio, punto de venta, tipo y número de comprobante, importe, moneda, documento del receptor, y el código de autorización con su tipo (A = CAEA, E = CAE). El QR se renderiza con ZXing y va al voucher (ver El agregado Ticket → comprobante).

Panel fiscal del Cockpit

monitoring accede a este dominio solo vía FiscalStatusFacade (modulith-safe, sin tocar internals). GET /monitoring/fiscal expone un snapshot:

Campo Qué muestra Acción si está mal
serviceStatuses[AFIP] CAE online/offline Offline → fallback a CAEA.
serviceStatuses[CAEA] CAEA disponible Offline → no se pueden autorizar comprobantes.
voucherPendingCount Tickets cobrados sin comprobante > 0 → el reconciliador está trabajando.
reconciliadorBacklog.indeterminado Pagos a reconciliar El reconciliador reintenta.
reconciliadorBacklog.requiereRevision Agotaron reintentos Intervención humana.
caeaVigente.topeInforme Tope de informe de la quincena Pasado → AFIP no acepta más con ese CAEA.

Patrones y reglas

  • Zona horaria: la quincena del CAEA y la fecha del comprobante se calculan en America/Argentina/Buenos_Aires, no en la zona de la JVM (fix TSTK-024).
  • Transaccionalidad: numeración fiscal y persistencia de CAEA usan @Transactional(rollbackFor=Exception) + locks pesimistas.
  • Modulith: monitoring solo ve FiscalStatusFacade y TicketAggregationService; nunca tickets.service/tickets.repository.

Lo que todavía no existe

Según el código actual, está modelado pero no implementado:

  1. Notas de crédito/débito de anulación: VoucherType.ANULACION_PAGO existe, pero generar la nota fiscal de anulación no está desarrollado.
  2. Percepciones y retenciones: los tipos están en NucleoComponenteTipo, pero CaeService aún no arma esos tributos en la solicitud SOAP.
  3. Multi-comercio en CAEA: hay TODOs sobre soportar múltiples comercios.
  4. Push al POS desde el reconciliador: el reconciliador marca el intento APROBADO, pero el aviso al POS para que actualice su estado está pendiente.

Para el contexto conceptual de todo este ciclo (pago indeterminado, durabilidad, por qué el ticket no se cierra antes de tiempo), leé El ciclo de pago y fiscalización.