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. 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í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.

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, 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 = 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