# sat_cfdi_descarga
Cliente Elixir para el **Web Service de Descarga Masiva de CFDI** del SAT (v1.5).
Implementa el flujo oficial de 4 pasos: autenticación con FIEL → solicitud de descarga → verificación de estado → descarga de paquetes ZIP.
---
## Documentación oficial SAT
| Documento | Descripción |
|-----------|-------------|
| [Especificación técnica Descarga Masiva v1.5](https://www.sat.gob.mx/cs/Satellite?blobcol=urldata&blobkey=id&blobtable=MungoBlobs&blobwhere=1461174995051&ssbinary=true) | Especificación completa del WS: operaciones, SOAP envelopes, firma XML-DSig, códigos de estado |
| [Servicio de Solicitud](https://www.sat.gob.mx/cs/Satellite?blobcol=urldata&blobkey=id&blobtable=MungoBlobs&blobwhere=1461175195160&ssbinary=true) | Detalles de implementación del servicio `SolicitaDescarga` |
| [Servicio de Verificación](https://www.sat.gob.mx/cs/Satellite?blobcol=urldata&blobkey=id&blobtable=MungoBlobs&blobwhere=1461175779527) | Detalles del servicio `VerificaSolicitudDescarga` |
### WSDL de los servicios
```
# Autenticación
https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/CFDI-descarga-masiva-CSD-AuthService/autenticacion?wsdl
# Solicitud
https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/CFDI-descarga-masiva-CSD-SolicitudService/solicitud?wsdl
# Verificación
https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/CFDI-descarga-masiva-CSD-ConsultaService/verificacion?wsdl
# Descarga
https://cfdidescargamasivadescarga.clouda.sat.gob.mx/CFDI-descarga-masiva-CSD-DescargaService/descarga?wsdl
```
---
## Instalación
```elixir
# mix.exs
{:sat_cfdi_descarga, "~> 1.5"}
```
Dependencias requeridas: `sat_certificados` (para manejar la FIEL).
---
## Prerequisitos
Necesitas una **FIEL vigente** (`.cer` + `.key` + contraseña). El SAT la llama _e.firma_.
```elixir
{:ok, cred} = Sat.Certificados.Credential.create("fiel.cer", "fiel.key", "mi_contrasena")
```
---
## Flujo de Descarga Masiva
El WS tiene 4 pasos obligatorios y secuenciales. Cada paso depende del resultado del anterior.
```
[1] Autenticacion → token (válido 5 min)
│
▼
[2] Solicitud → id_solicitud
│
▼
[3] Verificacion → ids_paquetes (polling hasta que el SAT termine de procesar)
│
▼
[4] Paquete → ZIP binary (uno por cada id_paquete)
│
▼
Paquete.Reader → XMLs / metadata
```
### Paso 1 — Autenticación
Firma un `wsu:Timestamp` con la FIEL y obtiene un token Bearer válido por **5 minutos**.
```elixir
alias Sat.Cfdi.Descarga.Masiva.Autenticacion
{:ok, token} = Autenticacion.autenticar(credential: cred)
# token.value → "eyJhbGci..."
# token.issued_at → ~U[2025-01-01 00:00:00Z]
# token.expires_at → ~U[2025-01-01 00:05:00Z]
```
El token debe usarse en los pasos 2, 3 y 4. Si expira, repetir este paso.
> **Producción con Oban:** reautenticar al inicio de cada worker o verificar
> `token.expires_at` antes de usarlo.
---
### Paso 2 — Solicitud de descarga
Registra una solicitud de descarga. El SAT retorna un `id_solicitud` que se usa en la verificación.
```elixir
alias Sat.Cfdi.Descarga.Masiva.Solicitud
alias Sat.Cfdi.Descarga.Masiva.Types.SolicitudParams
params = %SolicitudParams{
rfc_solicitante: "AAA010101AAA",
fecha_inicial: ~U[2025-01-01 00:00:00Z],
fecha_final: ~U[2025-01-31 23:59:59Z],
tipo_solicitud: :cfdi # :cfdi | :metadata
}
{:ok, resultado} = Solicitud.solicitar(token, params, credential: cred)
# resultado.id_solicitud → "b6ace7b1-9e39-4cdb-a9c6-7b9f6c7a2e1a"
# resultado.cod_estatus → "5000" (aceptada)
# resultado.mensaje → "Solicitud Aceptada"
```
#### Tipos de solicitud (v1.5)
La operación SOAP se selecciona automáticamente según `tipo_solicitud`:
| `tipo_solicitud` | Operación SOAP | Descripción |
|---|---|---|
| `:emitidos` | `SolicitaDescargaEmitidos` | CFDIs emitidos por el RFC solicitante |
| `:recibidos` | `SolicitaDescargaRecibidos` | CFDIs recibidos por el RFC solicitante |
| `:folio` | `SolicitaDescargaFolio` | Un CFDI específico por UUID |
| `:cfdi` / `:metadata` | `SolicitaDescargaEmitidos` | Fallback (compatibilidad) |
#### Parámetros opcionales de `SolicitudParams`
| Campo | Tipo | Descripción |
|---|---|---|
| `rfc_emisor` | `String` | Filtra por RFC del emisor |
| `rfc_receptor` | `String \| [String]` | Filtra por RFC(s) del receptor |
| `tipo_comprobante` | `:i \| :e \| :t \| :n \| :p \| :null` | I=Ingreso, E=Egreso, T=Traslado, N=Nómina, P=Pago |
| `estado_comprobante` | `:todos \| :vigente \| :cancelado` | Estado del CFDI |
| `complemento` | `String` | Clave del complemento (p.ej. `"nomina12"`) |
| `uuid` | `String` | UUID específico (para solicitud tipo `:folio`) |
| `rfc_a_cuenta_terceros` | `String` | RFC a cuenta de terceros |
> **Límite SAT:** máximo **2 solicitudes con los mismos parámetros** (mismo RFC + rango de fechas).
> La tercera solicitud idéntica retorna `cod_estatus = "5002"` de forma permanente.
#### Códigos de estado en la solicitud
| Código | Significado |
|---|---|
| `5000` | Solicitud aceptada |
| `5002` | Solicitud duplicada (límite alcanzado) |
| `5004` | Sin comprobantes para los parámetros dados |
| `5005` | RFC no autorizado |
---
### Paso 3 — Verificación
Consulta el estado de la solicitud. El SAT procesa en background; puede tomar desde segundos hasta minutos dependiendo del volumen.
#### Verificación simple (un intento)
```elixir
alias Sat.Cfdi.Descarga.Masiva.Verificacion
{:ok, resultado} = Verificacion.verificar(token, id_solicitud, credential: cred)
# resultado.estado_solicitud → :en_proceso | :terminada | :error | ...
# resultado.ids_paquetes → ["PKG_AAA_01", "PKG_AAA_02"]
# resultado.numero_cfdis → 1500
# resultado.codigo_estado_solicitud → "5000"
```
#### Verificación con polling (flujo sincrónico)
```elixir
{:ok, resultado} = Verificacion.esperar_terminada(token, id_solicitud,
credential: cred,
poll_interval_ms: 30_000, # default: 30 segundos
max_attempts: 60 # default: 60 intentos (~30 minutos máximo)
)
ids_paquetes = resultado.ids_paquetes
# ["PKG_AAA_01", "PKG_AAA_02", ...]
```
#### Estados de la solicitud
| Código | Átomo | Descripción |
|---|---|---|
| `1` | `:aceptada` | Solicitud recibida, pendiente de procesar |
| `2` | `:en_proceso` | El SAT está generando los paquetes |
| `3` | `:terminada` | Paquetes listos para descargar |
| `4` | `:error` | Error interno del SAT |
| `5` | `:rechazada` | Solicitud rechazada |
| `6` | `:vencida` | Solicitud expirada (no descargada a tiempo) |
Solo cuando el estado es `:terminada` el campo `ids_paquetes` contiene valores.
> **Producción con Oban:** no usar `esperar_terminada/3`. Crear un worker que llame
> `verificar/3` y se re-encole si el estado es `:en_proceso` o `:aceptada`.
---
### Paso 4 — Descarga de paquetes
Descarga cada paquete como ZIP en bytes. Cada paquete contiene hasta **10,000 CFDIs**.
```elixir
alias Sat.Cfdi.Descarga.Masiva.Paquete
Enum.each(ids_paquetes, fn id_paquete ->
{:ok, paquete} = Paquete.descargar(token, id_paquete, credential: cred)
# paquete.id → "PKG_AAA_01"
# paquete.content → <<80, 75, 3, 4, ...>> (ZIP binary)
# paquete.size → 2_048_000
end)
```
---
### Lectura de paquetes
Una vez descargado el ZIP, `Paquete.Reader` lo extrae en memoria sin escribir al filesystem.
#### CFDIs (tipo `:cfdi`)
```elixir
alias Sat.Cfdi.Descarga.Masiva.Paquete.Reader
{:ok, stream} = Paquete.Reader.stream_cfdis(paquete)
stream
|> Stream.each(fn {filename, xml} ->
File.write!("output/#{filename}", xml)
end)
|> Stream.run()
```
#### Metadata (tipo `:metadata`)
```elixir
{:ok, filas} = Paquete.Reader.parse_metadata(paquete)
# filas → [
# %{uuid: "...", rfcemisor: "AAA...", rfcreceptor: "BBB...", total: "1500.00", ...},
# ...
# ]
```
#### Listar archivos del ZIP (debug)
```elixir
{:ok, nombres} = Paquete.Reader.list_files(paquete)
# ["uuid1.xml", "uuid2.xml", ...]
```
---
## Flujo completo de ejemplo
```elixir
alias Sat.Cfdi.Descarga.Masiva.{Autenticacion, Solicitud, Verificacion, Paquete}
alias Sat.Cfdi.Descarga.Masiva.Paquete.Reader
alias Sat.Cfdi.Descarga.Masiva.Types.SolicitudParams
{:ok, cred} = Sat.Certificados.Credential.create("fiel.cer", "fiel.key", "contrasena")
params = %SolicitudParams{
rfc_solicitante: "AAA010101AAA",
fecha_inicial: ~U[2025-01-01 00:00:00Z],
fecha_final: ~U[2025-01-31 23:59:59Z],
tipo_solicitud: :cfdi
}
# 1. Autenticar
{:ok, token} = Autenticacion.autenticar(credential: cred)
# 2. Solicitar
{:ok, %{id_solicitud: id_sol, cod_estatus: "5000"}} =
Solicitud.solicitar(token, params, credential: cred)
# 3. Verificar con polling
{:ok, %{ids_paquetes: ids}} =
Verificacion.esperar_terminada(token, id_sol, credential: cred)
# 4. Descargar y extraer
Enum.each(ids, fn id ->
{:ok, paquete} = Paquete.descargar(token, id, credential: cred)
{:ok, stream} = Paquete.Reader.stream_cfdis(paquete)
Enum.each(stream, fn {filename, xml} ->
File.write!("output/#{filename}", xml)
end)
end)
```
---
## Pipeline sincrónico (`Masiva.Pipeline`)
Para scripts o herramientas CLI donde no se necesita control paso a paso:
```elixir
alias Sat.Cfdi.Descarga.Masiva.Pipeline
# Stream lazy — no carga todo en memoria
Pipeline.stream_xml(params, credential: cred)
|> Stream.each(fn
{:ok, {filename, xml}} -> File.write!("output/#{filename}", xml)
{:error, reason} -> IO.warn("Error: #{inspect(reason)}")
end)
|> Stream.run()
# Lista completa (solo para volúmenes pequeños < 10,000 CFDIs)
{:ok, xmls} = Pipeline.listar_xml(params, credential: cred)
# Metadata
{:ok, filas} = Pipeline.listar_metadata(params, credential: cred)
```
> **Nota:** En producción con Oban usar los módulos primitivos directamente
> (`Autenticacion`, `Solicitud`, `Verificacion`, `Paquete`), no `Pipeline`.
> Oban provee retry por paso, visibilidad de estado y no bloquea un proceso
> durante el polling de verificación.
---
## Estructura del paquete
```
Sat.Cfdi.Descarga # entry point / version
└── Sat.Cfdi.Descarga.Masiva # WS Descarga Masiva
├── Autenticacion # Paso 1: token FIEL
├── Solicitud # Paso 2: registrar solicitud
├── Verificacion # Paso 3: consultar estado (+ polling)
├── Paquete # Paso 4: descargar ZIP
├── Paquete.Reader # Extraer XMLs / metadata del ZIP
├── Pipeline # Flujo completo sincrónico
├── Types # Structs: Token, SolicitudParams, etc.
└── Internal.* # SOAP, HTTP, XMLDSig (privado)
```
---
## Licencia
MIT