TiFactura · Notificador AFIP
De dónde partimos, cómo quedó unificado, en qué se diferencia cada cliente, y —lo más importante— cómo configurar cada uno sin que quede una sola duda.
01 — De dónde partió
Cada cliente (DINO, ALMACOR, BECERRA, TIZIANO, CARACOL, CYRE) era un proyecto Maven separado. El ~90 % del código era idéntico entre los seis; la variación real vivía en apenas 5 archivos. El problema no era el tamaño: era que cada cambio había que hacerlo seis veces. Un ajuste regulatorio de AFIP (como la RG 5782) significaba tocar, compilar y desplegar seis repos a mano — y con eso, seis oportunidades de que uno quedara distinto.
02 — Cómo se unificó
El servicio unificado tiene un núcleo que se escribe una sola vez (el armado del comprobante, el scheduler que notifica a AFIP, el cliente HTTP al Manager) y que no sabe nada de clientes: trabaja contra un contrato neutral, ComprobanteRecord. Cada cliente aporta solo dos piezas chicas —cómo lee su tabla y qué impuestos emite— que se enchufan según la variable TIFACTURA_CLIENT. El mismo jar arranca como DINO, como CARACOL o como cualquiera de los seis.
Puesto en fila, se ve qué es compartido (una vez para todos) y qué es por cliente:
03 — Arquitectura técnica
Stack: Spring Boot 2.4.1 sobre Java 8, contra SQL Server (driver mssql-jdbc), empaquetado como jar ejecutable con Maven (com.tipre:tifactura-sql-rest, versión CalVer — hoy 20260804, la misma que se ve en el cockpit y en /status). Un solo Application.java con @SpringBootApplication + @EnableScheduling; nada de perfiles Spring por cliente — la selección es por property, ver más abajo.
com.tipre.tifactura.sql.rest Application.java # @SpringBootApplication, @EnableScheduling contract/ ComprobanteRecord # read-model normalizado (interface) TributoStrategy # qué tributos emite el cliente (interface) core/ convert/ TrxAssembler # arma el Trx — compartido, sin lógica de cliente ComprobanteConverter # elige la TributoStrategy según tifactura.client read/ ComprobanteReader # puerto de lectura, esquema-agnóstico schedule/ ComprobanteNotifier # scheduler único (reemplaza los 6 TresuNotifier) status/ StatusHolder # estado en memoria para /status y el cockpit startup/ StartupConfigLogger # vuelca la config al log, secretos redactados client/ dino/ almacor/ becerra/ tiziano/ caracol/ cyre/ # 1 ComprobanteReader por cliente g1/ g2/ g3/ # las 3 TributoStrategy reales schema/retail/ Almacor|Becerra|Tiziano|Caracol|CyreTresu # @Entity + su *Repository model/ sql/ Tresu # entidad original de DINO, implementa ComprobanteRecord trx/ Trx, Tributo, AlicuotaIva, ... # DTO que viaja al Manager web/ StatusController # GET /status CockpitController # GET /cockpit, /, /doc client/ TiFacturaOnlineManagerClient # cliente HTTP hacia el Manager
El núcleo compartido no tiene ni un if (cliente == "dino"). Toda la variación entra por tres puntos de extensión, cada uno resuelto por Spring según tifactura.client:
Es la interfaz que hace posible que TrxAssembler no sepa nada de clientes. Expone getters neutrales — identidad del comprobante (id, ptoVta, tipoFactura, nroComprobante, cuit, fechaComprobante, caea, clientType), reconciliación RG 5782/CAE-CAEA (nroSuc, nroPos, nroTicket), comprobante asociado para NC/ND, importes, tributos (IIBB, Com.Ind., Percepción IVA, Impuesto Interno) y el desglose de IVA (21/10,5/exento). Cada entidad @Entity por cliente implementa la interfaz delegando a sus propios getters existentes — un getter que no aplica a esa familia de esquema devuelve null/0, nunca lanza.
La property tifactura.client (env TIFACTURA_CLIENT) decide dos cosas a la vez, en dos lugares distintos del código:
El patrón ya se repitió cinco veces (ALMACOR, BECERRA, TIZIANO, CARACOL, CYRE detrás de DINO); agregar un séptimo cliente es mecánico:
04 — Los seis clientes, con nombre y apellido
Esta es la tabla maestra. El color del borde indica la base: TipreRetail · de_tresu o TipreSucursales · detresu.
| Cliente | Base · tabla | Impuestos que emite | Cambios respecto de su versión vieja | Verificado BD real |
|---|---|---|---|---|
| DINO | TipreRetail · de_tresu | IIBB · Com.Ind. · Percepción IVA | — es la base de referencia (ya mandaba todo bien) | ✓ |
| ALMACOR | TipreRetail · de_tresu | IIBB · Com.Ind. · Impuesto Interno | — ya mandaba NroSuc/Pos/Ticket y CbteFchHsGen | ✓ |
| BECERRA | TipreRetail · de_tresu | IIBB · Com.Ind. · Impuesto Interno | + NroSuc/Pos/Ticket | ✓ |
| TIZIANO | TipreRetail · de_tresu | IIBB · Com.Ind. · Impuesto Interno | + NroSuc/Pos/Ticket · total desde fprecioTotal (preservado) | ✓ |
| CARACOL | TipreSucursales · detresu | IIBB · Com.Ind. · Percepción IVA + Impuesto Interno | + NroSuc/Pos/Ticket · IIBB ÷100 (corregido) | falta BD propia |
| CYRE | TipreSucursales · detresu | IIBB · Com.Ind. · Impuesto Interno | + NroSuc/Pos/Ticket · IIBB ÷100 (corregido) | ✓ |
Donde varios clientes comparten una característica, acá están juntos — sin códigos, con nombre:
Nota técnica (para el equipo de desarrollo): internamente la estrategia de tributos se llama G1 (Percepción IVA = DINO), G2 (Impuesto Interno = ALMACOR, BECERRA, TIZIANO, CYRE) y G3 (ambos = CARACOL). Para configurar un cliente no necesitás saber esto — solo el nombre del cliente. Los G1/G2/G3 son detalle de implementación.
05 — Ajustes y correcciones
Unificar preservó el comportamiento de cada cliente byte por byte, salvo estos cambios deliberados — decididos, no accidentales. Cada uno quedó auditado contra un test que congela el "antes" y el "esperado con el cambio".
| Cambio | Qué es | A quién afecta | Tipo |
|---|---|---|---|
| CbteFchHsGen siempre | La fecha/hora real de generación (RG 5782, obligatoria) ahora se informa siempre, sin depender de un flag. | Los 6 (antes 4 lo condicionaban a modo_dual) | mejora |
| NroSuc / NroPos / NroTicketPos | La identidad física del ticket (para la reconciliación CAE-CAEA) ahora se envía siempre. | BECERRA · TIZIANO · CARACOL · CYRE (no los mandaban) | mejora |
| IIBB ÷100 | La alícuota de Ingresos Brutos se divide por 100, como toda la flota. Antes se enviaba ×100. | CARACOL · CYRE | bug corregido |
| Fecha de comprobante asociado | Preservado como está hoy (no se setea en NC/ND). Pendiente de datos de producción para decidir. | CARACOL · CYRE | preservado |
| importeTotal desde fprecioTotal | Preservado: TIZIANO toma el total de una columna, no lo recalcula. Falta confirmar contra prod que coincide con la suma. | TIZIANO | preservado |
06 — Instalación
Cada cliente es una carpeta con el mismo jar adentro y su propio config al lado. El jar nunca lleva credenciales ni datos de un cliente en particular — todo lo que lo distingue vive fuera, en config\application-prod.yml.
C:\TiFactura\<cliente>\ tifactura-sql-rest-20260804.jar # el mismo binario para los 6 clientes config\ application-prod.yml # copiado de config-template\ y editado para ESTE cliente run.bat # opcional — ver Sección 07 log\ # se crea solo al primer arranque
07 — Ejecución / operación
Pensado para no tener que acordarse de nada. Parado en la carpeta que tiene el jar al lado:
Sirve para el layout de instalación de la Sección 06 (jar + config\ al lado, sin target\). Hay que estar parado en esa carpeta al ejecutar:
cd C:\TiFactura\almacor java -jar tifactura-sql-rest-20260804.jar
java -jar tifactura-sql-rest-20260804.jar --spring.config.additional-location=file:C:/TiFactura/almacor/config/
Al levantar, StartupConfigLogger vuelca en el log toda la configuración efectiva (perfil activo + cada property de la app), una sola vez, apenas el contexto de Spring está listo. Cualquier clave cuyo nombre contenga password, secret, token, credential u otro marcador equivalente se imprime como **** — nunca en texto plano. Es la forma más rápida de confirmar, con solo mirar el log, qué cliente es esta instancia y contra qué está apuntando.
Consola (STDOUT) y archivo, según logback.xml: rotación diaria o cada 50 MB, con 30 días de historial. La ruta del archivo la fija logging.file.name — en la plantilla de prod es ./log/tifactura.log, relativa a la carpeta desde donde se ejecuta el jar (por eso importa el directorio de trabajo, igual que con el config externo).
08 — Configuración por cliente
Toda la config entra por variables de entorno — no hay nada hardcodeado. Para que una instancia sea un cliente u otro, cambian solo tres cosas: qué cliente es, a qué base pega, y qué IDs de tributo usa. El resto es común.
| Cliente | TIFACTURA_CLIENT | DATABASE_NAME | ID_TRIBUTO_PERCIVA | ID_TRIBUTO_IMPINTERNO |
|---|---|---|---|---|
| DINO | dino | TipreRetail | usa ✓ | no usa — |
| ALMACOR | almacor | TipreRetail | no usa — | usa ✓ |
| BECERRA | becerra | TipreRetail | no usa — | usa ✓ |
| TIZIANO | tiziano | TipreRetail | no usa — | usa ✓ |
| CARACOL | caracol | TipreSucursales | usa ✓ | usa ✓ |
| CYRE | cyre | TipreSucursales | no usa — | usa ✓ |
Los IDs marcados "no usa" pueden quedar sin definir (toman 0 por defecto): esa estrategia no se ejecuta para ese cliente. Definir el que corresponde con el valor real de ese cliente en su base.
# Base de datos (host/puerto propios de cada cliente) DATABASE_HOST=... DATABASE_PORT=1433 DATABASE_USER=... DATABASE_PASSWORD=... # Operación NOTIFY_TO_AFIP_CRON_SCHEDULER=0 0/5 * * * ? TIFACTURAONLINEMANAGER_SERVICE_URL=http://.../manager PROCESS_MAX_RESULTS=50 LOGGING_FILE_NAME=/var/log/tifactura.log # Tributos/IVA comunes (IDs reales de AFIP en Manager) ID_ENTE_FACTURADOR=… ID_TRIBUTO_ING_BRUTO=… ID_TRIBUTO_COMIND=… ID_IVA_0=… ID_IVA_105=… ID_IVA_21=…
Dos formas de fijar estas mismas claves, según cómo se instale (ver Sección 06): variables de entorno (arriba, las consume application-prod.properties empaquetado en el jar) o directamente en config\application-prod.yml externo, con notación anidada YAML. Son la misma configuración — el YML externo, cuando existe, tiene prioridad y no necesita ninguna variable de entorno.
| Clave (YAML) | Env var equivalente | Qué es | ¿Cambia por cliente? |
|---|---|---|---|
| tifactura.client | TIFACTURA_CLIENT | Cuál de los 6 clientes es esta instancia — decide el ComprobanteReader y la TributoStrategy (Sección 03). | sí |
| spring.datasource.url | DATABASE_HOST/PORT/NAME | Cadena JDBC hacia SQL Server (host, puerto, nombre de base). | sí |
| spring.datasource.username | DATABASE_USER | Usuario de esa base. | sí |
| spring.datasource.password | DATABASE_PASSWORD | Password de esa base. Nunca va dentro del jar ni se commitea. | sí |
| notify.to.afip.cron.scheduler | NOTIFY_TO_AFIP_CRON_SCHEDULER | Expresión cron de cuándo corre ComprobanteNotifier. | no (normalmente) |
| tifacturaonlinemanager.service.url | TIFACTURAONLINEMANAGER_SERVICE_URL | URL base del TiFacturaOnline Manager al que se notifica. | no (por Manager, no por cliente) |
| process.max.results | PROCESS_MAX_RESULTS | Tope de comprobantes que se traen por punto de venta en cada corrida. | no |
| id.ente.facturador | ID_ENTE_FACTURADOR | Id del ente facturador (comercio) en el Manager/AFIP. | no (uno por comercio) |
| id.tributo.ing.bruto | ID_TRIBUTO_ING_BRUTO | Id AFIP del tributo Ingresos Brutos. | no |
| id.tributo.comind | ID_TRIBUTO_COMIND | Id AFIP del tributo Comercio e Industria. | no |
| id.tributo.perciva | ID_TRIBUTO_PERCIVA | Id AFIP de Percepción IVA. | solo lo usan DINO y CARACOL |
| id.tributo.impinterno | ID_TRIBUTO_IMPINTERNO | Id AFIP de Impuesto Interno. | todos menos DINO |
| id.iva.0 / id.iva.105 / id.iva.21 | ID_IVA_0 / ID_IVA_105 / ID_IVA_21 | Ids AFIP de las tres alícuotas de IVA (0 %, 10,5 %, 21 %). | no |
| logging.file.name | LOGGING_FILE_NAME | Ruta del archivo de log de esta instancia. | no (salvo que convenga separarlos) |
Las únicas claves que de verdad distinguen a un cliente de otro son las cuatro primeras filas (marcadas "sí") más id.tributo.perciva / id.tributo.impinterno según qué tributo emita — coincide exactamente con la tabla de la subsección siguiente.
Cada tarjeta muestra solo lo que distingue a ese cliente. Combinalo con el bloque común de arriba.
TIFACTURA_CLIENT=dino DATABASE_NAME=TipreRetail ID_TRIBUTO_PERCIVA=<real> # ID_TRIBUTO_IMPINTERNO no aplica
TIFACTURA_CLIENT=almacor DATABASE_NAME=TipreRetail ID_TRIBUTO_IMPINTERNO=<real> # ID_TRIBUTO_PERCIVA no aplica
TIFACTURA_CLIENT=becerra DATABASE_NAME=TipreRetail ID_TRIBUTO_IMPINTERNO=<real> # ID_TRIBUTO_PERCIVA no aplica
TIFACTURA_CLIENT=tiziano DATABASE_NAME=TipreRetail ID_TRIBUTO_IMPINTERNO=<real> # ID_TRIBUTO_PERCIVA no aplica
TIFACTURA_CLIENT=caracol DATABASE_NAME=TipreSucursales ID_TRIBUTO_PERCIVA=<real> ID_TRIBUTO_IMPINTERNO=<real>
TIFACTURA_CLIENT=cyre DATABASE_NAME=TipreSucursales ID_TRIBUTO_IMPINTERNO=<real> # ID_TRIBUTO_PERCIVA no aplica
09 — El cockpit
Cada instancia expone un tablero liviano en /cockpit (y el dato crudo en /status). Muestra de un vistazo qué cliente es, qué impuestos emite, contra qué base pega, y cómo salió la última corrida. Se autorefresca solo cada 60 s. Así se ve corriendo como ALMACOR:
Es parte del propio jar — no depende de nada externo (la librería del tablero, ArrowJS, también viaja adentro). Cambiás TIFACTURA_CLIENT y el mismo cockpit muestra otro cliente. Link en vivo: /cockpit.
| Ruta | Qué devuelve | ¿Toca la base? |
|---|---|---|
| GET /cockpit | El tablero visual (HTML) de esta sección: cliente activo, tributos, próxima notificación, resultado de la última corrida y la versión del build. | no |
| GET / | Redirige a /cockpit (misma respuesta). | no |
| GET /doc | Este manual — el mismo doc.html autocontenido, servido por la app. | no |
| GET /status | El JSON crudo detrás del cockpit: client, version, cron, nextNotification, datos de la última corrida (lastRunStarted/Finished/Processed/Errors/Outcome) y el bloque config (esquema, estrategia de tributos, datasource, IDs — sin passwords). | no |
Ninguna de las cuatro rutas ejecuta una consulta SQL: /status y /cockpit leen únicamente StatusHolder (en memoria, lo actualiza el scheduler) y properties ya resueltas al arrancar — cero carga y cero bloqueos sobre la base del cliente, la tengan del tamaño que la tengan. /status también expone tifactura.version (la misma versión del pom.xml que se ve como pill en el cockpit), así que un vistazo alcanza para saber qué build está corriendo cada instancia.
10 — Troubleshooting
| Síntoma | Qué está pasando | Qué hacer |
|---|---|---|
| No arranca, falla apenas se ejecuta | Falta config\application-prod.yml al lado del jar. El perfil prod está fijo en application.properties (spring.profiles.active=prod) — sin el YML externo, cae al application-prod.properties empaquetado, que espera las variables de entorno DATABASE_HOST/etc., y sin ellas Spring no puede resolver los placeholders. | Si usás run.bat, el primer arranque ya lo crea solo (Sección 07) y se frena para que lo edites. Si corrés java -jar a mano, copiá config-template\application-prod.yml a config\application-prod.yml y editalo antes de levantar el servicio. |
| "Unknown tifactura.client" al arrancar | tifactura.client tiene un valor que no es ninguno de los 6 reconocidos. ComprobanteConverter lanza IllegalStateException en su constructor — es intencional: preferible que no arranque a que arranque sin estrategia de tributos. | Revisar el valor exacto de tifactura.client en el YML — debe ser, en minúsculas: dino, almacor, becerra, tiziano, caracol o cyre. |
| "Invalid column name" al consultar | La base real de ese cliente no tiene alguna columna que el read-model espera (por ejemplo, las columnas de reconciliación nropos/nroticket o inropos/inroticket — ver Sección 11, siguen sin confirmar en las 4 bases de producción). | Verificar el esquema real de esa base con el integration test correspondiente, pasando -Dit.db=true (por ejemplo mvn -Dit.db=true test apuntando al datasource de ese cliente) antes de poner la instancia en producción. |
| Password en texto plano | — | config\application-prod.yml queda fuera del jar y fuera del control de versiones (la carpeta config\ local está en .gitignore) — es el único lugar donde debe vivir la contraseña real de la base. Nunca hardcodearla en application-*.properties ni commitearla: es justamente lo que se corrigió en application-dev.properties (antes traía credenciales de desarrollo hardcodeadas). |
11 — Estado