Saltar a contenido

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 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);
    Caea instance = getInstance();
    if (instance == null) {          // CAEA ausente en la tabla Caea: no romper el flujo por el contador
        LOG.warn("[CaeaHome] ... no existe el CAEA #0 ...; se omite el incremento.", caea);
        return;
    }
    Integer movimientos = instance.getMovimientos();
    instance.setMovimientos(movimientos == null ? 1 : movimientos + 1);
    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:

protected static final String VERSION = "v20250328";

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. Integridad fiscal por ente (money-path)

Qué es. Como el sistema es multi-comercio, el POS auto-declara contra qué EnteFacturador factura (campo enteFacturador{id} del request <trx>) y los comercios pueden compartir (suc, pos). Antes de cualquier llamada a AFIP, el server DEBE validar que el (ente, suc, pos) declarado exista como combinación registrada en SucPosPV. Si no matchea —el (suc,pos) pertenece a otro ente, o no existe para ninguno— rechaza fail-closed, sin tocar AFIP y sin dejar fila en DB. Aplica a los tres paths de emisión: V1, V2 y CAEA Notificación. Si el request no declara enteFacturador.id, también rechaza: no hay "ente por defecto".

Por qué existe. Es un control fiscal nuevo, no una migración. Sin él, un POS mal configurado —o malicioso— podría emitir un comprobante a nombre del CUIT de otro comercio: declara el ente B pero usa el (suc,pos) del ente A, o al revés. Como en multi-comercio (suc,pos) ya no identifica al comercio, la única forma de garantizar que un comprobante se emite contra el ente correcto, sin cruzar datos entre comercios, es verificar la pertenencia (ente,suc,pos) ∈ SucPosPV antes de firmar nada. El rechazo es anterior al persist-before-emit, así que no queda rastro de un intento inválido.

Dónde vive. EnteResolver (action.ws), un único punto de resolución + validación que reemplazó los 3 puntos single-ente previos. resolve(trx) carga el ente declarado y exige que esté habilitado; validarPropiedad(ente, suc, pos) corre el control de pertenencia:

public void validarPropiedad(EnteFacturador ente, int suc, int pos) throws GeneralException {
    SucPosPV sp = SucPosPVList.instance().getBySucPos(ente, suc, pos);
    if (sp == null) {
        // ¿(suc,pos) existe para OTRO ente, o no existe para ninguno?
        long countSucPos = /* COUNT SucPosPV WHERE suc=:suc AND pos=:pos */;
        if (countSucPos > 0)
            throw new GeneralException("INTEGRIDAD_FISCAL: (suc,pos) no pertenece al ente declarado ...");
        else
            throw new GeneralException("INTEGRIDAD_FISCAL: (suc,pos) desconocido ...");
    }
}

En FacturacionService.fecaeSolicitar se invoca tras la idempotencia y antes de emitirCae. La spec dedicada es integridad-fiscal-ente; el control no toca el WSDL (WsdlDiffGate verde).

El rechazo es fail-closed y va ANTES de AFIP

(ente,suc,pos) que no esté en SucPosPVGeneralException, sin llamada a AFIP, sin Trx persistida. Confirmá ente,suc,pos antes de emitir; nunca asumas un ente por defecto.


11. Selección de EnteFacturador determinística + fail-closed

Qué es. El EnteFacturador es la identidad fiscal que firma WSAA y emite CAE/CAEA/NC. Su selección tiene que ser determinística (no depender del azar del orden de la base) y fail-closed (ante una ambigüedad, romper ruidosamente en vez de elegir cualquiera). En multi-comercio, el ente se resuelve del request (EnteResolver.resolve): se carga por el id declarado, se exige que esté habilitado, y si falta o no existe se rechaza con error descriptivo.

Por qué existe. Antes, la query del ente habilitado no tenía ORDER BY ni maxResults, y los consumidores hacían .get(0): con más de un ente habilitado (un error de datos o una migración a medias) el CUIT usado era no-determinístico run-to-run — un defecto fiscal silencioso (un comprobante firmado por el CUIT equivocado, distinto en cada corrida). La regla convierte ese estado ambiguo en un error explícito: el orden es estable (id asc) y el invariante "un solo facturador" pasó a ser un fallo ruidoso, no un pick arbitrario. Con multi-comercio, el origen de verdad ya no es "el único habilitado" sino el ente que el request declara, validado contra DB.

Dónde vive. El paso de blindaje sobre el path single-ente está en el commit cdfd376 (EnteFacturadorList.setOrder("id asc") + guards fail-closed en FacturacionService.getEnteFacturador y EnteFacturadorTokenProvider.loadEnteHabilitado, que lanzan si hay >1 habilitado). La resolución desde el request vive en EnteResolver.resolve (multicomercio F3):

public EnteFacturador resolve(Trx trx) throws GeneralException {
    if (trx.getEnteFacturador() == null || trx.getEnteFacturador().getId() == null)
        throw new GeneralException("ENTE_NO_DECLARADO: el request no declara enteFacturador.id");
    EnteFacturador ente = em.find(EnteFacturador.class, trx.getEnteFacturador().getId());
    if (ente == null)        throw new GeneralException("ENTE_NO_DECLARADO: no existe ...");
    if (!ente.isHabilitado()) throw new GeneralException("ENTE_DESHABILITADO: ...");
    return ente;
}

Nunca un pick arbitrario del ente

Ante ambigüedad (varios habilitados sin selección clara, o request sin ente), el contrato es romper, no get(0). El ente fiscal no se adivina. Spec multi-ente-emision.


12. Persistir la Trx CAEA antes de informar a ARCA

Qué es. En la notificación CAEA (CaeaNotificacionService.registrarCaea), la fila de la Trx se persiste primero —en una transacción propia REQUIRES_NEW— y recién después se informa el comprobante a ARCA (fecaeaRegInformativo). Es el mismo orden persist-before-emit que ya cumplía el CAE V2.

Por qué existe. Antes era al revés: informaba a ARCA y persistía después, todo en una sola transacción que recién commitea al final. Un crash entre el informe y el commit dejaba a ARCA con el comprobante registrado y a la DB sin la fila → ni el dedup (findExistingCaea) ni el batch (ComprobantesEmitidosJob) la veían → el reintento re-informaba el mismo comprobante (doble informe). Persistir primero en su propia tx cierra esa ventana: un crash pre-commit deja una fila con resultado=null que el dedup encuentra en el reintento y que el batch completa, en vez de re-informar.

Dónde vive. CaeaNotificacionService.registrarCaea (commit 9aee109). Persiste con TrxNextGenPersistHelper (REQUIRES_NEW) y, como el REQUIRES_NEW deja la entidad detached, re-lee el managed con em.find antes de actualizar —porque update() es flush-only, no merge (invariante 2):

persistHelper.persistNuevaTrxNextGen(trx);   // tx PROPIA, ANTES de informar
Trx managed = em.find(Trx.class, trx.getId());     // re-leer managed (update()=flush-only, §5.2)
// ... recién acá: fecaeaRegInformativo(...) a ARCA
managed.setResultado(result.getResultado());
updateOrPersistTrx(managed);

El payload a ARCA queda byte-idéntico (TrxToFecaeMapper lee campos del DTO, no resultado/id), así que el orden cambia pero la salida fiscal no.

Persist primero, informar después

El orden es el inverso de la intuición ("informo y guardo el resultado"). Invertirlo reabre la ventana de doble-informe. Paridad SPEC-016 + WsdlDiffGate verdes lo custodian.


13. Persistir la NC de reconciliación antes de emitir a AFIP

Qué es. En la reconciliación CAE-CAEA (NotaCreditoReconciliacionService), la fila de la Nota de Crédito se persiste primero —en una transacción propia REQUIRES_NEW, con su número y resultado=null— y recién después se pide el CAE de la NC a AFIP (emitirCae). Es el mismo orden persist-before-emit que ya cumplían el CAE V2 (invariante 2) y la notificación CAEA (invariante 12).

Por qué existe. El job busca CAE doblemente facturados que no tengan una NC apuntándolos (NOT EXISTS nc.trxoriginal = cae.id). Si primero emitiera a AFIP y persistiera después, un crash entre el CAE de la NC y el commit dejaría a AFIP con la NC emitida y a la DB sin la fila → en la corrida siguiente el mismo CAE volvería a seleccionarse como candidato → el job emitiría una segunda NC contra el mismo CAE: una doble anulación fiscal. Persistir primero, en su propia tx, cierra la ventana: un crash pre-commit deja una fila NC (con trxoriginal=cae.id) que el NOT EXISTS de la próxima corrida ya ve, así que el CAE no se re-selecciona.

Dónde vive. NotaCreditoReconciliacionService.emitirNcBajoLock (commit e07d83f). Persiste con TrxNextGenPersistHelper (REQUIRES_NEW) y, como el REQUIRES_NEW deja la entidad detached, re-lee el managed con em.find antes de emitir —porque update() es flush-only, no merge (invariante 2):

persistHelper.persistNuevaTrxNextGen(nc);   // tx PROPIA, ANTES de emitir el CAE de la NC
Trx managed = em.find(Trx.class, nc.getId());     // re-leer managed (update()=flush-only, §5.2)
afipCaeService.emitirCae(managed, ente);          // muta el managed: cae, resultado, ...
updateOrPersistTrx(managed);

Toda la emisión de la NC corre bajo el lock de numeración particionado por (idEnteFacturador, ptoVta, tipoComprobante), y el CUIT de la NC es el del ente que emitió el CAE original (no un ente global) — la NC fiscalmente tiene que salir con el CUIT correcto.

Persist primero, emitir después — también para la NC

Mismo patrón que las invariantes 2 y 12. Invertirlo reabre la ventana de doble anulación (dos NC contra un mismo CAE). El persist-before-emit y el NOT EXISTS nc.trxoriginal=cae.id son un par: los dos juntos garantizan "una NC, una vez" por CAE.


14. 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 de numeración (NumeracionLock.conLock) y la clave UNIQUE en DB sobre la clave de negocio. Con multi-comercio las dos pasaron a estar particionadas por ente: el lock keyea por (idEnteFacturador, ptoVta, tipoComprobante) —dos comercios que comparten un pvcae no se sobre-serializan— y los UNIQUE de Trx llevan idEnteFacturador adelante (ver Modelo de dominio). Las dos garantizan "un comprobante, una vez" por comercio y se detallan en ciclo-facturacion.md.


Para seguir