Arquitectura del sistema¶
Esta página explica cómo está armado TiFacturaOnlineNext y, sobre todo, por qué. Para los datos exactos —versiones, dependencias, rutas de endpoints— andá a Backend TiFacturaOnlineNext. Acá te cuento la forma del sistema y la lógica detrás de las decisiones, que en este proyecto está dominada por una sola fuerza: fue un legacy Seam/JBoss que se migró a Spring Boot sin reescribir el negocio. Si entendés eso, entendés casi todo lo demás.
Una foto de 10.000 metros¶
TiFacturaOnlineNext es un monolito Spring Boot: un único proceso (com.tipre.tifacturaonlinemanager.Application, anotado @SpringBootApplication + @EnableScheduling) que arranca un Tomcat embebido, conecta a SQL Server, publica endpoints SOAP y REST, y agenda jobs Quartz. No hay microservicios, no hay colas, no hay broker: la complejidad no está en la topología, está en el diálogo fiscal con AFIP y en preservar el comportamiento del legacy.
graph TD
POS["POS / sistema de ventas"]
OPS["Cockpit UI<br/>(operador)"]
subgraph App["TiFacturaOnlineNext (un proceso Spring Boot 4)"]
direction TB
subgraph Edge["Capa de borde (adaptadores)"]
SOAP["SOAP · CXF<br/>TrxWS · CaeaWS · TiFacturaOnlineManagerWS"]
REST["REST · Spring MVC<br/>/caeaWS · /fecaeaSolicitar · /api/cockpit"]
QZ["Quartz<br/>(tabla Tareas)"]
end
subgraph Biz["Lógica de negocio (lift & shift)"]
SVC["Services de facturación<br/>FacturacionService · CaeaGestion · ..."]
CLI["Cliente AFIP<br/>WSAA · WSFE · firma CMS"]
end
subgraph Compat["seam-compat (paquete org.jboss.seam.*)"]
HOME["EntityHome / EntityQuery / Controller"]
COMP["Component · ScopeType · @Name / @Scope"]
end
SOAP --> SVC
REST --> SVC
QZ --> SVC
SVC --> CLI
SVC --> HOME
HOME --> COMP
end
DB[("SQL Server<br/>esquema lo administra el cliente")]
AFIP["AFIP / ARCA<br/>WSAA + WSFEv1"]
POS -->|SOAP + REST| Edge
OPS -->|REST| REST
SVC -->|JPA / Hibernate 7| DB
CLI -.->|SOAP firmado · red externa| AFIP
Tres capas, una lectura
De afuera hacia adentro: la capa de borde (SOAP/REST/Quartz) traduce el mundo externo a llamadas internas. La lógica de negocio es código legacy movido casi sin cambios. Y debajo de todo está seam-compat, que hace que ese código legacy crea que todavía corre sobre Seam. El cliente AFIP es el único que sale a la red externa.
Por qué hay un paquete org.jboss.seam en el repo¶
Esta es la decisión arquitectónica que más sorprende al entrar. El código de negocio legacy usa la API de Seam: anotaciones @Name, @Scope, accesores estáticos Component.getInstance(...), clases base EntityHome / EntityQuery. Reescribir todos esos import org.jboss.seam.* sería cambiar código de negocio — exactamente lo que la regla de oro prohíbe.
La solución fue construir un shim: reimplementar el subconjunto de Seam que la app usa, manteniendo los mismos nombres de paquete y clase, pero por dentro delegando en Spring. El código legacy compila y corre sin que se le toque una línea.
graph LR
LEGACY["Código de negocio<br/>com.tipre.tifacturaonlinemanager.*<br/>(usa import org.jboss.seam.*)"]
COMPAT["seam-compat<br/>org.jboss.seam.*<br/>(reimplementación)"]
SPRING["Spring<br/>ApplicationContext · Scopes · Tx"]
LEGACY -->|"compila contra<br/>el mismo paquete"| COMPAT
COMPAT -->|"delega en"| SPRING
Algunos puentes concretos, verificables en el repo de código:
| Pieza Seam | Reimplementación en seam-compat |
Cómo funciona |
|---|---|---|
org.jboss.seam.Component.getInstance(name) |
org/jboss/seam/Component.java |
Guarda un ApplicationContext estático y traduce a ctx.getBean(name). |
@Name + @Scope |
org/jboss/seam/annotations/* + config/SeamComponentRegistrar.java |
Un BeanDefinitionRegistryPostProcessor escanea @Name, registra un bean Spring y mapea el scope Seam (EVENT, STATELESS, SESSION, APPLICATION). |
| Scope EVENT de Seam | config/SeamEventScope.java + SeamEventScopeFilter.java |
Un scope custom "seamEvent" por hilo, limpiado al final de cada request por un servlet filter. |
EntityHome / EntityQuery / Controller |
org/jboss/seam/framework/* |
Clases base que el negocio extiende, preservando su semántica exacta (ver invariantes). |
@org.jboss.seam.annotations.Transactional |
config/SeamTransactionAnnotationParser.java |
Hace que Spring reconozca la anotación de Seam y mapee su TransactionPropagationType. |
El detalle profundo de esta capa está en La capa seam-compat. Lo importante a nivel arquitectura: seam-compat es la pieza que hace posible no reescribir. Crece por demanda; el código de negocio no se toca.
El scope EVENT es el corazón del modelo de vida
En Seam, el scope EVENT es "una instancia por invocación" (un request HTTP, una llamada SOAP, una ejecución de job). SeamComponentRegistrar mapea la mayoría de los componentes legacy a un scope Spring custom "seamEvent", y SeamEventScopeFilter lo limpia al terminar cada request. Por eso Component.getInstance(...) dentro de un método SOAP funciona: el filtro aplica a todas las requests HTTP, incluido el servlet de CXF.
La capa de borde: tres superficies, un núcleo¶
El sistema expone tres formas de entrar, y todas terminan en el mismo núcleo de negocio.
SOAP — el contrato congelado (Apache CXF)¶
Tres endpoints SOAP 1.2, publicados con CXF en modo contract-first: TrxWS, CaeaWS y TiFacturaOnlineManagerWS. La decisión clave —visible en config/CxfSoapConfig.java— es que cada endpoint se publica con setWsdlLocation("wsdl/X.wsdl"), sirviendo el WSDL EXACTO del baseline de producción, no el que CXF regeneraría. Es la vía más segura para el diff de WSDL bloqueante que exige el proyecto.
graph TD
BASE["WSDL baseline de producción<br/>src/main/resources/wsdl/*.wsdl"]
GEN["cxf-codegen-plugin (wsdl2java)<br/>genera el SEI + DTOs JAXB"]
EP["Endpoints CXF<br/>TrxWsEndpoint · CaeaWsEndpoint · TfomWsEndpoint"]
SERVED["WSDL servido en runtime<br/>setWsdlLocation(classpath wsdl)"]
BASE --> GEN --> EP
BASE --> SERVED
EP --> SERVED
El WSDL no se negocia
El cxf-codegen-plugin genera el SEI desde los WSDL baseline en generate-sources; los nombres de operación (saveTrx, fecaeaSolicitar, getVersion), namespaces y binding (SOAP 1.2) son contrato externo. El proyecto valida con un gate WsdlDiff (SPEC-008) que el WSDL servido sea idéntico al baseline. Tocar eso rompe a los consumidores. Los detalles en API SOAP (CXF).
Detalle no obvio, documentado en el propio CxfSoapConfig.java: el handler JAX-WS legacy (WSServerLogHandler) no se porta, porque su logging estaba muerto/comentado en el legacy y su única función viva —montar el contexto Seam— ya la cubre SeamEventScopeFilter. Replicar el legacy fielmente acá significa no logear el cuerpo SOAP, igual que producción.
REST — Spring MVC¶
Reemplaza al RESTEasy/JAX-RS del legacy con controladores Spring MVC. Las rutas verificadas:
| Ruta | Método | Qué hace |
|---|---|---|
GET / |
GET | Devuelve el string de versión (v20250328). |
GET /caeaWS/getCaea |
GET | Entrega los CAEA vigentes (y, con nrosuc+nropos, el punto de venta CAE/CAEA y el CUIT). Paso 1 del flujo del POS. |
POST /fecaeaSolicitar |
POST | Registra una Trx emitida offline con CAEA (idempotente). |
GET /feCompUltimoAutorizado |
GET | Consulta a AFIP el último comprobante autorizado por punto de venta y tipo. |
/api/cockpit/* |
GET/POST/… | El panel de operación (ver más abajo). |
El detalle en API REST.
Quartz — los jobs que viven en la base¶
El scheduling no está hardcodeado: las tareas se leen de la tabla Tareas en SQL Server. Al arrancar, action/quartz/TareaScheduler.java lee la tabla, y por cada fila hace Class.forName(...) para resolver la clase del job y la agenda con su expresión cron. Esto reproduce el modelo del legacy, donde el cron vivía en la base de datos.
graph TD
START["Arranque de la app"] --> READ["TareaScheduler lee tabla Tareas"]
READ --> LOOP["Por cada Tarea:<br/>Class.forName(jobProcessor)"]
LOOP --> SCHED["Agenda JobDetail + CronTrigger<br/>con Tarea.programacion (cron)"]
SCHED --> RUN["El job corre"]
RUN --> LOCK["CommonJob: toma lock ThreadSafety<br/>(1 ejecución por Tarea)"]
LOCK --> BITA["Persiste bitácora en JobEjecucion<br/>(inicio · fin · resultado · error)"]
Los jobs principales (en action/job/):
| Job | Qué hace |
|---|---|
TokenManagerJob |
Renueva proactivamente el token WSAA en background, para que las emisiones nunca peguen contra un token frío. |
CaeaManagerJob |
Gestiona los CAEA: los consulta/solicita a AFIP y los persiste. |
ComprobantesEmitidosJob |
Informa a AFIP en diferido los comprobantes emitidos con CAEA (FECAEARegInformativo). |
PvSinMovimientosJob |
Informa a AFIP los puntos de venta sin movimientos para CAEA vencidos. |
NotaCreditoReconciliacionJob |
Detecta tickets doblemente facturados (CAE + CAEA) y emite la nota de crédito para anular. |
El locking lo provee model/tarea/ThreadSafety.java: garantiza una sola ejecución por Tarea a la vez. Más en Jobs Quartz.
El núcleo de negocio y el borde con AFIP¶
Por dentro de la capa de borde, los services orquestan la facturación y delegan el diálogo fiscal en el cliente AFIP (action/ws/client/). Ese borde es el más complejo del sistema y el único que sale a la red externa. El flujo, simplificado:
graph TD
REQ["Pedido de comprobante"] --> TOK{"¿Token WSAA<br/>vigente?"}
TOK -->|"sí (cache / DB)"| SIGN
TOK -->|"no"| LOGIN["WSAA LoginCMS<br/>firma CMS (BouncyCastle, SHA1, encapsulado)"]
LOGIN --> SIGN["Arma el pedido WSFEv1"]
SIGN --> CALL["FECAESolicitar a AFIP"]
CALL --> OK{"¿AFIP autorizó?"}
OK -->|"sí"| CAE["Persiste CAE en la Trx<br/>(cae · caeFchVto · caeBarCode)"]
OK -->|"AFIP caído / error"| CAEA["Fallback: emite con CAEA<br/>e informa en diferido (job)"]
El token WSAA se obtiene con una estrategia de capas (memoria → DB → login nuevo) persistiendo token/sign/expirationTime en la entidad EnteFacturador, de modo que sobrevive a reinicios. La firma es CMS SHA1 encapsulado con provider BouncyCastle "BC" — una invariante sagrada: la salida firmada tiene que ser válida para AFIP. Todo el detalle en Cliente AFIP y el flujo CAE/CAEA completo en El ciclo de facturación.
TLS hacia AFIP: inseguro a propósito
El legacy hablaba con AFIP detrás de un nginx con trust-all. Para mantener paridad, el sistema arranca con afip.tls-insecure=true por defecto: confía en cualquier certificado y hostname del servidor AFIP (construido en action/ws/client/common/ArcaTlsContextFactory.java, que loguea un WARNING fuerte). No es un descuido: es reproducir el comportamiento de producción. Si se quiere TLS verificado, se pone en false.
El Cockpit: operar sin tocar la base¶
Sobre el mismo proceso vive un panel de operación (paquete cockpit/), expuesto bajo /api/cockpit. No es parte del contrato con el POS: es para que un operador vea la salud del sistema —métricas de emisión, estado de AFIP, jobs agendados, últimas transacciones, comprobantes pendientes y errores— y administre la tabla SucPosPV (la relación sucursal+POS → puntos de venta AFIP). El ABM admin está protegido por un secreto de header (X-Cockpit-Secret). Más en Cockpit.
Persistencia: el esquema no es nuestro¶
Un principio que atraviesa toda la arquitectura: la app nunca genera ni altera DDL. El esquema SQL Server lo administra el cliente (hbm2ddl=none). Las entidades JPA (model/) mapean tablas existentes con nombres literales en PascalCase (Trx, Caeas, EntesFacturadores, TiposComprobante, Tareas). Por eso los "enums AFIP" del dominio (TipoComprobante, TipoDocumento, etc.) en realidad no son enums de Java: son entidades JPA que mapean tablas de catálogo (@Table(name = "TiposComprobante")). Esto es fácil de malinterpretar.
Los cambios de esquema van por fuera, en scripts
Las alteraciones de tabla necesarias para la migración viven como scripts SQL versionados en src/main/resources/db/migration/ (por ejemplo add-JobEjecucion.sql, add-cert-EntesFacturadores.sql, add-validadoArca-SucPosPV.sql). Son SQL para correr a mano contra la base del cliente, no migraciones automáticas que la app ejecute. Los datos exactos del modelo están en Modelo de dominio y Persistencia.
Lo que no podés romper¶
La arquitectura está al servicio de una restricción: comportamiento observable idéntico al legacy. Tres barreras lo hacen cumplir.
| Barrera | Qué protege |
|---|---|
| Diff de WSDL bloqueante (SPEC-008) | Ningún cambio que altere el WSDL publicado se mergea sin aprobación. |
| Invariantes de comportamiento | Flush MANUAL, EntityHome.update() que sólo hace flush (no merge), firma CMS SHA1, lookups id desc + maxResults(1), idempotencia… Ver Invariantes sagradas. |
| Suite de paridad (SPEC-016) | Tests que comparan la salida (CAE, PDF, email) contra un golden set real y contra homologación AFIP. |
El reflejo que tenés que entrenar
Cuando algo no compila o un test falla, el instinto es "mejorar" la lógica. Acá es al revés: si una clase de negocio no compila, probablemente falta algo en seam-compat → agregalo ahí. Si un test de paridad falla, el fix iguala al legacy, no lo corrige. La arquitectura entera está diseñada para que el negocio quede intacto.
Por dónde seguir¶
- Migración Seam/JBoss → Spring Boot — la estrategia completa, fase por fase.
- La capa seam-compat — cómo funciona el shim por dentro.
- Invariantes sagradas — lo que no se toca.
- Glosario del dominio — todo el vocabulario AFIP y técnico.
- Backend TiFacturaOnlineNext — los datos exactos de stack y build.