Las invariantes sagradas¶
La regla de oro del proyecto es una sola: no se altera la lógica de negocio. La salida funcional —CAE/CAEA emitidos, WSDL publicado, contrato REST, PDFs, emails— tiene que ser byte-equivalente o funcionalmente idéntica a la del sistema en producción (CLAUDE.md, AGENTS.md).
Esta página enumera las invariantes de comportamiento: las reglas que el código nuevo debe preservar al pie de la letra, porque romperlas cambia algo observable hacia afuera (AFIP, el POS, el cliente REST/SOAP) aunque la app compile y los tests obvios pasen. La lista canónica vive en REFERENCE.md §5; acá la explicamos: qué es, por qué existe, dónde vive en el código.
Cómo leer esta página
Cada invariante es un contrato. Si una tarea te lleva a tocar una de estas reglas, la respuesta por defecto es pará y preguntá (CLAUDE.md §0). No son preferencias de estilo: son el límite entre "migración fiel" y "sistema distinto que parece el mismo".
1. Flush MANUAL del EntityManager¶
Qué es. El EntityManager no auto-flushea a mitad de transacción. Los cambios bajan a la base sólo cuando alguien lo pide explícitamente (o en el commit). En JPA no existe un modo "MANUAL" puro; el equivalente práctico es FlushModeType.COMMIT.
Por qué existe. El legacy declaraba el contexto de persistencia con default-flush-mode="MANUAL" (components.xml). El código de negocio fue escrito asumiendo ese comportamiento: da por hecho que una entidad modificada no se persiste hasta que un *Home flushea. Si pusieras AUTO, Hibernate flushearía antes de cada query, cambiando el momento y el orden en que las filas llegan a la DB —y con eso, qué ve cada lectura intermedia, qué dispara las cascadas, y el comportamiento ante errores. Es un cambio silencioso y profundo.
Dónde vive. SeamCompatConfig.seamEntityManager:
@Bean(name = "entityManager")
public EntityManager seamEntityManager(EntityManagerFactory emf) {
EntityManager em = SharedEntityManagerCreator.createSharedEntityManager(emf);
try { em.setFlushMode(FlushModeType.COMMIT); } catch (Exception ignored) {} // MANUAL ~ COMMIT
return em;
}
Como consecuencia directa, los flush() tienen que ser explícitos en el CRUD (ver invariante 2). El comentario // INVARIANTE 5.1 (MANUAL ~ COMMIT) está en el código a propósito.
No poner AUTO
Es el default de JPA y la tentación obvia. No. COMMIT es deliberado. Ver REFERENCE.md §5.1.
2. EntityHome.update() hace SOLO flush, no merge¶
Qué es. El método update() del CRUD únicamente ejecuta getEntityManager().flush(). No llama a merge(). persist() y remove() también flushean explícitamente (porque el EM está en modo MANUAL, invariante 1).
Por qué existe. El legacy asume que la entidad que se actualiza ya está managed en el EntityManager del evento (fue leída o persistida en la misma invocación). Bajo esa premisa, alcanza con flushear para bajar los cambios. Si agregaras un merge():
- Reatarías grafos detached que el legacy nunca reataba, cambiando qué entidades quedan managed.
- Dispararías cascadas de persistencia distintas.
- Cambiarías la identidad de instancia (merge devuelve una copia managed), rompiendo código que sigue usando la referencia original.
Es exactamente el tipo de "mejora" que un dev de Spring haría por reflejo y que rompe la paridad.
Dónde vive. org.jboss.seam.framework.EntityHome:
public String persist() {
getEntityManager().persist(getInstance());
getEntityManager().flush(); // flush explícito (modo MANUAL)
this.id = readGeneratedId(getInstance());
return "persisted";
}
public String update() {
getEntityManager().flush(); // INVARIANTE 5.2: SOLO flush, sin merge
return "updated";
}
Este contrato tiene un eco directo en FacturacionService.fecaeSolicitarNextGen: tras un REQUIRES_NEW la Trx queda detached, y como update() no hace merge, el código re-lee el managed con em.find(Trx.class, id) antes de escribir. Si update() hiciera merge, ese find sobraría —pero entonces el comportamiento sería otro.
La línea que NO se agrega
getEntityManager().merge(getInstance()); en update(). Nunca. REFERENCE.md §5.2.
3. Lookup del último comprobante: id desc + maxResults(1)¶
Qué es. Para encontrar "el comprobante de esta clave" se ordena por id desc y se toma el primero (maxResults(1)): el más reciente. La clave es EnteFacturador + PtoVta + TipoComprobante + Comprobante + ComprobanteRelacionado.
Por qué existe. Puede haber más de una fila para la misma clave de comprobante (reintentos, reprocesos, históricos). El negocio define "la Trx vigente" como la última insertada. Ordenar de otra forma —o no limitar a 1— devolvería una fila distinta, y con eso una respuesta de idempotencia distinta: podrías devolver un intento viejo rechazado en vez del CAE bueno, o explotar con un NonUniqueResult. El orden id desc + maxResults(1) es la definición operativa de "el actual".
Dónde vive. ParentEntityManager.getTrx:
TrxList.getInstance().setEnteFacturador(enteFacturador);
TrxList.getInstance().setPtoVta(ptoVta);
TrxList.getInstance().setTipoComprobante(tipoComprobante);
TrxList.getInstance().setComprobante(comprobante);
TrxList.getInstance().setComprobanteRelacionado(comprobanteRelacionado);
TrxList.getInstance().setOrder("id desc"); // el ÚLTIMO para EF+PV+Cbte
TrxList.getInstance().setMaxResults(1);
List<Trx> resultList = TrxList.getInstance().getResultList();
El mismo patrón se repite en las búsquedas por clave de negocio de NextGen y CAEA (FacturacionService.findByNextGenKey, CaeaNotificacionService.findExistingCaea): siempre order by id desc + maxResults(1). Es un patrón, no un caso aislado.
Por qué TrxList puede armar este filtro sin código a medida
TrxList declara sus restricciones como expresiones EL (comprobante=#{trxList.comprobante}, etc.) y el shim las evalúa: las que dan no-null entran al WHERE. Por eso setear sólo algunos campos filtra por esos campos. El motor está en EntityQuery del shim (ver seam-compat.md).
4. Idempotencia del CAE¶
Qué es. Antes de pedir un CAE a AFIP, se consulta si el comprobante ya existe en DB con CAE. Si existe, se devuelve el existente sin ir a AFIP. El short-circuit es exacto: misma respuesta {trx, cae} que daría la emisión, pero sin tocar la red.
Por qué existe. AFIP autoriza un comprobante una sola vez. Reintentos del POS, timeouts, reenvíos por red flaky: todos pueden llegar dos veces. Sin idempotencia, el segundo intento pediría un CAE para un comprobante ya facturado → error de AFIP o, peor, una doble facturación. El short-circuit convierte "ya lo hice" en una lectura barata e inocua.
Dónde vive. ParentEntityManager.getTrxIfExistOnDb (la consulta) y el short-circuit en cada orquestador. En FacturacionService.fecaeSolicitar:
Trx trxOnDb = getTrxIfExistOnDb(trx);
if (trxOnDb != null && trxOnDb.getCae() != null) { // existe Y tiene CAE
return new FacturacionResult(initForSoapMapping(trxOnDb), caeFromTrx(trxOnDb));
}
// ... recién acá se va a AFIP
El matiz que no se puede perder: la condición es trxOnDb != null && trxOnDb.getCae() != null. Una Trx puede existir sin CAE (rechazada, resultado = "R"); en ese caso sí hay que reintentar. Confundir "existe" con "ya facturada" rompe el reintento legítimo. (saveTrx, que es CAEA y no emite CAE, usa la condición más laxa trxOnDb != null porque ahí no hay CAE que esperar.)
Preservar el short-circuit exacto
Mismo orden (idempotencia primero, antes del ente y de AFIP), misma condición. REFERENCE.md §5.4.
5. Firma CMS del TRA: SHA1 + encapsulado + provider BC¶
Qué es. El login a WSAA firma el TRA (Ticket Request) con CMS usando: digest/firma SHA1withRSA, contenido encapsulado (encapsulate = true), y provider BouncyCastle ("BC"). La salida es el CMS en Base64.
Por qué existe. Es lo que AFIP exige y valida. La firma es la prueba de identidad del facturador ante WSAA; si el algoritmo de digest, el modo de encapsulado o el provider cambian, AFIP rechaza el login y no hay token → no se puede facturar nada. Que SHA-1 sea débil en general es irrelevante: el contrato es con AFIP, y AFIP pide esto. Cambiarlo "por seguridad" rompe el sistema entero.
Dónde vive. WsaaCmsSigner.sign:
public static final String SIGNER_ALGORITHM = "SHA1withRSA"; // INVARIANTE §5: digest SHA-1
ContentSigner signer = new JcaContentSignerBuilder(SIGNER_ALGORITHM)
.setProvider(BouncyCastleProvider.PROVIDER_NAME).build(privateKey); // provider "BC"
// ...
CMSSignedData signedData = generator.generate(content, true); // encapsulate = true (INVARIANTE §5)
return Base64.getEncoder().encodeToString(signedData.getEncoded());
Esto sobrevivió a un cambio de librería no trivial: del BouncyCastle 1.46 jdk16 del legacy a bcprov/bcpkix-jdk18on 1.78 (API CMS nueva, SPEC-010). Cambió cómo se construye la firma; qué firma produce es idéntico. Esa es la prueba de que la invariante se entendió bien.
Los tres valores son contrato con AFIP
SHA1withRSA, encapsulate = true, provider "BC". No se cambia ninguno sin decisión explícita. REFERENCE.md §5.5.
6. Incremento de movimientos del CAEA tras cada save¶
Qué es. En el ciclo CAEA offline, después de persistir una Trx emitida con un CAEA, se llama a CaeaHome.instance().incrementarMovimientos(caea). Inmediatamente después del save, no antes, no en otro lado.
Por qué existe. El CAEA lleva una cuenta de cuántos comprobantes se emitieron bajo él. Esa cuenta es lo que después se concilia con AFIP al rendir las usadas (etapa 3 del ciclo CAEA). Si no incrementás —o lo hacés en otro momento— el contador queda desfasado de los comprobantes reales y la rendición sale inconsistente: AFIP y el sistema no coinciden en cuántos comprobantes hubo.
Dónde vive. El disparo en FacturacionService.saveTrx:
updateOrPersistTrx(trx);
CaeaHome.instance().incrementarMovimientos(trx.getCaea()); // INVARIANTE §6: tras cada save
Y la mecánica en CaeaHome.incrementarMovimientos:
public void incrementarMovimientos(String caea) {
setId(caea);
Integer movimientos = getInstance().getMovimientos();
movimientos++;
getInstance().setMovimientos(movimientos);
persist();
}
No mover esta llamada
Va inmediatamente después del persist de la Trx, en saveTrx. REFERENCE.md §5.6.
7. Cache cacheable en las queries (org.hibernate.cacheable)¶
Qué es. Las queries del shim van con el hint de query-cache de Hibernate activado (org.hibernate.cacheable = true), y el 2nd-level + query cache están encendidos.
Por qué existe. El plan de lecturas del legacy contaba con el cache de segundo nivel: ciertas consultas resuelven contra cache, no contra la DB. Si lo apagaras, no cambiaría el resultado de una lectura aislada, pero sí el patrón de acceso a la base (más hits a DB, otro perfil de carga) y, en casos de borde, la visibilidad de datos dentro de una transacción con flush MANUAL. Mantenerlo ON preserva el comportamiento de lectura tal como estaba afinado.
Dónde vive. ParentEntityQuery setea el hint para todas las queries que pasan por el shim:
public ParentEntityQuery() {
setHints(new HashMap<String, String>() {{
put(HibernateHints.HINT_CACHEABLE, "true");
}});
}
(En el camino a Hibernate 7, QueryHints se reemplazó por HibernateHints —ver AGENTS.md—, pero el hint sigue siendo el mismo org.hibernate.cacheable.) REFERENCE.md §5.7.
8. Contrato de error REST¶
Qué es. Ante cualquier excepción, la API REST responde HTTP 500 con: header exception-class (el nombre de la clase de la excepción), header exception-message (el mensaje), Content-Type: application/json, y el stacktrace completo en el body.
Por qué existe. Los clientes del legacy (el POS, integraciones) leen esos headers para clasificar el error. Es un contrato externo, igual que el WSDL o las rutas. Si cambiaras el código de estado, los nombres de los headers o el formato del body, romperías a los consumidores que ya parsean esa estructura —aunque "tu" forma de reportar errores sea más linda.
Dónde vive. DefaultExceptionHandler (@RestControllerAdvice), reescritura del ExceptionMapper JAX-RS del legacy a Spring MVC (SPEC-009):
public static final String ERROR_TYPE = "exception-class";
public static final String ERROR_MESSAGE = "exception-message";
@ExceptionHandler(Exception.class)
public ResponseEntity<String> toResponse(Exception e) {
StringWriter errors = new StringWriter();
e.printStackTrace(new PrintWriter(errors));
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.header(ERROR_TYPE, e.getClass().getName())
.header(ERROR_MESSAGE, String.valueOf(e.getMessage())) // valueOf evita NPE si message es null
.contentType(MediaType.APPLICATION_JSON)
.body(errors.toString());
}
El matiz del mensaje null
El legacy pasaba e.getMessage() crudo (que puede ser null). Acá se usa String.valueOf(...) para no explotar al setear el header; si el mensaje es null, el header queda "null" (string). Es la única desviación conocida y está documentada para revisar en paridad (SPEC-016) si algún cliente esperaba el header ausente. REFERENCE.md §5.8.
9. El VERSION string¶
Qué es. El sistema reporta una versión fija: v20250328. La devuelve getVersion() (SOAP) y el REST GET /.
Por qué existe. Es un identificador que los clientes y los procesos de despliegue usan para verificar contra qué versión están hablando. Es parte del contrato observable: cambiarlo sin pedido explícito puede confundir a un cliente que valida la versión, o a un script de smoke test que la chequea.
Dónde vive. La fuente de verdad es ParentEntityManager.VERSION:
Y el endpoint REST lo expone (con un stand-in del mismo valor en VersionController):
@GetMapping(value = "/", produces = "application/json")
public String getVersion() { return VERSION; } // "v20250328"
No cambiar salvo pedido explícito
REFERENCE.md §5.11. Si una tarea actualiza la versión, que sea porque alguien lo pidió, no como efecto colateral.
10. Otras invariantes a tener en mano¶
Estas también son sagradas (REFERENCE.md §5); las resumimos porque su detalle vive en otras páginas o capas.
| # | Invariante | Qué preservar | Dónde |
|---|---|---|---|
| 9 (ref) | SOAP idéntico | Binding SOAP 1.2, nombres de operación (saveTrx, fecaeaSolicitar, getVersion, ...), namespaces y WSDL byte-iguales al baseline. Diff de WSDL es bloqueante. |
SPEC-008, ../reference/soap-api.md |
| 10 (ref) | Mail dinámico | host/port/tls(=="1")/user/pass/from salen de la tabla Parametro, no de config estática. |
../reference/email-pdf.md |
| 12 (ref) | setMockActive(true) |
Debe seguir devolviendo el cliente AFIP mock (clave para los tests de paridad). | SPEC-016 |
| 13 (ref) | Locking de jobs | ThreadSafety.setExecuting/setAvailable evita ejecución solapada de la misma Tarea. |
../reference/quartz-jobs.md |
A esto se suman dos protecciones del camino NextGen que cumplen la misma función que la idempotencia clásica: el lock por (ptoVta, tipoComprobante) (NumeracionLock.conLock) y la clave UNIQUE en DB sobre la clave de negocio. Las dos garantizan "un comprobante, una vez" y se detallan en ciclo-facturacion.md.
Para seguir¶
- Los flujos donde estas invariantes se ejercen: ciclo-facturacion.md.
- La infraestructura del shim que sostiene flush MANUAL, update-sin-merge y el lookup: seam-compat.md.
- El cliente AFIP y la firma CMS por dentro: ../reference/afip-client.md.
- La referencia de persistencia (EntityManager, cache, dialecto): ../reference/persistencia.md.