El ciclo de facturación¶
Esta página explica los flujos de negocio del sistema: cómo una venta termina convertida en un comprobante fiscal autorizado por AFIP (hoy ARCA). Hay tres caminos, y entenderlos es entender el sistema entero:
- CAE online — se pide la autorización a AFIP en el momento de facturar.
- CAEA offline — el comercio emite con un código autorizado por anticipado y le informa a AFIP después.
- Consulta/sincronización — caminos informativos que leen el estado en AFIP sin emitir nada nuevo.
Para el detalle de cada operación SOAP/REST mirá ../reference/soap-api.md y ../reference/rest-api.md. Para el cliente AFIP por dentro (WSAA, WSFE, firma) está ../reference/afip-client.md. Acá contamos el recorrido del negocio.
El vocabulario mínimo
- CAE (Código de Autorización Electrónico): autorización por comprobante, online. AFIP lo devuelve al pedirlo.
- CAEA (CAE Anticipado): un código por quincena que AFIP entrega antes. El comercio factura offline con él y después rinde cuentas.
- WSAA: el servicio de AFIP que da el token de acceso (login con firma digital).
- WSFE (WSFEv1): el servicio de facturación electrónica propiamente dicho.
- Trx: la entidad que persiste el comprobante en la base. Es el agregado central del dominio.
0. Lo que comparten los tres caminos: autenticación y idempotencia¶
Antes de cada flujo, dos mecanismos transversales que aparecen una y otra vez.
0.1. El token de acceso (WSAA) se cachea¶
Toda llamada a WSFE necesita un Ticket de Acceso (TA = token + sign). Obtenerlo cuesta una firma digital y un round-trip a WSAA. Pero hay un detalle de AFIP que obliga a cachear: AFIP rechaza un nuevo login si ya hay un TA vigente ("El CEE ya posee un TA válido"). Así que WsaaTokenCache reutiliza el TA hasta poco antes de expirar, con un margen de seguridad de 10 minutos.
flowchart TD
A[Necesito llamar a WSFE] --> B{Hay TA cacheado<br/>y vigente?}
B -- Sí --> C[Reuso el TA]
B -- No --> D[WSAA LoginCms:<br/>firmo TRA con CMS + posteo]
D --> E[Cacheo el TA nuevo]
E --> C
C --> F[Llamo a WSFE con token + sign]
El TA se inyecta en los servicios como un Supplier<WsaaCredentials> (tokenProvider.get()), de modo que el detalle del cache y la firma queda detrás de esa interfaz. Quien factura no sabe ni le importa si el token vino del cache o de un login fresco.
0.2. La idempotencia: nunca facturar dos veces lo mismo¶
Es la invariante más sagrada del sistema (REFERENCE.md §5.4). Antes de ir a AFIP, siempre se consulta si ese comprobante ya está en la base. Si ya existe con CAE, se devuelve el existente sin tocar AFIP. Esto protege contra reintentos del cliente, timeouts y dobles envíos.
El lookup vive en ParentEntityManager.getTrx(...) y es exacto: busca por EnteFacturador + PtoVta + TipoComprobante + Comprobante + ComprobanteRelacionado, ordena id desc y toma maxResults(1) —el último. (Ver invariantes.md por qué ese orden importa.)
// ParentEntityManager.getTrxIfExistOnDb (resumido)
Trx trxOnDb = getTrx(trx.getEnteFacturador(), trx.getPtoVta(),
trx.getTipoComprobante(), trx.getComprobante(),
trx.getComprobanteRelacionado());
// si existe y tiene CAE -> el llamador hace short-circuit, no va a AFIP
1. Ciclo CAE online (fecaeSolicitar)¶
Es el camino directo: el POS pide facturar y el backend resuelve la autorización contra AFIP en la misma llamada. Lo orquesta FacturacionService.fecaeSolicitar(Trx).
El flujo tiene cuatro pasos, en este orden estricto:
- Idempotencia: si la Trx ya existe en DB con CAE, devuelve sin ir a AFIP (short-circuit).
- EnteFacturador: carga el único ente habilitado para emitir.
- Emisión AFIP: pide el CAE a WSFE; esto muta la Trx (cae, vencimiento, fecha de proceso, resultado, código de barras).
- Persistencia: guarda la Trx con los datos del CAE.
sequenceDiagram
participant POS as POS / Cliente
participant FS as FacturacionService
participant DB as Base (Trx)
participant AFIP as WSFE (AFIP)
POS->>FS: fecaeSolicitar(trx)
FS->>DB: getTrxIfExistOnDb(trx)
alt Ya facturada con CAE
DB-->>FS: Trx con CAE
FS-->>POS: CAE existente (short-circuit, sin AFIP)
else Comprobante nuevo
DB-->>FS: null
FS->>FS: getEnteFacturador()
FS->>AFIP: emitirCae(trx, ente) [FECAESolicitar]
AFIP-->>FS: FecaeResult (cae, vto, fchProceso, resultado)
FS->>FS: aplica CAE + calcula código de barras
FS->>DB: updateOrPersistCaeDataOnTrx(trx, cae)
FS-->>POS: FacturacionResult (trx, cae)
end
El código deja el orden explícito:
// FacturacionService.fecaeSolicitar (núcleo)
Trx trxOnDb = getTrxIfExistOnDb(trx);
if (trxOnDb != null && trxOnDb.getCae() != null) { // 1. idempotencia
return new FacturacionResult(initForSoapMapping(trxOnDb), caeFromTrx(trxOnDb));
}
final EnteFacturador enteFacturador = getEnteFacturador(); // 2. ente
trx.setEnteFacturador(enteFacturador);
FecaeResult fr = afipCaeService.emitirCae(trx, enteFacturador); // 3. AFIP (muta la Trx)
// ... arma AfipCAE con fr ...
updateOrPersistCaeDataOnTrx(trx, caeGenerado); // 4. persiste
El short-circuit exige CAE != null, no sólo que la Trx exista
Una Trx puede existir en DB con resultado = "R" (rechazada) y sin CAE —por ejemplo, tras un error de AFIP. En ese caso no se hace short-circuit: hay que reintentar. La condición es trxOnDb != null && trxOnDb.getCae() != null. Confundir "existe" con "ya facturada" es un bug clásico.
1.1. La emisión por dentro (AfipCaeService.emitirCae)¶
emitirCae es donde se habla con AFIP. Toma el token, mapea la Trx al input de WSFE, llama a FECAESolicitar, y aplica el resultado a la Trx. Si AFIP rechaza, marca resultado = "R" y propaga la excepción.
// AfipCaeService.emitirCae (resumido)
WsaaCredentials creds = tokenProvider.get();
WsfeAuth auth = new WsfeAuth(creds.getToken(), creds.getSign(), ente.getCuit());
FecaeInput input = TrxToFecaeMapper.map(trx);
try {
FecaeResult cae = caeClient.solicitarCae(auth, input);
applyTo(trx, ente, cae); // setea cae, vto, fchProceso, resultado + código de barras
metrics.recordOk(Op.CAE);
return cae;
} catch (RuntimeException e) {
trx.setResultado("R"); // queda marcada como rechazada para el reintento
metrics.recordError(Op.CAE, e.getMessage());
throw e;
}
El código de barras (caeBarCode) no viene de AFIP: lo calcula el sistema con Trx.calculoDigitoVerificador(cuit, tipoComprobante, ptoVta, cae, caeFchVto). Es el dato que va impreso en el PDF de la factura.
1.2. Variante con numeración automática (fecaeSolicitarNextGen)¶
Hay un camino más nuevo donde el POS no asigna el número de comprobante: lo asigna el backend. El POS manda una clave de negocio (nroTicketPos, nroSuc, nroPos, tipoComprobante, comprobanteFecha) y el gateway resuelve el punto de venta, consulta el último comprobante autorizado en AFIP y asigna último + 1.
Esto introduce dos protecciones extra contra la doble factura:
- Un lock por
(ptoVta, tipoComprobante)(NumeracionLock.conLock) serializa la asignación de número + persist + emisión: un solo hilo a la vez por punto de venta y tipo. - Una clave UNIQUE en DB
(nroTicketPos, nroSuc, nroPos, idTipoComprobante, comprobanteFecha) WHERE version='V2'garantiza atomicidad aunque dos requests crucen el lock.
flowchart TD
A[fecaeSolicitarNextGen trx] --> V[validar comprobanteFecha YYYYMMDD]
V --> K[buscar por clave de negocio<br/>order by id desc, maxResults 1]
K --> R2{existe y resultado = A?}
R2 -- Sí --> INC[incremento reintentos + update]
INC --> OUT1[devuelve CAE existente]
R2 -- No --> R3{existe pero NO aprobado?}
R3 -- Sí --> CONS[consulto ARCA]
CONS --> APROB{aprobó?}
APROB -- Sí --> OUT2[update + devuelve CAE]
APROB -- No --> ERR[GeneralException: rechazado, no re-emito]
R3 -- No --> NEW[ticket nuevo]
NEW --> SP[resolver ptoVta desde SucPosPV]
SP --> LOCK[conLock ptoVta + tipoComprobante]
LOCK --> ULT[ultimoAutorizado + 1 = numero]
ULT --> PER[persist en tx PROPIA REQUIRES_NEW]
PER --> EMIT[emitir CAE en AFIP]
EMIT --> OUT3[devuelve CAE]
Por qué se persiste ANTES de llamar a AFIP
En la rama de ticket nuevo, la fila se inserta en una transacción propia (REQUIRES_NEW) antes de pedir el CAE. Así, si la emisión falla a mitad, la fila ya quedó en DB con resultado != A, y el reintento entra por la rama "ya intentado" → consulta ARCA en vez de re-emitir. Es la defensa contra la doble factura cuando AFIP responde tarde o timea.
Cuidado: tras el REQUIRES_NEW la instancia trx queda detached del contexto externo. Por eso el código re-lee el managed con em.find(Trx.class, trx.getId()) antes de escribir el CAE —porque update() sólo hace flush, sin merge (ver invariantes.md).
2. Ciclo CAEA offline (anticipado)¶
El CAEA existe para que el comercio pueda facturar sin conexión a AFIP en el momento de la venta. AFIP entrega un código autorizado por anticipado (uno por quincena), el POS factura offline usando ese código, y el sistema le informa a AFIP los comprobantes emitidos después. El ciclo tiene tres etapas, normalmente en momentos distintos.
flowchart LR
subgraph Anticipado [1. Antes - obtener el lote]
G[CaeaGestionService<br/>gestionarCaeas] --> A1[WSFE FECAEAConsultar / FECAEASolicitar]
A1 --> P1[persistir Caea en DB]
end
subgraph Offline [2. En la venta - emitir offline]
POS[POS factura con CAEA vigente] --> S[saveTrx / registrarCaea]
S --> P2[persistir Trx]
P2 --> M[incrementar movimientos del CAEA]
end
subgraph Diferido [3. Después - rendir a AFIP]
J[ComprobantesEmitidosService<br/>informarComprobantesEmitidos] --> A2[WSFE FECAEARegInformativo]
A2 --> P3[marcar Trx con resultado]
end
Anticipado --> Offline --> Diferido
2.1. Etapa 1 — Solicitar el lote (CaeaGestionService)¶
Un job (gestionarCaeas) corre por cada EnteFacturador: consulta el CAEA de la quincena objetivo; si AFIP responde 602 (CAEA_NO_EXISTE), solicita uno nuevo; y lo persiste si no estaba.
La quincena objetivo se calcula igual que el legacy: si hoy es después del día 15, apunta a la 1ª quincena del mes siguiente (orden 1); si es el 15 o antes, a la 2ª quincena del mes actual (orden 2).
// CaeaGestionService.gestionarEnte (núcleo)
try {
cr = caeaClient.fecaeaConsultar(auth, periodo, orden);
} catch (WsfeFault f) {
if (f.getCode() != CAEA_NO_EXISTE) throw f; // 602 = no existe -> lo solicito
cr = caeaClient.fecaeaSolicitar(auth, periodo, orden);
}
CaeaHome.instance().persistIfNotExists(toCaea(cr, ente)); // idempotente: no re-graba si ya está
2.2. Etapa 2 — Emitir offline (saveTrx / registrarCaea)¶
Cuando el POS ya facturó offline con un CAEA vigente, el comprobante llega al backend sólo para persistirse. No se va a AFIP sincrónico en este momento. Lo clave acá es la invariante CAEA (REFERENCE.md §6): tras guardar la Trx, se incrementan los movimientos del CAEA.
// FacturacionService.saveTrx (núcleo)
Trx trxOnDb = getTrxIfExistOnDb(trx);
if (trxOnDb != null) { // idempotencia
return new FacturacionResult(initForSoapMapping(trxOnDb), caeFromTrx(trxOnDb));
}
updateOrPersistTrx(trx);
CaeaHome.instance().incrementarMovimientos(trx.getCaea()); // INVARIANTE §6: tras cada save
incrementarMovimientos carga el CAEA por su id, suma 1 a su contador y lo persiste:
// CaeaHome.incrementarMovimientos
public void incrementarMovimientos(String caea) {
setId(caea);
Integer movimientos = getInstance().getMovimientos();
movimientos++;
getInstance().setMovimientos(movimientos);
persist();
}
Existe una variante más reciente, CaeaNotificacionService.registrarCaea, usada por el notificador "DINO", que además reporta sincrónico a ARCA en el alta (FECAEARegInformativo) y deja el resultado seteado para que el job diferido no lo vuelva a informar. Esa ruta hace dedup por (ptoVta, comprobante) con tipofacturacion='CAEA' y version='V2'.
El contador de movimientos no se puede saltear ni mover
Si no incrementás los movimientos del CAEA en cada save, la rendición posterior a AFIP queda inconsistente con lo que realmente se emitió. La llamada va inmediatamente después del persist, no antes, no en otro lado. Ver invariantes.md.
2.3. Etapa 3 — Informar las usadas (ComprobantesEmitidosService)¶
En diferido, un job rinde a AFIP los comprobantes CAEA que todavía no fueron informados (resultado = null). Por cada ente y cada grupo (PtoVta, TipoComprobante) pendiente: consulta el último autorizado en AFIP; si AFIP ya cubre lo pendiente, no hace nada; si no, informa cada Trx con FECAEARegInformativo y la marca con el resultado.
// ComprobantesEmitidosService.informarGrupo (núcleo)
long ultimoAutorizado = ultimoClient.ultimoAutorizado(auth, grupo.getPtoVta(),
grupo.getTipoComprobante().getId());
if (ultimoAutorizado >= grupo.getUltimoComprobante()) return; // AFIP ya está al día
for (Trx trx : TrxList.getInstance().toSendAfip(grupo)) {
FecaeResult r = caeaClient.fecaeaRegInformativo(auth, in, trx.getCaea(), trx.getCbteFchHsGen());
trx.setResultado(r.getResultado());
trx.setFchProceso(r.getFchProceso());
TrxHome.instance().update(); // solo flush
}
Los errores se tragan por grupo: si un (PtoVta, TipoComprobante) falla, se loguea y se sigue con el siguiente, sin abortar todo el job. Es deliberado: la rendición es un proceso de fondo que debe avanzar lo que pueda.
3. Sincronización informativa (consultas)¶
Hay caminos que no emiten nada nuevo: leen el estado real en AFIP o en la base. Son la red de seguridad ante dudas de "¿esto se facturó o no?".
| Operación | Qué hace | ¿Va a AFIP? | ¿Persiste? |
|---|---|---|---|
feCompomprobanteFacturado |
Si la Trx existe en DB, la devuelve con su CAE; si no, devuelve la del request con CAE nulo | No | No |
feCompConsultar |
Si existe en DB la devuelve; si no, consulta FECompConsultar en AFIP, persiste y devuelve |
Sólo si no estaba en DB | Sí (si consultó AFIP) |
sequenceDiagram
participant POS as POS
participant FS as FacturacionService
participant DB as Base (Trx)
participant AFIP as WSFE
POS->>FS: feCompConsultar(trx)
FS->>DB: getTrxIfExistOnDb(trx)
alt Existe en DB
DB-->>FS: Trx con CAE
FS-->>POS: CAE proyectado (sin AFIP)
else No existe
DB-->>FS: null
FS->>AFIP: consultarComprobante(trx, ente) [FECompConsultar]
AFIP-->>FS: FecaeResult
FS->>DB: persiste Trx + CAE
FS-->>POS: CAE recuperado de AFIP
end
feCompomprobanteFacturado es de sólo lectura (@Transactional(readOnly = true)): nunca toca AFIP ni escribe. Es la consulta más barata y la primera a la que recurre un cliente que quiere chequear estado.
El detalle del LazyInitialization (initForSoapMapping)
Cuando una de estas operaciones devuelve una Trx leída de la base hacia el endpoint SOAP, el grafo lazy (enteFacturador, tipos, colecciones) tiene que estar inicializado dentro de la transacción, porque con open-in-view=false la sesión ya está cerrada cuando el mapper SOAP la recorre. Por eso initForSoapMapping fuerza Hibernate.initialize(...) sobre los 11 atributos que el mapper lee. El legacy lo resolvía con open-session-in-view; acá se hace explícito. Es idempotente y barato sobre un grafo ya inicializado.
4. Resumen: qué camino dispara cada operación¶
| Operación (negocio) | Camino | Va a AFIP | Incrementa CAEA | Persiste Trx |
|---|---|---|---|---|
fecaeSolicitar |
CAE online | Sí (si nueva) | No | Sí |
fecaeSolicitarNextGen |
CAE online, numeración automática | Sí (si nueva) | No | Sí (REQUIRES_NEW) |
saveTrx |
CAEA offline, etapa 2 | No | Sí | Sí |
registrarCaea (DINO) |
CAEA offline, etapa 2 + reporte | Sí (RegInformativo) | No (setea resultado) | Sí |
gestionarCaeas (job) |
CAEA, etapa 1 | Sí (Consultar/Solicitar) | No | Sí (Caea) |
informarComprobantesEmitidos (job) |
CAEA, etapa 3 | Sí (RegInformativo) | No (marca resultado) | Sí (update) |
feCompConsultar |
Sincronización | Sí (si no estaba) | No | Sí (si consultó) |
feCompomprobanteFacturado |
Sincronización | No | No | No |
Email y PDF de la factura
El envío de email y la generación del PDF de la factura son etapas posteriores a la emisión y están fuera del alcance del main actual (clases "rojas" EmailHome/TrxToPdfHome, ver REFERENCE.md §4 y el stack en AGENTS.md). Cuando estén portadas, el detalle vivirá en ../reference/email-pdf.md. El dato fiscal que esas etapas consumen —CAE, vencimiento, código de barras— ya queda persistido en la Trx por los flujos de esta página.
Para seguir¶
- Las reglas que estos flujos no pueden romper: invariantes.md.
- Cómo el shim Seam sostiene los
*Home/*Listque estos servicios usan: seam-compat.md. - El cliente AFIP por dentro (WSAA, WSFE, firma CMS): ../reference/afip-client.md.
- El contrato SOAP/REST expuesto: ../reference/soap-api.md y ../reference/rest-api.md.