# Prompt — Timbrado de CFDI 4.0 de ingreso vía PAC Comercio Digital (modo pruebas)

> Doc **Prompt** del ticket Kanban SM. Plan asincrónico para ejecución autónoma en ventana Sundown.
> Tipo de actividad IA a registrar: **DISEÑO DE PROMPTS IA** (elaboración de este plan) y
> **REVISION DE CODIGO IA** + **PRUEBAS SOBRE CAMBIOS IA** en el Sunrise siguiente.

| Campo | Valor |
|---|---|
| Ticket | SM-XXX *(asignar antes de disparar)* |
| Repo | `CRM_AGENTES_CARGA` |
| Rama base | `feature/crm-cumplimiento-pdf` *(confirmar — ver Bloqueante B3)* |
| Rama de trabajo | `feature/AS-XXX-timbrado-cfdi-ingreso` |
| **Ventana** | **Lunes 10 de agosto de 2026, 17:00 → martes 11, 07:00.** Ciclos cada 30 min (~28) |
| Entregable | Commits locales en la rama de trabajo. **Sin push.** El PR lo abre una persona en el Sunrise |
| Sunrise | Martes 11 de agosto de 2026, primera hora — ver `automatizacion/SUNRISE.md` |
| Estimación | 6–7 h de trabajo efectivo |
| Riesgo | Medio-bajo — módulo nuevo, sin migraciones a prod, sin tocar flujo de facturación existente |

> **Operación autónoma.** Este plan se ejecuta en una ventana nocturna sin supervisión. El mecanismo
> (corredor, preflight, instrucción, Sunrise) está montado en [`automatizacion/`](../../automatizacion/)
> y se describe en §10. **La instrucción que recibe el agente cada ciclo es
> `automatizacion/instruccion-nocturna.md`**, que remite a este documento como especificación.

---

## 1. Objetivo

Agregar al backend del CRM la capacidad de **generar, sellar y timbrar un CFDI 4.0 de tipo
ingreso (`TipoDeComprobante = "I"`)** a partir de una factura existente en `fin.invoices`,
usando el PAC **Comercio Digital** en su **entorno de pruebas**.

El proceso replica el que hoy vive en la solución legada WinForms `CFDI.sln`
(.NET Framework 4.8, `CFDI/CFDI.cs`), pero reimplementado en Python/FastAPI siguiendo las
convenciones del CRM. La solución legada es **fuente de verdad del contrato con el PAC**, no
un modelo de arquitectura a copiar.

### Fuera de alcance (NO hacer)

- Frontend SvelteKit. Este Sundown es backend puro.
- Tipos de comprobante distintos de `I` (egreso `E`, traslado `T`, pago `P`, retenciones).
- Complementos (Carta Porte, Comercio Exterior, INE, Notarios, Pagos 2.0).
- Cancelación, consulta de estatus, recuperación de XML, consulta de saldo del PAC.
- Timbrado contra el entorno de **producción** de Comercio Digital.
- Generación del PDF/representación impresa del comprobante timbrado y del código QR.
- Migraciones aplicadas a bases de datos productivas.

---

## 2. Estado actual del CRM (verificado)

Ya existe y **se debe reutilizar, no duplicar**:

| Pieza | Ruta | Qué aporta |
|---|---|---|
| Catálogos SAT | `backend/api/v1/modules/fin/catalogs/` | Schema `sat`: `tax_regimes`, `taxes`, `payment_forms`, `payment_methods`, `voucher_types`, `units_of_measure`, `products_services`, `tax_objects`. Con `seed_data.py`. |
| Datos del emisor | `backend/api/v1/modules/fin/issuer/` | `fin.issuer_settings`: `legal_name`, `rfc`, `tax_regime_id`, `zip_code`. Único vigente por empresa. |
| Facturas | `backend/api/v1/modules/fin/invoices/` | `fin.invoices`, `fin.invoice_items`, `fin.invoice_item_taxes`, `fin.payments`. Ya tienen FK a los catálogos SAT (`voucher_type_id`, `payment_form_id`, `payment_method_id`, `expedition_zip_code`, `product_service_id`, `unit_of_measure_id`, `tax_object_id`). |
| Conceptos | `backend/api/v1/modules/fin/concepts/` | Catálogo interno de conceptos facturables. |
| Claves MinIO | `backend/core/s3_keys.py:288` | `company_certificate_key(tenant_id, company_id, certificate_type, timestamp, file_ext)` → `tenants/{tid}/companies/{cid}/certificates/{tipo}_{ts}.{cer\|key}`. **El CSD ya tiene dónde vivir.** |
| Config | `backend/core/config.py` | `Settings` (pydantic-settings). Aquí se agregan las variables del PAC. |
| Tests | `backend/tests/` | Convención `test_<modulo>.py`. Referencias cercanas: `test_fin_sat_catalogs.py`, `test_invoices.py`. |

**Lo que NO existe todavía:** ningún módulo de certificados/CSD, ningún cliente de PAC,
ninguna generación de XML CFDI. Este ticket lo crea.

---

## 3. Contrato con el PAC — extraído del legado (fuente de verdad)

Todo lo de esta sección está verificado en
`C:\Users\Jair Cedillo\Documents\Visual Studio 2022\Projects\Proyectos\CFDI CP y 4.0\CFDI\CFDI\CFDI.cs`
(21,127 líneas; el agente NO necesita abrir el archivo, la información relevante está aquí).

### 3.1 Endpoint de timbrado — `timbrarV5Xml` (`CFDI.cs:19274-19346`)

```
POST https://{host}/timbre4/timbrarV5

host pruebas     : pruebas.comercio-digital.mx
host producción  : ws.comercio-digital.mx

Content-Type : text/plain
Timeout      : 15 s
TLS          : 1.2 (el legado fuerza SecurityProtocolType 3072)

Headers de petición:
  usrws  : usuario del web service (longitud válida 12–13 caracteres)
  pwdws  : password del web service
  tipo   : "XML"
  email  : opcional, en minúsculas; se omite el header si va vacío

Body: bytes crudos del XML CFDI **ya sellado**, en UTF-8. NO va en base64,
      NO va envuelto en SOAP.

Respuesta:
  body    : XML del CFDI timbrado (con el nodo tfd:TimbreFiscalDigital incorporado)
  headers : uuid, errmsg, saldo, erremail, codigo
```

Validaciones previas que el legado hace antes de salir a la red (replicarlas):

| Condición | Código | Mensaje |
|---|---|---|
| `len(usrws)` fuera de 12–13 | 701 | Usuario/password inválido |
| `pwdws` vacío | 702 | Usuario/password inválido |
| XML nulo o < 200 bytes | 711 | Contenido XML vacío |
| Excepción de red | 833 | Error de transmisión |
| HTTP != 200 | 998 | Error HTTP |

**Criterio de éxito del legado:** el header `errmsg` viene vacío. Si trae contenido, es error
(`ComDig_Exception`, `CFDI.cs:16516-16519`).

### 3.2 Defectos del legado que NO se deben replicar

Al portar `timbrarV5Xml` hay que corregir, no copiar:

1. **`codigo` nunca recibe el valor del header `codigo`.** En `CFDI.cs:19331-19336` el valor se
   lee en la variable local `cod2`, que se descarta. El parámetro de salida `codigo` termina
   siempre en `999` o `991`. → En Python, el código del PAC **sí** se debe leer y persistir.
2. **`saldo` nunca se asigna.** Mismo patrón (`CFDI.cs:19324-19327`): se lee en `cod2` y se
   pierde. El parámetro de salida queda siempre en `0`. → Leerlo y persistirlo como entero.
3. **`GetResponseHeader` devuelve `""` y no `null`** cuando el header no existe, así que la
   rama `if (... == null) codigo = 991` prácticamente nunca se cumple. → En Python usar
   presencia real en el diccionario de headers.
4. **Los errores se tragan con `MessageBox`** y el flujo continúa. → En el CRM, error del PAC
   se traduce a excepción de dominio y respuesta HTTP con detalle, nunca a un `except: pass`.

### 3.3 RFC del proveedor de certificación (`CFDI.cs:16665`)

| Entorno | RFC |
|---|---|
| Pruebas | `SPR190613I52` |
| Producción | `SCD110105654` |

Sirve para **verificar** que el `RfcProvCertif` del timbre recibido corresponde al entorno
esperado. Si no coincide, es error: se timbró contra el entorno equivocado.

### 3.4 Cadena original y sello (`CFDI.cs:8295-8402`, `CFDI.cs:8150-8210`)

- La **cadena original** se obtiene aplicando la transformación XSLT oficial del SAT
  `cadenaoriginal_4_0.xslt` al XML del comprobante **sin sello**, y decodificando las
  entidades HTML del resultado.
- El **sello** es `RSA` + `SHA256` sobre los bytes UTF-8 de la cadena original, resultado en
  base64. (El legado usa SHA1 solo para retenciones con otro PAC — para CFDI de ingreso con
  Comercio Digital es **SHA256**.)
- La llave privada `.key` del CSD es **PKCS#8 DER cifrada con contraseña**; el certificado
  `.cer` es **X.509 DER**.
- El atributo `Certificado` del comprobante es el **base64 del DER** del `.cer`.
- El atributo `NoCertificado` es el número de serie del certificado del SAT, que viene
  codificado de forma que sus bytes son directamente los 20 caracteres ASCII del número.

⚠️ **Lección de seguridad del legado:** el XSLT del SAT hace `xsl:include` de otros archivos.
El legado tuvo que introducir un resolver que no descarga recursos externos (ver el bloque
comentado en `CFDI.cs:8333-8363`). En el CRM, los XSLT se versionan en el repo y la
transformación se ejecuta **sin acceso a red y sin habilitar scripts ni `document()`**.

### 3.5 Detalles del XML CFDI 4.0 verificados en el legado

- **Orden de atributos del nodo `cfdi:Comprobante`** (`CFDI.cs:14340-14394`):
  `Version`, `Serie`, `Folio`, `Fecha`, `Sello`, `FormaPago`, `NoCertificado`, `Certificado`,
  `CondicionesDePago`, `SubTotal`, `Descuento`, `Moneda`, `TipoCambio`, `Total`,
  `TipoDeComprobante`, `Exportacion`, `MetodoPago`, `LugarExpedicion`.
- `Serie` y `CondicionesDePago` se omiten si vienen vacíos; `Descuento` solo si es > 0;
  `TipoCambio` solo si la moneda no es MXN.
- **`Fecha` en CFDI 4.0 va SIN offset de zona horaria** (`CFDI.cs:12654`: el `-06:00` es
  exclusivo de CFDI 3.3). Formato `YYYY-MM-DDTHH:MM:SS`.
- `Exportacion` es obligatorio en 4.0. Para factura de ingreso nacional: `"01"` (no aplica).
- `TipoDeComprobante = "I"` es el caso por defecto en el legado (`CFDI.cs:8966`).

---

## 4. Diseño a implementar

### 4.1 Estructura de archivos

Módulo nuevo por dominio, siguiendo la convención de `fin/`:

```
backend/api/v1/modules/fin/stamping/
├── __init__.py
├── models.py                 # InvoiceStamp → fin.invoice_stamps
├── dto.py                    # Pydantic v2
├── routes.py                 # endpoints
├── service.py                # orquestación del flujo
├── cfdi_builder.py           # construcción del XML CFDI 4.0 tipo I
├── sealer.py                 # cadena original (XSLT) + sello RSA-SHA256
├── pac_comercio_digital.py   # cliente HTTP del PAC
└── xslt/                     # XSLT oficiales del SAT, versionados
    ├── cadenaoriginal_4_0.xslt
    └── utilerias.xslt        # (y cualquier otro archivo incluido por el anterior)
```

Los XSLT se descargan **una sola vez durante el desarrollo** desde `www.sat.gob.mx` y se
commitean. En tiempo de ejecución no se descarga nada.

### 4.2 Modelo de datos

#### Campo nuevo en `fin.invoices`

El modo de timbrado **se decide por factura**, no por entorno:

```
stamping_mode  VARCHAR(12)  NOT NULL  DEFAULT 'pruebas'   -- 'pruebas' | 'produccion'
```

- Editable desde el CRUD de facturas (`InvoiceCreate` / `InvoiceUpdate` / `InvoiceResponse`).
- Al crear una factura sin el campo, toma el valor de `PAC_DEFAULT_MODE` (§4.5); si tampoco
  está, `'pruebas'`.
- **Inmutable una vez timbrada**: si la factura ya tiene un `invoice_stamp` en estado
  `timbrado`, un `PATCH` que intente cambiar `stamping_mode` se rechaza con 409. Cambiarlo
  después del hecho falsearía el registro de con qué intención se emitió.
- Validado como enum cerrado en el DTO Pydantic. Un valor distinto de los dos permitidos es
  error de validación (422), nunca un default silencioso.

Este campo es la **única** fuente del modo. No se acepta modo ni host por body, query string ni
cabecera en el endpoint de timbrado.

#### Tabla nueva `fin.invoice_stamps`

Una fila por intento de timbrado (incluidos los fallidos, para trazabilidad). Usa
`TenantScopedMixin` + `TimestampMixin` como el resto del módulo.

Campos mínimos:

- `id`, `invoice_id` (FK `fin.invoices.id`, indexado)
- `mode` — `pruebas` | `produccion`
- `status` — `pendiente` | `timbrado` | `error`
- `uuid` (36, nullable, indexado)
- `stamped_at` (datetime, nullable) — `FechaTimbrado` del TFD
- `pac_rfc` (13, nullable) — `RfcProvCertif` recibido
- `sat_cert_number` (20, nullable) — `NoCertificadoSAT`
- `sat_seal` / `cfd_seal` (Text, nullable) — `SelloSAT` / `SelloCFD`
- `pac_code` (int, nullable), `pac_balance` (int, nullable) — headers `codigo` y `saldo`
- `error_message` (Text, nullable) — header `errmsg`
- `xml_file_key` (512, nullable) — clave MinIO del XML timbrado

El campo `mode` se copia desde `invoices.stamping_mode` **en el momento del timbrado** y queda
congelado en la fila: es el registro de contra qué entorno se transmitió realmente.

Índice único parcial sobre `uuid` (donde `deleted_at IS NULL`) para impedir UUID duplicado.

**Migración Alembic** en `backend/alembic/versions/`, siguiendo el estilo de las existentes.
Se aplica solo en local/testing. **No tocar producción.**

### 4.3 Flujo del servicio

`stamp_invoice(db, invoice_id, tenant_id, company_id, user_id)`:

1. **Cargar y validar** — la factura existe, pertenece al tenant/company, no está cancelada y
   **no tiene ya un timbre en estado `timbrado`** (idempotencia: si lo tiene, devolver el
   existente sin volver a timbrar; timbrar dos veces cuesta folios y genera un CFDI duplicado
   ante el SAT).
2. **Validar completitud fiscal** — emisor configurado (`fin.issuer_settings`), receptor con
   RFC / razón social / régimen / domicilio fiscal, partidas con `product_service_id`,
   `unit_of_measure_id` y `tax_object_id`, forma y método de pago. Cada faltante se acumula y
   se devuelve como lista, no se falla en el primero.
3. **Construir el XML** sin sello (`cfdi_builder`).
4. **Calcular cadena original** por XSLT y **sellar** con el CSD (`sealer`).
5. **Insertar `Sello`, `NoCertificado` y `Certificado`** en el comprobante.
6. **Transmitir al PAC** (`pac_comercio_digital.stamp`), con el modo leído de
   `invoice.stamping_mode` — que determina el host, ver §4.5.
7. **Verificar el timbre recibido** — el nodo `tfd:TimbreFiscalDigital` existe y trae `UUID`, y
   el `RfcProvCertif` corresponde al modo solicitado (§3.3). Si no corresponde, es error grave:
   se timbró contra un entorno distinto del pedido.
8. **Persistir** el registro en `fin.invoice_stamps` y subir el XML timbrado a MinIO.
9. **Devolver** el resumen (uuid, fecha, modo, clave del archivo).

Si algo falla en 6–7: se persiste la fila con `status = "error"`, `pac_code` y `error_message`,
y se responde con error HTTP. **No se marca la factura como timbrada.**

### 4.4 Endpoints

```
POST /api/v1/fin/invoices/{invoice_id}/stamp?company_id={cid}
GET  /api/v1/fin/invoices/{invoice_id}/stamp?company_id={cid}
GET  /api/v1/fin/invoices/{invoice_id}/stamp/xml-url?company_id={cid}
```

Mismo patrón de dependencias que `invoices/routes.py`: `company_id: int = Query(...)`,
`current_user: dict = Depends(get_current_user)`, `db: Session = Depends(get_core_db)`.

### 4.5 Configuración

En `backend/core/config.py`, agregar al `Settings`:

```python
# ----- PAC Comercio Digital (timbrado CFDI) -----
# El modo NO se configura aquí: vive en invoices.stamping_mode (§4.2). Esta variable
# solo define con qué valor nacen las facturas nuevas que no lo especifican.
PAC_DEFAULT_MODE: str = "pruebas"                      # pruebas | produccion
PAC_HOST_TEST: str = "pruebas.comercio-digital.mx"
PAC_HOST_PROD: str = "ws.comercio-digital.mx"
PAC_USER: str = ""                                     # header usrws
PAC_PASSWORD: str = ""                                 # header pwdws
PAC_TIMEOUT_SECONDS: int = 15
PAC_NOTIFICATION_EMAIL: str = ""                       # header email, opcional
CSD_PASSWORD: str = ""                                 # contraseña de la llave .key
```

El andamiaje ya está hecho: el passthrough existe en `docker-compose.yml` (bloque
`backend.environment`) y los nombres están documentados en `.env.example`. Los **valores** viven
solo en `.env`, que está en `.gitignore` (línea 28). Falta únicamente declarar los campos en
`Settings`.

⚠️ **`docker-compose.yml` no está versionado** (`.gitignore` línea 77): cada quien tiene el suyo.
El passthrough ya está aplicado en el compose local de este clon, que es donde corre la ventana
nocturna, así que el agente lo hereda. Pero **no intentes commitearlo ni versionarlo**: git lo
ignora y forzarlo rompería la convención del proyecto. Si otro entorno necesita estas variables,
se documentan en `.env.example` — que sí está versionado — y cada quien las añade a su compose.

Reglas duras:

- **Ningún valor real** (usuario, password, RFC, contraseña de CSD) se escribe en el código,
  en tests, en fixtures ni en `.env.example`. Solo variables de entorno con default vacío.
- Si faltan `PAC_USER` o `PAC_PASSWORD`, el servicio falla con mensaje claro en vez de intentar
  timbrar con cadenas vacías. (El legado ya lo hace con los códigos 701/702 — §3.1.)

#### Resolución del host — regla única

El host **se deriva exclusivamente del enum** `invoice.stamping_mode`, en el servidor:

| `stamping_mode` | Host |
|---|---|
| `pruebas` | `PAC_HOST_TEST` → `pruebas.comercio-digital.mx` |
| `produccion` | `PAC_HOST_PROD` → `ws.comercio-digital.mx` |

Tres reglas que el cliente del PAC debe cumplir:

1. **Nunca aceptar un host, una URL ni un modo por parámetro de la petición HTTP.** El modo se
   lee de la factura y punto. Es el defecto de diseño del legado, donde `CFDIPacUrl` es un
   campo mutable que cualquier rama del código puede reasignar (§3.1).
2. **Fallar de entrada** ante un `stamping_mode` que no sea exactamente `"pruebas"` o
   `"produccion"`. Nada de default silencioso. Ojo con el legado: en `CFDI.cs:19288` un host
   vacío cae a `pruebas.comercio-digital.mx` — comportamiento implícito que aquí no se replica.
3. **Verificar el timbre recibido** contra el modo solicitado. Si se pidió `pruebas` y el
   `RfcProvCertif` devuelto es el de producción (`SCD110105654`), o al revés, se trata como
   error grave: se registra y la operación **no** se da por exitosa.

⚠️ **Contexto para quien lea esto después.** Las credenciales configuradas son de la cuenta de
**producción** del PAC (§8, B1), y por decisión del responsable del proyecto **no** existe un
veto de entorno: una factura con `stamping_mode = "produccion"` timbra un CFDI con validez
fiscal real ante el SAT desde cualquier entorno donde estén esas credenciales, incluido
desarrollo. Deshacerlo requiere cancelar el comprobante ante el SAT. Las tres reglas de arriba
son, por tanto, la única barrera técnica que queda: no se relajan sin decisión explícita.

**Restricción para este Sundown:** todas las facturas que el agente cree o use para pruebas
llevan `stamping_mode = "pruebas"`. El agente **no** debe timbrar en `produccion` bajo ninguna
circunstancia (ya está en Fuera de alcance, §1).

### 4.6 Dependencias nuevas — justificación obligatoria

Dos, ambas necesarias y sin alternativa razonable en la stdlib:

| Paquete | Por qué |
|---|---|
| `lxml` | La cadena original del SAT **solo** se puede calcular aplicando el XSLT 1.0 oficial. `xml.etree` de la stdlib no hace XSLT. Es la única implementación de XSLT 1.0 madura en Python. |
| `cryptography` | Lectura del `.key` (PKCS#8 DER cifrado) y del `.cer` (X.509 DER), y firma RSA-SHA256. Ya entra de forma transitiva por `python-jose[cryptography]`; aquí se declara explícita porque pasa a ser dependencia directa. |

Agregar a `backend/requirements.txt` con versión fijada, en una sección comentada
`# CFDI / timbrado`, respetando el estilo del archivo.

---

## 5. Pruebas

### 5.1 Unitarias — sin red (`backend/tests/test_fin_stamping.py`)

- **Constructor de XML**: dada una factura de prueba, el XML resultante valida contra el XSD
  `cfdv40.xsd`, tiene los atributos en el orden correcto, `TipoDeComprobante="I"`,
  `Exportacion="01"`, y `Fecha` sin offset de zona horaria.
- **Omisión de atributos opcionales**: sin serie → no aparece `Serie`; descuento en 0 → no
  aparece `Descuento`; moneda MXN → no aparece `TipoCambio`.
- **Cadena original**: la transformación XSLT produce una cadena que empieza con `||` y
  termina con `||`, y no intenta ninguna descarga externa.
- **Sello**: firmando con el CSD público de pruebas del SAT, el sello resultante **verifica**
  contra la llave pública del `.cer`. Es la prueba fuerte del sellado.
- **Cliente del PAC contra un doble**: valida los cinco casos de error previos (701, 702, 711,
  833, 998) y el camino feliz, comprobando que `codigo` y `saldo` **sí** se extraen de los
  headers — el defecto corregido de la sección 3.2.
- **Idempotencia**: timbrar dos veces la misma factura no genera un segundo llamado al PAC.
- **Validación de completitud**: una factura incompleta devuelve **todos** los faltantes.
- **Resolución del host por modo** (§4.5): una factura con `stamping_mode = "pruebas"` produce
  una URL contra `pruebas.comercio-digital.mx`, y una con `"produccion"` contra
  `ws.comercio-digital.mx`. Ambos casos se prueban contra el doble del PAC, sin red.
- **Modo inválido**: `stamping_mode = "prod"`, `""` o `None` es error de validación, y en
  ningún caso termina resolviendo a un host.
- **El modo no se puede inyectar por HTTP**: mandar `mode` / `host` / `url` en el body o el
  query string de `POST /stamp` no altera el host usado. Es la prueba de la regla 1 de §4.5.
- **`stamping_mode` inmutable tras timbrar**: `PATCH` sobre una factura ya timbrada devuelve
  409 y no modifica el valor.
- **Verificación de `RfcProvCertif`**: un doble que responde con el RFC del entorno equivocado
  deja la fila en `status = "error"` y no marca la factura como timbrada.

### 5.2 Integración — timbrado real en pruebas (`test_fin_stamping_pac.py`)

Marcada con `@pytest.mark.skipif` sobre la ausencia de `PAC_USER`/`PAC_PASSWORD` en el entorno,
para que la suite normal siga siendo verde sin credenciales.

- Timbra un CFDI de ingreso completo contra `pruebas.comercio-digital.mx`.
- Verifica: `errmsg` vacío, `uuid` con formato UUID válido, `RfcProvCertif == "SPR190613I52"`,
  el XML devuelto contiene `tfd:TimbreFiscalDigital` y la fila en `fin.invoice_stamps` queda en
  `status = "timbrado"`.

### 5.3 CSD de pruebas

Se usa el **CSD público de demostración que publica el SAT** (RFC de prueba). No es dato
sensible y puede vivir en `backend/tests/fixtures/csd/`. **Bajo ninguna circunstancia** se
commitea un CSD real de un cliente.

Datos dummy autorizados para el resto de las pruebas: RFC `XAXX010101000`,
pedimento `0000-0000000`.

---

## 6. Criterios de aceptación

Verificables uno por uno en el Sunrise:

1. `POST /api/v1/fin/invoices/{id}/stamp` sobre una factura completa con
   `stamping_mode = "pruebas"` devuelve 200 con `uuid`, `stamped_at`, `mode: "pruebas"` y
   `xml_file_key`.
2. El XML timbrado queda en MinIO bajo una clave obtenida de `core/s3_keys.py` (función nueva,
   no ruta construida a mano en la ruta HTTP).
3. Existe una fila en `fin.invoice_stamps` con `status = "timbrado"` y `pac_rfc = "SPR190613I52"`.
4. Un segundo `POST` sobre la misma factura devuelve el timbre existente **sin** llamar al PAC.
5. Una factura sin `issuer_settings` o con partidas sin clave de producto/servicio devuelve 422
   con la lista completa de faltantes.
6. Un error del PAC deja fila con `status = "error"`, `pac_code` y `error_message` poblados, y
   la factura **no** queda marcada como timbrada.
7. `pytest backend/tests/test_fin_stamping.py` pasa completo sin acceso a red.
8. `black`, `flake8` y `mypy` pasan sobre el módulo nuevo.
9. La migración Alembic sube y baja limpio (`upgrade head` / `downgrade -1`) en local, e
   incluye tanto `fin.invoice_stamps` como la columna `fin.invoices.stamping_mode`.
10. No hay credenciales, RFC reales ni CSD de cliente en el diff. Verificable con
    `git diff --stat` + inspección de los archivos nuevos.
11. `stamping_mode` es editable vía `POST`/`PATCH` de facturas, se refleja en
    `InvoiceResponse`, y una factura ya timbrada rechaza su modificación con 409.
12. Con `stamping_mode = "produccion"` el cliente resuelve `ws.comercio-digital.mx` — probado
    **solo contra el doble del PAC**. Ninguna prueba transmite a producción.

---

## 7. Convenciones obligatorias

- **Nombres de código en inglés**; comentarios y docstrings en **español** para la lógica
  fiscal/aduanera, igual que en `fin/issuer/models.py` y `fin/catalogs/models.py`.
- **FastAPI**: Pydantic v2, routers por dominio, DTOs separados de los modelos.
- **Sin `print` de depuración. Sin `except: pass`.** Todo error se maneja o se propaga.
- Conventional commits (`feat(fin): ...`, `test(fin): ...`, `chore(deps): ...`).
- Rama `feature/AS-XXX-timbrado-cfdi-ingreso`. **PR, nunca push directo** a main/master/
  develop/release.
- No inventar tablas, campos ni endpoints fuera de los especificados aquí. Si hace falta algo
  que no está descrito, se documenta como PENDIENTE DECISIÓN.

### Ambigüedad durante la ejecución autónoma

**No adivinar.** Cualquier decisión no cubierta por este documento se implementa con la opción
más conservadora y se documenta en el cuerpo del PR bajo un encabezado
`## PENDIENTE DECISIÓN`, con: qué se asumió, qué alternativas había, y qué se necesita para
resolverlo. Es la misma convención que ya usa `fin/catalogs/seed_data.py`.

---

## 8. Bloqueantes a resolver ANTES de disparar

El DoR **no se cumple** hasta que estos tres estén cerrados:

- **B1 — Credenciales del PAC. RESUELTO, con reserva.** Se reutilizan las credenciales de
  Comercio Digital que hoy usa la solución legada, apuntando al host de pruebas. Van en `.env`
  como `PAC_USER` / `PAC_PASSWORD`; el andamiaje ya está listo (§4.5). **No se pegan en este
  documento, en el chat ni en ningún archivo versionado.**
  → Reserva: son credenciales de **producción** — en el legado las ramas de pruebas y
  producción del ternario son idénticas (§9). Mientras eso siga así, la única barrera entre un
  timbre de prueba y un CFDI fiscal real es el host, y por eso la salvaguarda de §4.5 no es
  opcional. Conviene solicitar a Comercio Digital un usuario de pruebas independiente y
  sustituirlo en `.env` cuando exista; no bloquea este Sundown.
  → Nota: el `usrws` es de 12–13 caracteres (formato de RFC), así que el RFC del emisor en el
  CFDI de prueba debe ser coherente con la cuenta del PAC. Si el CSD de demostración del SAT
  resulta incompatible con esa cuenta, el agente lo documenta como PENDIENTE DECISIÓN en el PR
  en vez de inventar un RFC.

- **B2 — Trabajo previo sin commitear.** `backend/api/v1/modules/fin/catalogs/` aparece como
  no rastreado en git. Este ticket **depende** de esos catálogos. Hay que commitearlo (o
  fusionarlo) antes de disparar; si no, el agente arranca sobre una base que no está en el
  árbol y el PR saldrá mezclando dos trabajos.

- **B3 — Rama base del PR.** El repo tiene `feature/crm-cumplimiento-pdf` como rama principal
  de integración, no `main`. Confirmar que el PR va contra ella.

- **B4 — CLI de Claude Code. RESUELTO.** Instalado en WSL (`~/.local/bin/claude`, versión
  `2.1.224`) y verificado también por la ruta que usa el corredor (PowerShell → `wsl.exe` →
  `bash -lc`). El ensayo de extremo a extremo corre y devuelve `ENSAYO-OK` — ver §10.3.

- **B5 — La tarea programada no está registrada.** Requiere PowerShell **como administrador**; un
  agente no puede ni debe registrarse una tarea del sistema. El bloque listo para pegar está en
  §10.6. Al registrarla, verificar que `NextRunTime` **no** salga vacío: una tarea con hora de
  arranque ya pasada nunca dispara y en la interfaz se ve perfectamente bien.

> **Alcance de la noche: COMPLETO, en una sola ventana.** Se planteó partirlo en dos —el mecanismo
> primero, el timbrado real después—, siguiendo la recomendación del kit de que la primera ventana
> lleve un entregable pequeño. **Decisión del responsable del proyecto: se ejecutan los cinco
> entregables de §4 en la ventana del lunes 10.** No se reserva nada para una segunda noche.
>
> Consecuencia práctica para el agente: la escalera de `instruccion-nocturna.md` se recorre entera y
> el entregable 5 (timbrado real contra `pruebas.comercio-digital.mx`) **sí** entra en el alcance,
> siempre que `PAC_USER` y `PAC_PASSWORD` estén en el entorno. Si faltan, se salta ese entregable y
> se sigue con la escalera — no es motivo para detener la noche.

---

## 9. Hallazgo de seguridad — acción separada de este ticket

Durante el análisis del legado se encontraron **credenciales de ambos PAC en texto plano dentro
del código fuente** de `CFDI.cs`. Ubicaciones exactas:

| Líneas | PAC | Contenido |
|---|---|---|
| 2709, 2711 | Edicom | usuario y password (2710: password anterior, comentado) |
| 2720, 2721 | Comercio Digital | usuario y password |
| 6858, 6860 | Edicom | duplicado exacto del bloque 2709 |
| 6863, 6864 | Comercio Digital | duplicado exacto del bloque 2720 |
| 7210, 7212 | Edicom | tercer duplicado |
| 7216, 7217 | Comercio Digital | tercer duplicado |
| 21061, 21063 | Edicom | dentro de `tst_xml_ine()` (línea 20910), más un `Emisor_Rfc` fijo |
| 20833, 20834 | Edicom | dentro de `tst_xml()` (línea 20695), comentado |

Agravantes:

1. **El "modo pruebas" de Comercio Digital usa credenciales de producción.** Los ternarios de
   las líneas 2720-2721 (y sus dos duplicados) tienen **ambas ramas idénticas**:
   `CFDIMod == "Prueba" ? X : X`. Lo único que realmente cambia entre entornos es la URL
   (línea 2722).
2. **`tst_xml_ine()` no se invoca desde ningún lado, pero se compila.** Las credenciales quedan
   en el ejecutable distribuido, y `Costura.Fody` embebe todo en un único `.exe`: son
   recuperables con cualquier decompilador de .NET.
3. El password de Edicom de la línea 2711 tiene forma de contraseña corporativa, con riesgo de
   estar reutilizada en otros sistemas.

Todo está commiteado en el repositorio `CFDI` (rama `main`, con remoto configurado).

**Recomendación:** rotar esas credenciales con Comercio Digital y moverlas a configuración
externa en la solución legada. Es un ticket aparte — **no** forma parte de este Sundown y el
agente autónomo **no** debe tocar la solución C#.

---

## 10. Operación de la ventana nocturna

El plan se ejecuta sin supervisión entre las 17:00 del lunes 10 y las 07:00 del martes 11 de agosto
de 2026, en ciclos de 30 minutos disparados por el Programador de tareas de Windows. Cada ciclo es
un proceso nuevo **sin memoria del anterior**: la única continuidad es la bitácora.

### 10.1 Las piezas

| Archivo | Dónde corre | Qué hace |
|---|---|---|
| `automatizacion/ventana-nocturna.ps1` | Windows | El corredor. Vigila la ventana, el candado, el árbol de procesos y la cuota |
| `automatizacion/ciclo-noche.sh` / `ciclo-ensayo.sh` | WSL | Envoltorios **sin argumentos**. Existen para que ningún argumento lleve espacios — ver §10.2 |
| `automatizacion/ciclo-wsl.sh` | WSL | Un ciclo: mete la instrucción por stdin y lanza el agente |
| `automatizacion/cpu_arbol.py` | WSL | Mide si el ciclo trabaja o está muerto de pie |
| `automatizacion/instruccion-nocturna.md` | — | **La instrucción del agente.** Es el 80% del valor del montaje |
| `automatizacion/instruccion-ensayo.txt` | — | Instrucción de una línea para el ensayo del mecanismo |
| `automatizacion/preflight.py` | WSL | Comprueba, antes de irse, que la ventana *puede* funcionar |
| `automatizacion/SUNRISE.md` | — | Qué revisar el martes por la mañana, en orden |

Nada de esto se commitea desde un ciclo nocturno: la bitácora vive en `~/.ventana/`, y el log del
corredor en `%LOCALAPPDATA%\ventana-nocturna\`, ambos fuera del árbol versionado.

### 10.2 La adaptación a WSL, y por qué no es cosmética

El kit de operación nocturna asume Windows de punta a punta. Aquí el proyecto vive en WSL2, así que
el corredor sigue en Windows pero cada ciclo entra a la distro con `wsl.exe`. Tres consecuencias que
**no se deben "simplificar"**:

1. **La CPU se mide dentro de Linux, no desde Windows.** En WSL2 el trabajo ocurre en una VM ligera:
   `Get-Process` y `Win32_Process` solo ven `wsl.exe`, un cliente delgado con CPU casi nula. Medir
   desde Windows daría "mudo" en **todos** los ciclos, incluidos los sanos, y el corte por silencio
   apagaría la ventana en el primer disparo. Verificado: un árbol que trabaja de verdad mide 8.00 s
   con `cpu_arbol.py` y **0.00 s** si solo se mira la raíz.
2. **Matar `wsl.exe` no mata al agente.** El árbol hay que terminarlo también del lado Linux
   (`pkill`), que es el que consume cuota.
3. **`bash -lc`, nunca `bash -c`.** El CLI vive en `~/.local/bin`, que solo entra al `PATH` en un
   shell de *login*. Sin el `-l`, el ciclo muere con «command not found» y cero caracteres de salida
   — indistinguible de un cuelgue. `ciclo-wsl.sh` además asegura ese `PATH` por su cuenta, para que
   un ensayo lanzado a mano se comporte igual que el ciclo real.
4. **Ningún argumento puede contener espacios**, y por eso existen `ciclo-noche.sh` y
   `ciclo-ensayo.sh`. `Start-Process -ArgumentList` no entrecomilla: el elemento
   `"bash '<ruta>' noche"` llegaba partido y `bash -lc bash <ruta> noche` ejecutaba `bash` a secas,
   que salía en el acto sin trabajo y sin error. El modo va codificado en la **ruta** del envoltorio,
   no como argumento. Es el defecto 1 del kit reaparecido una capa más abajo.
5. **El `.out`/`.err` del ciclo anterior se borra ANTES de lanzar.** Si no, un ciclo que no produce
   nada reporta la salida del ciclo previo como suya. Costó un falso `ENSAYO-OK` en la única
   comprobación que existe para detectar que la instrucción no llega.

### 10.3 Verificación ya ejecutada (no leída)

| Comprobación | Resultado |
|---|---|
| El corredor parsea en PowerShell 5.1 | **LIMPIO** |
| El corredor es ASCII puro | **OK**, 20,110 bytes |
| `cpu_arbol.py` suma la CPU de un **nieto** | **8.00 s** en árbol ocupado, **0.00 s** en dormido |
| La misma prueba con el recorrido de descendientes mutilado | **ROJA** — la prueba sí detecta el defecto |
| Interop PowerShell → `wsl.exe` → `git` / `python3` | Responde correctamente |
| `ciclo-wsl.sh` sin CLI instalado | Aborta con código 4 y mensaje reconocible, no en silencio |
| CLI de Claude Code en WSL | `2.1.224`, en `~/.local/bin/claude`, visible también por interop |
| **Ensayo de extremo a extremo desde el corredor** | **`ENSAYO-OK`**, 9 caracteres, 4.3 s |
| Marcas `8< / >8` alrededor de la salida del agente | Presentes en el log |
| Limpieza del `finally` | Sin temporales en `~/.ventana/` ni locks en `%LOCALAPPDATA%` |
| Respaldo del corredor fuera del repo | Creado |
| Suite de pruebas del proyecto | **98 pruebas en 6.1 s** |

### 10.4 El falso verde que apareció al verificar, y cómo se cazó

La primera corrida del ensayo reportó `ENSAYO-OK` con 9 caracteres y **cerró en menos de un
segundo**. Un `claude --print` real no es tan rápido: esa era la única señal de que algo iba mal.

Al borrar el estado y repetir, la salida fue **0 caracteres**. El corredor había estado leyendo el
`.out` que dejó un ensayo manual previo. Debajo había dos defectos encadenados —los puntos 4 y 5 de
§10.2— y el segundo enmascaraba al primero.

Es exactamente lo que advierte el kit: *un corredor que se lee correcto y no se corrió nunca es un
corredor que no funciona*, y *una herramienta que nombra una causa falsa es peor que no tener
ninguna*. Tras corregir ambos, el ensayo tarda 4.3 s y el `ENSAYO-OK` es real.

### 10.5 Antes de irse el lunes

1. `python3 automatizacion/preflight.py` y que salga **verde**.
2. Ensayo de extremo a extremo (§4.2 del kit), y que la salida del agente diga `ENSAYO-OK`:
   ```powershell
   powershell -NoProfile -ExecutionPolicy Bypass -File "<ruta>\ventana-nocturna.ps1" -Ensayo
   ```
3. Dejar el checkout en `feature/AS-XXX-timbrado-cfdi-ingreso`, **no** en la rama base.
4. Dejar el árbol limpio (o saber qué queda sin commitear).
5. Dejar los contenedores arriba: `docker compose up -d`.

### 10.6 Lo que solo puede hacer una persona

Registrar la tarea programada y decidir con qué nivel de autorización corre el agente durante
catorce horas. Un agente no puede —ni debe— registrarse una tarea del sistema ni autorizarse
herramientas por adelantado.

Está automatizado en `automatizacion/instalar-tarea.ps1`. **En una terminal de PowerShell normal**
(no hace falta administrador, ver más abajo):

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "\\wsl.localhost\Debian\home\jcedillo\Projects\CRM_AGENTES_CARGA\automatizacion\instalar-tarea.ps1"
```

Tiene que terminar con `OK: primer disparo el 10/08/2026 17:05:00`. Si dice que `NextRunTime` está
vacío, la tarea **no va a disparar** y hay que corregir la fecha.

Tres decisiones que se apartan del bloque genérico del kit, y por qué:

1. **El corredor se copia a `%LOCALAPPDATA%\ventana-nocturna\` y la tarea invoca esa copia**, no el
   archivo del repo. Una ruta `\\wsl.localhost\` solo responde con el servicio de WSL arriba: si la
   máquina arranca y la tarea dispara antes, el ciclo se pierde sin más rastro que un código de
   error. Y como el agente cambia de rama durante la noche, invocar el archivo dentro del árbol de
   git haría correr una versión vieja del corredor (defecto 4 del kit). Fuera del repo, el problema
   desaparece de raíz.
   → **Si editas `ventana-nocturna.ps1`, vuelve a correr `instalar-tarea.ps1`.** El preflight
   compara ambas copias y avisa si se separaron.
2. **Disparo `-Once` con fecha explícita** (`2026-08-10 17:05`), no `-Daily`. Con `-Daily`,
   registrarla un viernes por la tarde la dispararía *ese mismo día*.
3. **Sin `-RunLevel Highest`.** Elevar no aporta nada —lanzar `wsl.exe` no necesita privilegios— y
   sí puede estorbar: una tarea elevada corre en otro contexto, donde el acceso al perfil de WSL y a
   las credenciales del agente en `~/.claude` puede no resolverse igual. Efecto secundario útil: no
   hace falta abrir PowerShell como administrador.

⚠️ **La tarea corre como tu usuario y solo con la sesión iniciada.** El agente necesita tu perfil
para llegar a WSL, a docker y a sus credenciales; como SYSTEM no funcionaría. **Deja la sesión de
Windows iniciada el lunes** — bloquear la pantalla está bien; cerrar sesión o apagar, no.

- Para quitarla: `Unregister-ScheduledTask -TaskName 'VentanaNocturna' -Confirm:$false`
- Para probarla sin esperar: `Start-ScheduledTask -TaskName 'VentanaNocturna'` y mirar el log.
  Ojo: fuera de la ventana el ciclo registrará `fuera de la ventana; no se lanza nada`, que es la
  respuesta correcta. Para probar el mecanismo completo, usa el ensayo de §10.5.

### 10.7 Las reglas que cambian de día a noche

| De día, supervisado | De noche, autónomo |
|---|---|
| Una duda se pregunta | **Nada espera a nadie.** Solo abrir o mergear un PR requiere una persona |
| Un bloqueo se plantea | Se anota como BLOQUEADO con su razón en una línea y **se pasa al siguiente** |
| Un servicio caído se reporta | **Se levanta.** Es la primera tarea, no un bloqueo |
| El push pide confirmación | **Esta noche no hay push.** El trabajo se queda en commits locales |
| La prueba manual la hace una persona | Se cubre con pruebas automáticas y se deja escrito el guion para la mañana |
| Una revisión con varios subagentes vale la pena | **Cuidado con la cuota:** es la misma bolsa para los ~28 ciclos |

Y dos que no se negocian:

- **Un cambio sin regresión en verde no se sube. Nunca.** Levantar el ambiente sirve para poder
  *verificar*, no para saltarse la verificación.
- **Ninguna hora se escribe de memoria.** El agente no tiene reloj: si la infiere del trabajo hecho,
  siempre sale mal. Se lee de `date` o de la fecha de un commit, o no se escribe.
