Saltar a contenido

Cliente AFIP (ARCA)

Referencia técnica del cliente que TiFacturaOnlineNext usa para hablar con AFIP/ARCA: el login en WSAA, las operaciones de WSFEv1 (CAE, CAEA, consultas) y el transporte TLS. Para el porqué del flujo de negocio (cuándo se pide CAE vs CAEA), mirá El ciclo de facturación. Para los endpoints SOAP que este backend expone (no los que consume), mirá API SOAP.

Todo el cliente vive bajo com.tipre.tifacturaonlinemanager.action.ws.client (SPEC-010). Está escrito en raw-SOAP (armado de sobres a mano + parseo DOM por local-name), no con stubs generados: así cada pieza es testeable offline inyectando un transporte fake, y solo el transporte real toca la red.

Stack y origen

Pieza Valor
Firma CMS BouncyCastle bcprov-jdk18on + bcpkix-jdk18on 1.78
Algoritmo de firma SHA1withRSA (digest SHA-1), CMS encapsulado
Transporte HttpsURLConnection + SSLContext TLS 1.2 (directo, sin nginx)
WSAA homologación https://wsaahomo.afip.gov.ar/ws/services/LoginCms
WSFE homologación https://wswhomo.afip.gob.ar/wsfev1/service.asmx
Namespace WSFEv1 http://ar.gov.afip.dif.FEV1/
Namespace WSAA http://wsaa.view.sua.dvadac.desein.afip.gov

Los valores productivos los sobrescribe application-prod.yml; los de homologación son el default seguro en application.yml.

Layout de paquetes

action.ws.client
├── AfipCaeService            orquesta emisión/consulta de CAE de una Trx
├── wsaa/                     login WSAA (TRA → CMS → TA cacheado)
├── wsfe/                     clientes WSFEv1 (CAE, CAEA, consultas, dummy)
├── common/                   transporte SOAP + SSLContext + captura de tráfico
└── exceptions/              excepciones de dominio AFIP

Flujo WSAA → WSFE

Antes de cualquier operación WSFE hay que tener un Ticket de Acceso (TA = token + sign) vigente. El TA se reutiliza en 3 niveles (memoria → DB → LoginCms) para no chocar con el "El CEE ya posee un TA válido" de AFIP, y se emite con el .p12 del comercio (multi-ente: un TA por CUIT). El orquestador de esa estrategia es EnteFacturadorTokenProvider; WSFE adjunta el TA en el header Auth de cada llamada.

sequenceDiagram
    participant S as AfipCaeService
    participant P as EnteFacturadorTokenProvider
    participant C as WsaaTokenCache (L1)
    participant DB as EnteFacturador (L2)
    participant CL as AfipCertLoader
    participant W as WsaaClient
    participant SG as WsaaCmsSigner
    participant WS as WSAA (AFIP)
    participant FE as WsfeCaeClient
    participant AR as WSFEv1 (AFIP)

    S->>P: get(ente)
    P->>C: get(cuit, issuer)
    alt L1 hit (TA en memoria, vigente)
        C-->>P: WsaaCredentials
    else L1 miss
        C->>DB: TA persistido en EnteFacturador
        alt L2 hit (token/sign/exp en DB, vigentes)
            DB-->>P: WsaaCredentials (reutilizado)
        else L3 — LoginCms
            P->>CL: loadAndLogin(ente, ...)
            CL->>CL: resuelve .p12 del ente (path/alias→CUIT/pass)
            CL->>W: login(privateKey, cert, params)
            W->>W: LoginTicketRequest.build (TRA)
            W->>SG: sign(TRA) → CMS Base64 (SHA1withRSA)
            W->>WS: POST loginCms (SOAPAction urn:LoginCms)
            WS-->>W: loginTicketResponse (token+sign+expirationTime)
            W-->>CL: WsaaCredentials
            CL-->>P: WsaaCredentials
            P->>DB: persiste TA en EnteFacturador (REQUIRES_NEW)
        end
    end
    P-->>S: WsaaCredentials (token+sign)
    S->>FE: solicitarCae(WsfeAuth, FecaeInput)
    FE->>AR: POST FECAESolicitar (Auth=token+sign+cuit)
    AR-->>FE: CAE + CAEFchVto + Resultado
    FE-->>S: FecaeResult

WSAA — login (paquete wsaa)

El login es una sola operación SOAP (loginCms), pero el cliente la parte en piezas chicas y testeables. El orquestador es WsaaClient.

Clase Rol
WsaaClient Orquesta TRA → firma → envelope → POST → parse. login(privateKey, cert, params)WsaaCredentials.
LoginTicketRequest Arma el TRA (Login Ticket Request) XML.
WsaaCmsSigner Firma el TRA en CMS (BouncyCastle), devuelve Base64.
WsaaLoginResponseParser Parsea la respuesta SOAP y extrae token/sign/expirationTime.
WsaaCredentials DTO inmutable del TA: token, sign, expirationTime.
WsaaLoginParams Parámetros del login: loginCmsUrl, destination (dstdn), service, ticketTimeMs.
AfipCertLoader Resuelve y carga el .p12 del ente y arma el login. validateCertConfig / resolveAlias (puras, testeables) + loadAndLogin.
WsaaTokenCache Cache L1 en memoria del TA, clave por CUIT. Reusa hasta el margen de expiración; expone evict(cuit).
WsaaSoapTransport / HttpsWsaaSoapTransport Interfaz de transporte + impl HTTPS real.

El orquestador del TA por ente (cache L1 + persistencia L2 + login L3) es EnteFacturadorTokenProvider (vive en action.ws.client, no en wsaa): ver más abajo El cache del TA en 3 niveles.

El TRA (LoginTicketRequest)

El TRA es por naturaleza variable en el tiempo (uniqueId y fechas cambian cada llamada): no hay "TRA golden" byte-exacto, el requisito es que AFIP lo acepte. Reglas de tiempo (preservadas del legacy AfipWSDictionary.create_LoginTicketRequest):

  • uniqueId = epoch en segundos de "ahora".
  • generationTime = ahora menos 10 min (evita el fault por desincronización de reloj).
  • expirationTime = ahora + ticketTimeMs (default 12 h vía afip.ticket-time-ms=43200000).
  • source = subjectDN del certificado firmante; destination = afip.dstdn.
<loginTicketRequest version="1.0">
  <header>
    <source>...subjectDN del .p12...</source>
    <destination>cn=wsaahomo,o=afip,c=ar,serialNumber=CUIT 33693450239</destination>
    <uniqueId>1719400000</uniqueId>
    <generationTime>2026-06-26T11:50:00.000-03:00</generationTime>
    <expirationTime>2026-06-26T23:50:00.000-03:00</expirationTime>
  </header>
  <service>wsfe</service>
</loginTicketRequest>

La firma CMS (WsaaCmsSigner)

WsaaCmsSigner firma el TRA con BouncyCastle y lo devuelve en Base64. Dos invariantes (REFERENCE.md §5), preservados respecto del legacy 1.46:

  • SHA1withRSA (digest SHA-1) — constante SIGNER_ALGORITHM. No cambiar sin decisión explícita.
  • CMS encapsulado (generate(content, true)): el TRA viaja dentro del CMS firmado.

El provider BouncyCastleProvider se registra una sola vez (idempotente). Tiene dos entradas: sign(tra, privateKey, certificate) y signWithP12(tra, p12Path, password, alias), que carga la clave/cert de un keystore PKCS12.

El cert sale de la base, por comercio

El .p12 (path/clave/alias) no está en application.yml: sale de la tabla EntesFacturadores (certP12Path / certP12Password / certP12Alias), un certificado por comercio.

La resolución del cert (AfipCertLoader)

Resolver el .p12 del ente y emitir el login se extrajo a una clase dedicada AfipCertLoader (commit refactor(wsaa) b3db829), separada del @Bean de wiring para poder testear la lógica pura sin un .p12 real. Reglas (idénticas al legacy, byte-equivalentes):

  • validateCertConfig(ente) — falla con IllegalStateException si el ente es null o certP12Path está vacío. Es el check fail-closed por ente: con multi-comercio, un cert mal configurado en UN comercio rompe solo sus emisiones, no las de los demás.
  • resolveAlias(ente) — usa certP12Alias si está seteado; si está vacío, cae al CUIT por convención (como el legacy).
  • loadAndLogin(ente, wsaa, metrics) — carga el keystore PKCS12 (password null""), saca la clave/cert por alias, llama a WsaaClient.login(...) con WsaaLoginParams(wsaaUrl, dstdn, service, ticketTimeMs) y registra la métrica TOKEN (recordOk / recordError).

AfipClientConfig arma el @Bean("rawWsaaLogin") (Function<EnteFacturador, WsaaCredentials>) delegando en AfipCertLoader.loadAndLogin(...). Ese login es crudo: emite un TA nuevo cada vez, sin cache ni persistencia. La reutilización la pone arriba EnteFacturadorTokenProvider. El .p12 se carga lazy (recién en el login), así el contexto Spring levanta sin cert presente (necesario para los tests).

El parseo (WsaaLoginResponseParser)

La respuesta de loginCms trae <loginCmsReturn> con el loginTicketResponse como XML escapado: el parser lo desescapa (getTextContent) y lo vuelve a parsear para sacar token / sign / expirationTime (búsqueda por local-name, ignora prefijos de namespace). Si hay <faultstring>, lanza IllegalStateException.

El cache del TA en 3 niveles (EnteFacturadorTokenProvider)

AFIP rechaza un loginCms si ya hay un TA vigente ("El CEE ya posee un TA válido"), así que el TA se reusa lo más posible. El provider lo busca en 3 niveles, por CUIT, fiel al legacy:

Nivel Dónde Quién Para qué
L1 Memoria WsaaTokenCache (ConcurrentHashMap, clave por CUIT) Cache rápido del proceso.
L2 DB columnas token / sign / expirationTime de EnteFacturador Sobrevive reinicios: tras reiniciar no dispara un re-login con un TA que sigue vigente.
L3 AFIP LoginCms vía AfipCertLoader + WsaaClient Emite uno nuevo solo si L1 y L2 fallaron/vencieron.

EnteFacturadorTokenProvider.get(ente) re-lee el ente managed por id (puede venir detached desde la tx externa), pide a WsaaTokenCache.get(cuit, issuer), y el issuer mira primero el TA persistido en la fila (L2): si sigue válido lo reutiliza, si no hace LoginCms (L3) y persiste el TA nuevo en EnteFacturador. Toda la operación corre en su propia transacción @Transactional(REQUIRES_NEW): el TA vale en AFIP independientemente de que la operación de negocio commitee o no, así que se persiste por separado (como el job legacy).

El margen de seguridad para considerar un TA "vigente" es 10 min antes de expirationTime (WsaaTokenCache.isStillValid); si el expirationTime no se puede parsear, se considera inválido (renueva).

Nunca persistir un MEDIO-TA (persistTa)

persistTa parsea el expirationTime antes de mutar la entidad: si no parsea, no toca nada y el TA fresco queda solo en memoria para este proceso. Setear token/sign con una expiración vieja/null y flushear grabaría un medio-TA: en el próximo boot, L2 lo vería incompleto → forzaría un LoginCms con el TA que sigue vigente en AFIP → rechazo "CEE ya posee un TA válido" → emisión caída hasta que expire (~12 h). Como la entidad es managed, cualquier mutación flushea al commit aunque se saltee el update(); por eso no se muta en absoluto ante un expirationTime ilegible (fix e1ce684).

El TA no se loguea (redacción en el tráfico capturado)

El token y el sign del TA son credenciales vigentes (~12 h). TrafficCapture redacta su contenido en el XML capturado antes de que viaje al browser del cockpit: reemplaza el contenido de <token>/<sign> por ***, cubriendo las dos formas reales —crudas con prefijo (<ar:Token>/<ar:Sign> del WsfeAuth) y escapadas dentro del loginCmsReturn de WSAA (&lt;token&gt;/&lt;sign&gt;). Redacta antes de truncar el payload, para que un recorte a mitad de un token no deje media credencial visible. En producción la captura está inactiva, así que la redacción ni corre (costo cero). Ver TLS trust-all y el logger TRAFFIC más abajo (fix e434356).

Multi-ente: un TA por CUIT, sin un provider por comercio

Hay un solo EnteFacturadorTokenProvider (singleton) y un solo WsaaTokenCache. Lo multi-ente es la clave por CUIT: cada comercio tiene su propio TA en el mismo cache/DB. Quienes recorren los N comercios son el job y el warm-up (ver Jobs Quartz), no el provider — el provider resuelve un ente a la vez.

El warm-up del TA al arranque se dispara al detectar el TokenManagerJob. Esa detección es por clase resuelta, no por string crudo: como una fila Tarea puede declarar el job por simple name o por FQCN, matchear solo el literal "TokenManagerJob" salteaba el warm-up en silencio (sistema sin TA hasta el primer cron). El fix e1ce684 compara contra TokenManagerJob.class resuelto.

El TA por CUIT también protege las concurrencias

El check-then-act del cache (WsaaTokenCache.get) se serializa por key (CUIT) con un monitor propio: sin eso, dos hilos con el TA vencido/ausente dispararían dos LoginCms concurrentes para el mismo CUIT y AFIP rechaza el segundo. Es un double-checked locking que NO toma el lock del mapa durante el LoginCms (I/O lento) — un CUIT lento no bloquea a los demás.

Evict del TA en 2 niveles (cambio de cert sin reiniciar)

Cuando se rota el .p12 de un comercio, el TA viejo hay que invalidarlo o la próxima emisión seguiría usando el cert anterior cacheado. EnteFacturadorTokenProvider.evict(ente) lo hace en 2 niveles, también en REQUIRES_NEW:

  • L1 (memoria): WsaaTokenCache.evict(cuit) remueve la entrada del CUIT (idempotente; no toca otros CUITs).
  • L2 (DB): re-lee la fila managed por id y nula token / sign / expirationTime + flush (sobre detached el flush no persistiría — invariante flush-sin-merge, ver Invariantes).

No se fuerza un re-login inmediato (chocaría con el "TA ya válido" de AFIP): el siguiente get(ente) ve L1 miss + L2 vacío ⇒ LoginCms con el cert nuevo. El disparador es la acción "recargar cert" del ABM: POST /api/cockpit/comercios/{id}/cert/reloadEnteFacturadorAdminService.recargarCert(id) (valida que el cert esté configurado, si no responde 422) → evict(ente).

WSFEv1 — operaciones (paquete wsfe)

Todos los clientes WSFE comparten el patrón: sobre SOAP 1.1 con xmlns:ar="http://ar.gov.afip.dif.FEV1/", header Auth (WsfeAuth.toXml() = Token + Sign + Cuit), transporte SoapTransport inyectable, y parseo que primero busca <faultstring>, luego <Err>, y recién después el dato.

Cliente Operación AFIP Entrada → salida
WsfeCaeClient FECAESolicitar solicitarCae(auth, FecaeInput)FecaeResult (un comprobante por request)
WsfeCaeaClient FECAEASolicitar fecaeaSolicitar(auth, periodo, orden)CaeaResult
WsfeCaeaClient FECAEAConsultar fecaeaConsultar(auth, periodo, orden)CaeaResult
WsfeCaeaClient FEParamGetPtosVenta feParamGetPtosVentaCaea(auth)List<PtoVentaInfo> (filtra PV CAEA no bloqueados)
WsfeCaeaClient FECAEARegInformativo fecaeaRegInformativo(auth, in, caea, cbteFchHsGen)FecaeResult
WsfeCaeaClient FECAEASinMovimientoInformar fecaeaSinMovimientoInformar(auth, ptoVta, caea) → void
WsfeConsultaClient FECompConsultar consultar(auth, ptoVta, cbteTipo, comprobante)FecaeResult (lee el CAE de un comprobante autorizado)
WsfeUltimoComprobanteClient FECompUltimoAutorizado ultimoAutorizado(auth, ptoVta, cbteTipo)long (último Nº autorizado; 0 si no hay)
WsfeDummyClient FEDummy dummy()Map (health check; no requiere TA/Auth)

CAE — WsfeCaeClient (FECAESolicitar)

Emite un comprobante por request (como el legacy). El orden de los elementos de FECAEDetRequest respeta el XSD (lo construye FeDetRequestXml): si se altera, AFIP rechaza. El parse exige Resultado=A y un CAE no vacío; ante Err o Resultado=R lanza IllegalStateException con las observaciones (<Obs>). El resultado es FecaeResult (cae, caeFchVto, fchProceso, resultado).

El Resultado que decide es el de la CABECERA

El parse lee el Resultado del FeCabResp (la cabecera, primera en orden de documento), no el del detalle — paridad con el legacy (AfipWSCliente.analyzeErrorsInFECAEResponse). Con CantReg=1 hardcodeado (un comprobante por request) cabecera y detalle no pueden divergir: A de cabecera = todos los detalles aprobados. El P (parcial) requiere 2+ detalles y acá nunca ocurre.

El detalle completo: FeDetRequestXml

FeDetRequestXml.build(FecaeInput) arma los campos de FEDetRequest en el orden exacto del XSD (propOrder), compartido entre FECAESolicitar (CAE) y FECAEARegInformativo (CAEA), que llevan el mismo detalle base. Además de los escalares (concepto, docs, importes, moneda, CondicionIVAReceptorId, fechas de servicio) y el bloque Iva, emite tres sub-arrays opcionales que el legacy sí mandaba y que la reescritura SPEC-010 había perdido (restaurados en el commit 74f1a71):

Sub-array Elemento Contenido Orden XSD
CbtesAsoc <ar:CbteAsoc> Comprobantes asociados (NC/ND → factura acreditada): Tipo, PtoVta, Nro, Cuit, CbteFch. antes de Iva
Tributos <ar:Tributo> Tributos no-IVA (percepciones, imp. internos): Id, Desc, BaseImp, Alic, Importe. antes de Iva
Opcionales <ar:Opcional> Datos opcionales por RG (CBU, leyendas): Id, Valor. después de Iva

El orden importa: CbtesAsoc y Tributos van antes del bloque Iva, Opcionales después. El texto libre (Desc de tributo, Valor/Id de opcional) se escapa a mano con paridad JAXB —incluye \r&#xD;— para no romper el XML ni el byte-a-byte del legacy.

Los sub-arrays no son cosméticos: son money-path

Sin Tributos, un comprobante con ImpTrib > 0 explota con AFIP 10024 (ImpTrib>0 sin bloque Tributos). Sin CbtesAsoc, la Nota de Crédito de la reconciliación anti-doble-factura rompía en silencio (una NC debe declarar el comprobante que acredita). Un golden master (commit 13bca60, src/test/resources/golden/afip/) congela byte-a-byte el request por variante de comprobante —factura B, con tributos, con opcionales, NC con comprobante asociado, servicios, multi-IVA, moneda extranjera, todos los sub-arrays juntos, y CAEA reg-informativo— para cazar cualquier campo que se caiga, reordene o cambie de formato.

TrxToFecaeMapper.map(Trx, cuit) es el puente dominio → FecaeInput: recorre trx.getComprobantesAsociados(), trx.getTributos() y trx.getOpcionales() además de las alícuotas de IVA. El Cuit del comprobante asociado sale del ente emisor (el cuit que recibe el mapper), no de la entidad ComprobanteAsociado —por eso la firma es map(Trx, cuit) y no map(Trx).

CAEA — WsfeCaeaClient

Cubre el ciclo CAEA completo. Detalles fidelity-critical detectados con el smoke de homologación, documentados en el propio código:

  • FECAEASolicitar / FECAEAConsultar: AFIP espera <Periodo> y <Orden> directos bajo la operación, no envueltos en <FeCAEAReq> (envolverlos daba [15004] / [15005]).
  • La operación de "sin movimiento" es FECAEASinMovimientoInformar, no FECAEASinMovimiento (AFIP no reconoce ese SOAPAction).
  • FEParamGetPtosVenta filtra los PV con Bloqueado != "N" y los que no sean EmisionTipo CAEA.
  • Los <Err> se exponen vía WsfeFault con su código (p.ej. 602 = CAEA inexistente → solicitar uno nuevo).

CaeaResult trae caea, periodo, orden, fchVigDesde, fchVigHasta, fchTopeInf, fchProceso.

Consultas de solo lectura

  • WsfeUltimoComprobanteClient (FECompUltimoAutorizado) — la operación más usada en prod; estableció el patrón WSFE. Devuelve el long del último comprobante autorizado.
  • WsfeConsultaClient (FECompConsultar) — consulta un comprobante ya autorizado por (PtoVta, CbteTipo, CbteNro) y devuelve su CAE (campo AFIP CodAutorizacion), vencimiento (FchVto), proceso y resultado.

El orquestador AfipCaeService

AfipCaeService arma el flujo end-to-end para una Trx: pide el TA al Supplier<WsaaCredentials> (cacheado), mapea Trx → FecaeInput (TrxToFecaeMapper), llama a WSFE y actualiza la Trx in-place (cae, caeFchVto, fchProceso, resultado, caeBarCode). Registra métricas de cockpit (recordOk / recordError). Ante rechazo setea resultado="R" y re-lanza.

public FecaeResult emitirCae(Trx trx, EnteFacturador ente) {
    WsaaCredentials creds = tokenForEnte.apply(ente);   // TA POR ENTE (no un Supplier sin parámetro)
    WsfeAuth auth = new WsfeAuth(creds.getToken(), creds.getSign(), ente.getCuit());
    FecaeInput input = TrxToFecaeMapper.map(trx, ente.getCuit());   // el CUIT alimenta los CbtesAsoc
    FecaeResult cae = caeClient.solicitarCae(auth, input);
    applyTo(trx, ente, cae);          // cae, caeFchVto, fchProceso, resultado, caeBarCode
    metrics.recordOk(Op.CAE);
    return cae;
}

El TA es del MISMO comercio que el CUIT del Auth

AfipCaeService recibe un Function<EnteFacturador, WsaaCredentials> (enteTokenProvider::get), no un Supplier sin parámetro. Con multi-comercio un Supplier.get() devolvía el TA del primer habilitado mientras el Cuit del WsfeAuth era el del ente resuelto: TA y CUIT de comercios distintos ⇒ AFIP rechaza. Todos los callers (emisión, NC, gestión CAEA, consultas) piden el TA por ente (get(ente)).

Transporte SOAP (paquete common)

La red está aislada detrás de dos interfaces gemelas: SoapTransport (genérica, para WSFE) y WsaaSoapTransport (para WSAA). Ambas exponen post(url, soapAction, soapBody) y devuelven el body de respuesta crudo.

Clase Rol
SoapTransport Interfaz genérica (POST + SOAPAction).
HttpsSoapTransport Impl HTTPS real para WSFE/ARCA. Timeouts default 15 s / 60 s.
WsaaSoapTransport / HttpsWsaaSoapTransport Idem para WSAA. Timeouts default 15 s / 30 s.
ArcaTlsContextFactory Construye el SSLContext TLS 1.2.
TrafficCapture Captura el payload XML completo a logs/traffic.log (logger TRAFFIC).

Cada POST loguea el sobre completo al logger TRAFFIC ([ARCA-OUT] / [ARCA-IN] / [WSAA-OUT] / [WSAA-IN]) y registra en TrafficCapture. Si el HTTP status es ≥ 400 lee del errorStream igual, para devolver el fault de AFIP.

TLS trust-all (ArcaTlsContextFactory)

ArcaTlsContextFactory.build(...) arma el SSLContext TLS 1.2 directo (en JDK 17+ el TLS 1.2/1.3 es nativo, no hace falta nginx). Acepta keystore cliente (mutual-TLS opcional), truststore (o el default del JDK) y un flag insecureTrustAll.

trust-all activado por default

En este proyecto afip.tls-insecure=true por default, replicando al legacy Https.java que corría trust-all también en producción. Con insecureTrustAll=true: TrustManager que acepta cualquier cert + TRUST_ALL_HOSTS (HostnameVerifier que acepta cualquier hostname). Loguea un WARN fuerte al armarse. Para TLS verificado: afip.tls-insecure=false.

Excepciones AFIP (paquete exceptions)

Estas excepciones son de dominio (las usa la capa de servicio / endpoints SOAP), distintas de las excepciones que lanzan los clientes raw-SOAP (IllegalStateException / WsfeFault).

Clase Tipo Uso
AfipGeneralException @WebFault extends GeneralException Error/observación de AFIP con Resultado (OBSERVACION / ERROR), code y una lista anidada de AfipGeneralException.
ComprobanteNoCorrelativoException @WebFault extends AfipGeneralException Caso específico: el número de comprobante no es correlativo.
AfipUltimoCompAutorizado DTO (no excepción) Resultado de "último autorizado": enteFacturador, cbteNro, cbteTipo, ptoVta.

WsfeFault (paquete wsfe) es aparte: un RuntimeException con el code del primer <Err> de AFIP, para que el llamador reaccione por código (p.ej. 602).

Por dónde seguir