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. WSAA lo emite una vez (login con CMS firmado) y se cachea hasta poco antes de expirar; WSFE lo adjunta en el header Auth de cada llamada.
sequenceDiagram
participant S as AfipCaeService
participant C as WsaaTokenCache
participant W as WsaaClient
participant SG as WsaaCmsSigner
participant WS as WSAA (AFIP)
participant FE as WsfeCaeClient
participant AR as WSFEv1 (AFIP)
S->>C: get(key, issuer)
alt TA cacheado y vigente
C-->>S: WsaaCredentials (token+sign)
else sin TA o por expirar
C->>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-->>C: WsaaCredentials
C-->>S: WsaaCredentials
end
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. |
WsaaTokenCache |
Cachea el TA por clave (servicio/cuit) y lo reusa hasta el margen de expiración. |
WsaaSoapTransport / HttpsWsaaSoapTransport |
Interfaz de transporte + impl HTTPS real. |
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íaafip.ticket-time-ms=43200000).source=subjectDNdel 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) — constanteSIGNER_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.
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 (WsaaTokenCache)¶
AFIP rechaza un loginCms si ya hay un TA vigente ("El CEE ya posee un TA válido"), así que el TA se reusa hasta poco antes de expirar. get(key, issuer) devuelve el TA cacheado si sigue válido; si no, lo emite con el issuer y lo cachea. Margen de seguridad: 10 min antes de expirationTime; si el expirationTime no se puede parsear, se considera inválido (renueva). Thread-safe (ConcurrentHashMap).
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).
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, noFECAEASinMovimiento(AFIP no reconoce ese SOAPAction). FEParamGetPtosVentafiltra los PV conBloqueado != "N"y los que no seanEmisionTipoCAEA.- Los
<Err>se exponen víaWsfeFaultcon 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 ellongdel último comprobante autorizado.WsfeConsultaClient(FECompConsultar) — consulta un comprobante ya autorizado por(PtoVta, CbteTipo, CbteNro)y devuelve suCAE(campo AFIPCodAutorizacion), 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 = tokenProvider.get();
WsfeAuth auth = new WsfeAuth(creds.getToken(), creds.getSign(), ente.getCuit());
FecaeInput input = TrxToFecaeMapper.map(trx);
FecaeResult cae = caeClient.solicitarCae(auth, input);
applyTo(trx, ente, cae); // cae, caeFchVto, fchProceso, resultado, caeBarCode
metrics.recordOk(Op.CAE);
return cae;
}
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¶
- API SOAP — los 3 endpoints SOAP que este backend expone.
- El ciclo de facturación — cuándo se usa CAE vs CAEA.
- Invariantes — reglas que no se pueden romper (firma SHA-1, TLS, orden XSD).