diff --git a/.gitignore b/.gitignore index b43e2e3..a53303a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ Thumbs.db .vscode/ .idea/ +# Secretos — NUNCA versionar (token API de BIND, producción) +bind_token_api.txt +*.env + # Python (skill proposal-pdf) __pycache__/ *.pyc diff --git a/bind-api-sandbox/.env.example b/bind-api-sandbox/.env.example index a13e8bb..aac0b74 100644 --- a/bind-api-sandbox/.env.example +++ b/bind-api-sandbox/.env.example @@ -1,16 +1,23 @@ # --- Configuración del sandbox de BIND --- # # Por default el cliente apunta al mock local (puerto 4010). -# Cuando Pedro entregue el API key real, copia este archivo a .env y -# cambia BIND_BASE_URL a https://api.bind.com.mx + llena las credenciales. +# Para apuntar al API REAL: BIND_BASE_URL=https://api.bind.com.mx y pon el +# token en BIND_API_TOKEN (así lo nombra el correo de entrega de Pedro, 6-jul). # # Recuerda: BIND solo tiene PRODUCCIÓN. Cualquier llamada con base URL real # afecta datos reales de Balam. Mantén MODE=read-only mientras no haya -# autorización explícita para escribir. +# autorización explícita para escribir. El token NUNCA se versiona ni se +# imprime (este archivo .env está en .gitignore). +# +# Validado 6-jul-2026 (ver VALIDACION-API.md): la auth real es SOLO +# Authorization: Bearer — la subscription key no se requiere. BIND_BASE_URL=http://localhost:4010 +# Token del API real (entregado por Pedro; usuario BIND de Arturo Rosas): +BIND_API_TOKEN= +# Credencial para el mock local (cualquier string no vacío funciona): BIND_API_KEY=mock-bearer-token -BIND_SUBSCRIPTION_KEY=mock-subscription-key +BIND_SUBSCRIPTION_KEY= # read-only | dry-run | write # - read-only: solo GET. Bloquea POST/PUT/PATCH/DELETE en el cliente. @@ -20,3 +27,6 @@ BIND_MODE=read-only # Puerto del mock server MOCK_PORT=4010 + +# Presupuesto de peticiones del script de validación (src/validate-real-api.ts) +VALIDATION_BUDGET=120 diff --git a/bind-api-sandbox/.gitignore b/bind-api-sandbox/.gitignore index aa0926a..608efae 100644 --- a/bind-api-sandbox/.gitignore +++ b/bind-api-sandbox/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .env *.log +validation-output/ diff --git a/bind-api-sandbox/README.md b/bind-api-sandbox/README.md index 009bb13..df1082b 100644 --- a/bind-api-sandbox/README.md +++ b/bind-api-sandbox/README.md @@ -7,21 +7,31 @@ Está pensado para que tú (Johann), Pedro (Balam) o un futuro dev puedan: 2. Probar el cliente tipado contra un mock que replica los headers, el formato OData y el rate-limit observable de BIND. 3. Cuando Pedro entregue las credenciales reales, **cambiar dos variables de entorno** y apuntar el mismo código a `https://api.bind.com.mx` sin reescribir nada. +> ✅ **ACTUALIZACIÓN 6-jul-2026 — validación contra el API real EJECUTADA.** +> Con el token entregado por Pedro (usuario de Arturo Rosas) se corrió la validación +> técnica de solo lectura (117 peticiones GET). Resultados completos, tabla de +> cobertura de endpoints, inventario de campos y veredicto del saldo en +> **[VALIDACION-API.md](VALIDACION-API.md)**. Los hallazgos clave ya están +> reconciliados en el cliente (`types.real.ts`, `BindClient` con `idStyle`). +> El mock conserva el schema aproximado previo — sigue siendo útil para CI/demo, +> pero el contrato real es el de `types.real.ts`. + --- -## TL;DR del API de BIND (lo que descubrí del discovery) +## TL;DR del API de BIND (reconciliado con la validación del 6-jul) | Tema | Hallazgo | |---|---| -| **Base URL** | `https://api.bind.com.mx` | -| **Estilo** | REST con sintaxis **OData v3** (filtros `$filter`, `$top`, `$skip`, `$orderby`, `$count`, IDs como `guid'...'`) | -| **Auth** | Dos headers: `Authorization: Bearer ` + `Ocp-Apim-Subscription-Key: ` (este último cuando aplica) | -| **Origen del API key** | Cuenta BIND → Perfil de usuario → pestaña *Integraciones* | -| **Rate limit** | **20,000 peticiones / día** (confirmado por Noe, 25-may-2026) | -| **Sandbox oficial** | **No existe.** BIND recomienda Postman contra producción → razón #1 de este sandbox | -| **Portal dev** | [developers.bind.com.mx](https://developers.bind.com.mx) (login requerido para ver schemas detallados) | -| **PAC para CFDI** | Integrado dentro del propio BIND — la plataforma de Balam **no toca el SAT**, solo orquesta | -| **Recursos confirmados** | `Activities`, `Customers`, `Products` (y según el discovery doc: `Invoices`, `Payments`, `Quotes` muy probables) | +| **Base URL** | `https://api.bind.com.mx` ✅ confirmado | +| **Estilo** | REST con filtros OData v3 (`$filter`, `$top`≤100, `$skip`, `$orderby`). ⚠️ `$select` NO funciona (500); conteo total inaccesible; **GET por ID es REST `/{id}`, no `(guid'...')`** | +| **Auth** | ✅ **Solo** `Authorization: Bearer ` — la `Ocp-Apim-Subscription-Key` no se requiere. ⚠️ Token inválido → **500** (no 401) | +| **Origen del API key** | Cuenta BIND → Perfil de usuario → pestaña *Integraciones* (el de Balam salió del usuario de **Arturo Rosas**) | +| **Rate limit** | **20,000 peticiones / día** — no observable en headers; llevar contador local | +| **Sandbox oficial** | **No existe.** Este mock local sigue siendo la única red de pruebas sin efecto fiscal | +| **PAC para CFDI** | Integrado en BIND; el PDF del CFDI se descarga vía `GET /api/Invoices/{id}/pdf` ✅ | +| **Recursos confirmados (200)** | `Invoices`, **`Clients`** (no Customers), **`Quotes`**, `Products`, `Currencies`, `Warehouses`, `Locations`, `Activities`, `PriceLists`, `Orders`, `Providers`, `Banks`, `BankAccounts`, `Users` | +| **Recursos que NO existen** | **`Payments`** (≈20 nombres probados → 404 — el acumulado pagado viene DENTRO de cada factura), `Customers`, `Series`, `CreditNotes`, `Companies`… | +| **Saldo por factura** | ✅ **Plan A operativo:** `Total − Payments − CreditNotes` en la misma fila de `/api/Invoices` (verificado aritméticamente) | ### Por qué un sandbox propio y no Postman @@ -75,18 +85,26 @@ Stats del cliente { requestsToday: 6, quota: 20000, remaining: 19994, mode: 'read-only' } ``` -### Apuntar a producción (cuando llegue el API key) +### Apuntar a producción (token real) -Solo cambiar variables de entorno — el código no se modifica: +Solo cambiar variables de entorno — el código no se modifica (el cliente detecta +el estilo de ID; contra el API real usa `/{id}`): ```powershell $env:BIND_BASE_URL = "https://api.bind.com.mx" -$env:BIND_API_KEY = "" -$env:BIND_SUBSCRIPTION_KEY = "" +$env:BIND_API_TOKEN = "" $env:BIND_MODE = "read-only" # mantenlo así hasta tener autorización para escribir npm run demo ``` +> ⚠️ La demo fue escrita contra el schema del mock (`types.ts` aproximados); +> contra producción algunos escenarios no aplican (p. ej. `Customers` → 404 real). +> Para explorar el API real usa el script de validación: +> +> ```powershell +> npm run validate:real # solo GET, presupuesto de peticiones, reporte sanitizado +> ``` + --- ## Mapeo a la arquitectura de Balam @@ -151,9 +169,14 @@ Estos son los puntos del `03_Anexo_Tecnico_Integraciones_Discovery_Balam.docx` y ## Limitaciones honestas de este sandbox -- **El schema de `Invoice`, `Customer`, etc. es una aproximación** — está modelado a partir de la doc pública y del flujo que necesita Balam, no del SDK oficial. Cuando salga el primer `GET` real contra producción, hay que reconciliar nombres de campos (especialmente capitalización y campos opcionales). -- **El mock acepta cualquier Bearer**, solo valida que exista. No es un servidor de auth real, es un placeholder. -- **El parser de OData del mock solo cubre lo que el cliente genera** (`eq, ne, gt, lt, ge, le, and, or`, paréntesis). No soporta `contains`, `startswith`, funciones, lambdas. Suficiente para el MVP. -- **No reproduce la lógica de `$expand`** — si BIND lo soporta para traer `Lines` o `Customer` embebidos, hay que extender. +- **El schema del MOCK (`types.ts`) sigue siendo la aproximación previa** — el contrato + confirmado contra producción vive en **`src/client/types.real.ts`** y en + [VALIDACION-API.md](VALIDACION-API.md). Pendiente (opcional): regenerar el seed del + mock con los shapes reales para que la demo ejercite el contrato confirmado. +- **El mock acepta cualquier Bearer**, solo valida que exista. No es un servidor de auth real. +- **El mock implementa el GET por ID estilo OData `(guid'...')`** — el API real usa `/{id}`; + el cliente lo resuelve con `idStyle`, el mock quedó intacto. +- **El parser de OData del mock solo cubre lo que el cliente genera** (`eq, ne, gt, lt, ge, le, and, or`, paréntesis). +- **No reproduce la lógica de `$expand`** — y el API real ni siquiera soporta `$select`, así que el payload completo es la norma. Cuando alguno de estos límites se vuelva una piedra en el zapato, se extiende. Hoy es deliberadamente mínimo. diff --git a/bind-api-sandbox/VALIDACION-API.md b/bind-api-sandbox/VALIDACION-API.md new file mode 100644 index 0000000..a8a1e9c --- /dev/null +++ b/bind-api-sandbox/VALIDACION-API.md @@ -0,0 +1,357 @@ +# Validación técnica de la API de BIND ERP — cuenta real (Balam) + +> **Actividad:** "Validación técnica de la API de BIND" · Etapa 0 (Discovery) +> **Fecha de ejecución:** 6-jul-2026 · **Base URL:** `https://api.bind.com.mx` +> **Token:** entregado por Pedro el 6-jul (correo, [REGISTRO #29]) — generado con el usuario de **Arturo Rosas**, conforme al acuerdo del kickoff ([REGISTRO #22]). Vive en `bind-api-sandbox/.env` (`BIND_API_TOKEN`), fuera de git. +> **Método:** script [`src/validate-real-api.ts`](src/validate-real-api.ts) — **exclusivamente GET** (no existe código de escritura en el script), **117 de 120 peticiones** presupuestadas (límite real: 20K/día). Reporte crudo sanitizado en `validation-output/report.json` (gitignoreado). +> **Política de datos:** este documento contiene **solo estructura** — nombres de campos, tipos, formatos, conteos y códigos de estatus. Ningún valor real de Balam (nombres, RFCs, montos, folios, correos). Los ejemplos son inventados con el mismo shape. + +--- + +## Resumen ejecutivo + +| Pregunta | Veredicto | +|---|---| +| ¿El token autentica? | ✅ Sí — `Authorization: Bearer` como único header. Sin subscription key. | +| ¿Alcance del token? | ✅ Cubre todos los recursos existentes que probamos (ningún 401/403 por recurso) — coherente con "cuenta mayor, todos los permisos" del kickoff. | +| ¿Se distingue prefactura de factura timbrada? | ✅ Sí — `UUID eq null` / `IsFiscalInvoice eq false` devuelven filas. | +| ¿PPD/PUE visible? | ✅ Sí, en el **detalle** por factura (`CFDIPaymentTerm`) — no en la lista. | +| **¿Saldo abierto por factura?** | ✅ **Plan A operativo:** `Total − Payments − CreditNotes`, todos campos de la **misma fila** de `/api/Invoices`. Verificado aritméticamente. | +| ¿Pagos individuales listables? | ❌ **No** — no existe recurso de pagos consultable (19 nombres probados → 404). El acumulado sí (`Invoices.Payments`). | +| ¿Cotizaciones consultables? | ✅ Sí (`/api/Quotes` + detalle) — pero **sin relación visible** cotización→factura. | +| ¿OData? | ⚠️ Parcial — `$filter/$top/$skip/$orderby` sí (sintaxis v3); `$select` y conteo total **no**. | +| ¿Multi-empresa? | ✅ Acotado a **una** empresa (la del usuario del token). Sin recurso `Companies` ni campo de empresa. | +| ¿PDF del CFDI por API? | ✅ `GET /api/Invoices/{id}/pdf` → `application/pdf`. | + +--- + +## 1 · Autenticación + +Esquema confirmado: **un solo header**. + +``` +GET https://api.bind.com.mx/api/{Recurso} +Authorization: Bearer +Accept: application/json +``` + +- El `Ocp-Apim-Subscription-Key` que el sandbox contemplaba como posible **no es necesario** — no se envió en ninguna petición y todo funcionó. + +| Escenario | Estatus | Cuerpo (estructura) | +|---|---|---| +| Token válido | `200` | Colección OData `{ value: [...] }` | +| Sin header Authorization | `401` | `{ "Message": "Authorization has been denied for this request." }` | +| Token corrupto/inválido | ⚠️ **`500`** | `{ "message": "API Key es inválida. \| Your API Key is invalid.", "code": "0" }` | + +> **Hallazgo importante:** un token inválido responde **500, no 401/403**. El monitoreo de la plataforma **no puede fiarse del código HTTP** para distinguir "token revocado" de "error del servidor de BIND" — hay que inspeccionar el mensaje del body (`API Key es inválida`). + +--- + +## 2 · Cobertura de endpoints (inventario) + +Todos con `GET {recurso}?$top=1`. **No apareció ningún 401/403 por recurso**: con este token todo existe (200) o no existe (404) — no hay recursos "prohibidos" visibles. + +### Responden 200 + +| Recurso | Método probado | Estatus | Notas | +|---|---|---|---| +| `Invoices` | GET lista / GET `/{id}` / GET `/{id}/pdf` / GET `/{id}/xml` | 200 | Colección OData. Detalle trae más campos que la lista (50 vs 32). | +| `Clients` | GET lista / GET `/{id}` | 200 | **Así se llaman los clientes** (no `Customers`). Detalle 28 campos vs 10 de lista. | +| `Quotes` | GET lista / GET `/{id}` | 200 | Cotizaciones. Detalle 45 campos con partidas `Items[]`. | +| `Products` | GET lista | 200 | 30 campos. Incluye `ChargeVAT`, `CurrencyCode`, unidad. | +| `Currencies` | GET lista | 200 | Catálogo: `ID`, `Name`, `Code` (3 letras), `ExchangeRate`. | +| `Warehouses` | GET lista | 200 | `ID`, `Name`, `LocationID`, `AvailableInOtherLoc`. 1 fila (Matriz). | +| `Locations` | GET lista | 200 | Sucursales/domicilios: `Name`, `Street`, `ZipCode`, `City`, `State`… 1 fila. | +| `Activities` | GET lista | 200 | Devuelve colección **vacía** en esta cuenta (0 filas). | +| `PriceLists` | GET lista | 200 | Listas de precios. | +| `Orders` | GET lista | 200 | Pedidos (no se profundizó — fuera del flujo MVP). | +| `Providers` | GET lista | 200 | Proveedores (fuera del flujo MVP). | +| `Banks` | GET lista | 200 | Catálogo bancario (relevante futuro: conciliación). | +| `BankAccounts` | GET lista | 200 | Cuentas bancarias de la empresa (ídem). | +| `Users` | GET lista | 200 | Usuarios BIND. No se analizó su shape (contiene datos personales, no prioritario). | + +### Responden 404 (no existen con ese nombre) + +| Grupo | Nombres probados → 404 | +|---|---| +| Clientes (alias) | `Customers` | +| **Pagos** | `Payments`, `Payment`, `ClientPayments`, `CustomerPayments`, `Incomes`, `Income`, `Deposits`, `Collections`, `PaymentComplements`, `Complements`, `CashReceipts`, `AccountsReceivable`, `Receivables`, `Cobros`, `Pagos`, `InvoicePayments`, `PaymentsReceived` | +| Pagos (sub-recurso) | `Invoices/{id}/Payments`, `Invoices/{id}/payments`, `Invoices/{id}/CreditNotes` | +| Cotizaciones (alias) | `Quotations`, `Cotizaciones` | +| Series | `Series`, `InvoiceSeries`, `Folios`, `DocumentSeries` (la serie es **campo** de la factura, no recurso) | +| Sucursales (alias) | `Branches`, `Sucursales` (lo real es `Locations`) | +| Otros | `CreditNotes`, `Taxes`, `Prices`, `SalesOrders`, `PurchaseOrders`, `Suppliers`, `Sellers`, `Employees`, `Companies`, `Expenses`, `Inventory`, `CFDI`, `CFDIs` | + +--- + +## 3 · Inventario de campos por recurso + +Solo nombres, tipos y formatos observados (muestras de `$top=5`). `(≈corto/medio/largo)` = longitud aproximada del string; los valores reales nunca se persistieron. + +### 3.1 `Invoices` — lista (32 campos) + +| Campo | Tipo/formato | Nota | +|---|---|---| +| `ID` | guid | Clave para `GET /api/Invoices/{id}`. | +| `Serie` | string corto (a veces vacío) | ⚠️ En el detalle se llama **`Series`** (inconsistencia del API). | +| `Number` | integer | Folio interno. | +| `UUID` | guid | Folio fiscal del CFDI. **`null` en prefacturas.** | +| `Date` | datetime ISO | Fecha del documento. | +| `ExpirationDate` | datetime ISO | **Fecha de vencimiento** (esto alimenta el aging). No existe campo `DueDate`. | +| `ClientID` / `ClientName` | guid / string | Denormalizado en la propia fila. | +| `RFC` | string formato RFC | Del receptor. | +| `Cost`, `Subtotal`, `Discount`, `Total` | decimal | | +| `VAT`, `IEPS`, `ISRRet`, `VATRet` | decimal | Impuestos y retenciones. | +| `VATRate`, `VATRetRate` | decimal | Tasas (p. ej. `0.16`). | +| **`Payments`** | decimal | **Acumulado pagado de la factura** (ver §4). | +| **`CreditNotes`** | decimal | Acumulado de notas de crédito aplicadas. | +| `CurrencyID` | guid | FK a `Currencies` (la lista no trae el código — el detalle sí). | +| `ExchangeRate` | decimal | TC fijado al emitir. | +| `LocationID`, `WarehouseID`, `PriceListID` | guid | | +| `CFDIUse` | integer | ⚠️ Código **interno** (se observaron `3`, `23`), no la clave SAT (`G03`…). Falta tabla de mapeo. | +| `Comments` | string | Aquí ponen hoy el nº de ticket Jira (Discovery #27). | +| `PurchaseOrder` | string | Orden de compra. | +| `IsFiscalInvoice` | boolean | **`false` = prefactura** (sin timbrar). | +| `ShowIEPS` | boolean | | +| `Status` | integer | Ver semántica abajo. | + +**Semántica de `Status` (mapeada contra el propio API, lista→detalle):** + +| Código | Etiqueta (campo `Status` del detalle) | +|---|---| +| `0` | Activa | +| `1` | Pagada | +| `2` | Cancelada | + +Se probaron códigos 3–5: sin filas (o no existen o no hay ejemplares). ⚠️ `Status` **no distingue** prefactura de timbrada — el discriminador fiable es `UUID eq null` / `IsFiscalInvoice eq false` (ambos filtros devuelven filas: **las prefacturas sí son visibles por API**). + +### 3.2 `Invoices/{id}` — detalle (50 campos; los adicionales) + +| Campo | Tipo/formato | Nota | +|---|---|---| +| `Series` | string | La lista lo llama `Serie`. | +| `Status` / `StatusCode` | string / integer | Etiqueta + código (p. ej. "Pagada" / `1`). | +| **`PaymentTerms`** | integer | **Días de crédito** de la factura. Solo en detalle. | +| **`CFDIPaymentTerm`** | string | ⚠️ **El método de pago SAT (PPD/PUE)** — se observó el literal "PAGO EN UNA SOLA EXHIBICIÓN" (=PUE). Puede venir vacío. Solo en detalle. | +| **`CFDIPaymentMethod`** | string | ⚠️ **La forma de pago SAT** (se observaron "Transferencia Electrónica de Fondos", "Por Definir"). Nomenclatura **invertida** respecto al SAT — ver hallazgos. | +| `CFDIAccountNumber` | string | Nº de cuenta (últimos dígitos), puede venir vacío. | +| `CurrencyName` | string 3 letras | Código de moneda (`MXN`/`USD`) — en el detalle es el código, no el nombre. | +| `ClientPhoneNumber`, `ClientContact` | string \| null | | +| `CreatedByID` / `CreatedByName` | guid / string | Quién creó el documento (auditoría). | +| `CreationDate` / `ApplicationDate` | datetime ISO | | +| `PriceListName`, `LocationName`, `WarehouseName` | string | Denormalizados. | +| `FiscalID` | guid | | +| `Address` | string largo | Dirección fiscal del receptor. | +| `Products` | array | Partidas de productos (vacío en la muestra — Balam factura servicios). | +| `Services` | array | **Partidas de servicios.** | +| `Services[].ID`, `Services[].ServiceID` | guid | | +| `Services[].IndexNumber` | integer | Orden de la partida. | +| `Services[].Name`, `Services[].Code` | string | Concepto (p. ej. el 029 "consultoría y servicios" del Discovery). | +| `Services[].Qty`, `Services[].Price` | decimal | | +| `Services[].VATRate` | decimal | **Tasa de IVA por partida** — habilita la validación 16 % / 0 %. | +| `Services[].Discount` | decimal | | + +> El detalle **no** trae `CFDIUse` (solo la lista) ni un campo de saldo precalculado. + +### 3.3 `Quotes` — lista (11 campos) y detalle (45) + +**Lista:** `ID` (guid), `Number` (string), `CreationDate` (datetime), `ClientName`, `Locations` (string), `Comments`, `TotalOriginalCurrency` (decimal), `Currency` (nombre, p. ej. "Peso mexicano"), `Total` (decimal), `Status` (integer), `StatusText` (string). + +**Semántica de `Quotes.Status`** (mapeada vía `$filter` + `StatusText` de la misma fila): + +| Código | `StatusText` | +|---|---| +| `0` | Activa | +| `1` | Cancelada | +| `2` | Surtida | + +**Detalle `Quotes/{id}` agrega:** `QuoteNumber`, `ClientID`/`ClientContact`/`ClientPhone`, `LocationName/ID`, `PriceListName/ID`, `EmployeeName/ID` (comercial que cotizó), `CurrencyCode` (3 letras), `ExchangeRate`, `Subtotal`, `Discount`, `IEPS`, `VAT`/`VATRate`, `ISR`/`ISRRate`, `VatRet`, `Total`, `BaseCurrency` (bool), `OriginalCurrencySubtotal`, `OriginalCurrencyDiscountAmount`, `IsPercentage` (bool), **`ContactEmails`**, `ExternalIDType` (int), `Comments`, y partidas **`Items[]`**: `ID`, `Code`, `ProductID`, `ProductName`, `Unit`, `Qty`, `Price`, `Amount`, `IEPS`, `VAT`, `IndexNumber`. + +> ⚠️ **No hay campo que ligue la cotización con la factura generada** (ni `InvoiceID` en Quote, ni `QuoteID` en Invoice). "Surtida" dice que se convirtió, pero no *a qué* factura. Ver implicaciones (§9.2). + +### 3.4 `Clients` — lista (10 campos) y detalle (28) + +**Lista:** `ID` (guid), `Number` (int), `ClientName`, `LegalName`, `RFC`, `Email`, `Phone`, `NextContactDate`, `LocationID` (guid), `RegimenFiscal` (string). + +**Detalle `Clients/{id}` agrega:** + +| Campo | Tipo | Nota | +|---|---|---| +| `CommercialName` | string | | +| **`CreditDays`** | integer | **Días de crédito default del cliente** (los 30/45/90 del Discovery). | +| `CreditAmount` | decimal | Límite de crédito. | +| `PaymentMethod` | string | Forma de pago default (se observó "Efectivo"). | +| `PaymentTermType` | string | Puede venir vacío. | +| `Status` | string | "Activo"/… | +| `SalesContact` / `CreditContact` | string | Contactos comercial y de cobranza. | +| **`Loctaion` / `LoctaionID`** | string / guid | ⚠️ **Typo real del API** ("Loctaion", sic) — el cliente tipado debe usar el nombre con typo. | +| `PriceList` / `PriceListID` | string / guid | | +| `Email`, `Telephones` | string \| null | Correos configurados (los que usa el botón "enviar email" de BIND). | +| `AccountNumber`, `DefaultDiscount`, `ClientSource`, `Account` | varios | | +| `City`, `State`, `Addresses[]` | string / array | | +| `RegimenFiscal` | string | Régimen fiscal SAT. | +| `CreationDate` | datetime | | + +> **No se observó** un campo "uso CFDI default por cliente" — el `CFDIUse` vive en la factura. La regla "gastos en general vs sin efectos fiscales" tendrá que derivarse de otra señal (p. ej. RFC extranjero/`XEXX010101000`, país, o configuración en la plataforma). + +### 3.5 Catálogos + +- **`Currencies`:** `ID` (guid), `Name`, `Code` (3 letras), `ExchangeRate` (decimal). 4 filas en la cuenta. +- **`Warehouses`:** `ID`, `Name` ("Matriz"), `LocationID`, `AvailableInOtherLoc` (bool). 1 fila — confirma el "hoy solo matriz" del Discovery. +- **`Locations`:** `ID`, `Name`, `Street`, `ExtNumber`, `IntNumber`, `ZipCode`, `Colonia`, `City`, `State`. 1 fila. +- **`Products`:** 30 campos, incl. `Code`, `Title`, `Cost`, `CostType(+Text)`, `CurrentInventory`, **`ChargeVAT`** (bool), `Unit`, `CurrencyID/Code`, `PricingType(+Text)`, `PurchaseType(+Text)`, `IEPSRate`, `Type(+Text)`, `SKU`, categorías. + +### 3.6 Documentos del CFDI + +| Endpoint | Estatus | Content-Type | Nota | +|---|---|---|---| +| `GET /api/Invoices/{id}/pdf` | 200 | `application/pdf` | **PDF real descargable por API** — insumo directo del módulo de envío. | +| `GET /api/Invoices/{id}/xml` | 200 | `application/json` | Responde 200 pero como JSON — probablemente envuelve el XML o una URL. El contenido se descartó por política de no persistir datos; **shape pendiente** (§10). | + +--- + +## 4 · La pregunta del saldo — veredicto + +**Plan A (operativo). No se necesita Plan B ni Plan C.** + +- No existe un campo literal `Balance`/`Saldo`, **pero** cada fila de `/api/Invoices` trae `Total`, **`Payments`** (acumulado pagado) y **`CreditNotes`** (acumulado de notas de crédito): + +$$\text{SaldoPorFactura} = \text{Total} - \text{Payments} - \text{CreditNotes}$$ + +- **Verificación aritmética (en memoria, sin persistir montos):** en 5/5 facturas con `Status=1` (Pagada), `Payments + CreditNotes ≈ Total` (diferencia < 0.01); en 5/5 con `Status=0` (Activa), el residual es positivo. La fórmula cuadra en ambas poblaciones. +- Es "Plan A" en el sentido operativo del riesgo de la propuesta: **una sola llamada a `/api/Invoices` basta** para calcular saldo y aging de toda la cartera — no hay que correlacionar una colección de pagos (Plan B) ni capturar nada a mano (Plan C). +- Matiz honesto: BIND no expone el número ya restado; la resta la hace la plataforma. El costo es cero (mismos campos, misma fila). + +--- + +## 5 · Payments — el hallazgo duro + +**No existe recurso consultable de pagos individuales.** Se probaron 17 nombres de colección y 3 sub-recursos (§2) — todos 404. + +Lo que **sí** hay: + +| Necesidad del MVP | ¿Cubierta? | Cómo | +|---|---|---| +| Saldo por factura | ✅ | `Total − Payments − CreditNotes` (§4). | +| ¿Factura pagada? | ✅ | `Status = 1` y/o residual ≈ 0. | +| Aging / vencimiento | ✅ | `ExpirationDate` + saldo. | +| **Fecha y monto de cada abono individual** | ❌ | No visible por API con este token. | +| Complementos de pago (REP) de facturas PPD | ❌ | Ningún recurso visible (`PaymentComplements`, `Complements` → 404). | + +**Implicación:** el motor de cobranza puede detectar *que* una factura se pagó (transición de `Status`/residual entre sincronizaciones) y registrar el *timestamp de detección* en la plataforma, pero no la fecha valor del pago según BIND. Preguntar a Pedro/soporte BIND si existe un endpoint de pagos/REP no descubierto (la doc completa está tras login en developers.bind.com.mx) — ver §10. + +--- + +## 6 · OData y paginación + +| Mecanismo | ¿Funciona? | Evidencia | +|---|---|---| +| `$top` | ✅ con tope | `$top=100` → 200; **`$top=101` → 500**. | +| `$skip` | ✅ | `$top=1&$skip=1` devuelve la fila siguiente (verificado por ID). | +| `$orderby` | ✅ | `Date asc` → orden ascendente verificado. | +| `$filter eq` (int) | ✅ | `Status eq 1`, `CFDIUse eq 3` → 200. | +| `$filter eq null` | ✅ | `UUID eq null` → 200 con filas. | +| `$filter ge` + fecha | ✅ **sintaxis v3** | `Date ge datetime'2020-01-01T00:00:00'` → 200. | +| `$select` | ❌ | → **500**. No se pueden proyectar columnas; el payload siempre viene completo. | +| `$inlinecount=allpages` (v3) | ❌ | → 500. | +| `$count=true` (v4) | ⚠️ | → 200 pero **ignorado**: no devuelve conteo. | +| `odata.nextLink` | ❌ | Nunca apareció. | + +**Paginación:** no hay `nextLink` ni conteo total ⇒ **paginación manual** con `$top=100&$skip=N` hasta recibir página corta. ⚠️ `GET` sin `$top` devuelve la colección completa en una respuesta (se observó con una colección de 41 filas) — con colecciones grandes es un riesgo de payload; **siempre** paginar. Sondeo por `$skip` (sin descargar): la colección histórica de `Invoices` supera las 1,000 filas. + +**Rate limit:** no se observó **ningún header** de cuota (`X-RateLimit-*`, `Retry-After` en 200s) — el límite de 20K/día no es observable por request; hay que llevarlo con contador local (como ya hace `BindClient`). + +**Estabilidad:** ~3 respuestas `500` transitorias en 117 peticiones (resueltas al primer retry). El retry con backoff **no es opcional** en producción. Nota: BIND usa 500 también para errores de sintaxis OData y token inválido — distinguir por body/contexto antes de reintentar a ciegas. + +**GET por ID:** estilo **REST** — `GET /api/Invoices/{id}` → 200; el estilo OData `Invoices(guid'...')` → **404**. (El cliente del sandbox asumía el estilo OData; ya se corrigió.) + +--- + +## 7 · Multi-empresa + +- `Companies` → 404; **ningún** recurso expone campo `Company`/`Empresa`. +- `Locations` y `Warehouses` devuelven **1 fila** (Matriz). +- Conclusión: **el token está acotado a la empresa del usuario que lo generó** (Arturo → Balam). La distinción multi-empresa del portal Jira (Balam/Regiotour/Elmstone, Discovery #27) **no viaja a BIND por este token**: para facturar otras empresas se necesitaría una cuenta BIND distinta con su propio token. Anotado para el roadmap — coherente con dejar multi-empresa fuera del MVP. + +--- + +## 8 · Hallazgos inesperados + +1. **Token inválido → 500** (no 401/403), con mensaje `"API Key es inválida"` en el body. El 401 solo aparece cuando *falta* el header. +2. **`$select` no funciona** (500): no se puede reducir payload por columnas. +3. **Conteo total inaccesible**: `$inlinecount` truena (500) y `$count=true` se ignora — el total solo se conoce paginando hasta el final. +4. **No hay recurso de pagos** (17 nombres → 404) — el acumulado vive dentro de la factura (§5). +5. **Nomenclatura CFDI invertida respecto al SAT:** `CFDIPaymentTerm` = *Método de pago* SAT (PPD/PUE); `CFDIPaymentMethod` = *Forma de pago* SAT (transferencia, efectivo…). Cablearlo al revés rompería la validación de oro PPD/PUE. +6. **`CFDIUse` es un código interno** (enteros `3`, `23`), no la clave SAT (`G03`, `S01`…). Se necesita la tabla de mapeo (pedir a Pedro o doc tras login). +7. **Typo real en el API:** el detalle de `Clients` trae `Loctaion`/`LoctaionID` (sic). +8. **Inconsistencias lista vs detalle:** `Serie` (lista) vs `Series` (detalle); `Status` int (lista) vs `Status` string + `StatusCode` int (detalle); `CurrencyID` (lista) vs `CurrencyName` con el código (detalle); PPD/PUE y días de crédito **solo** en el detalle. +9. **Prefacturas visibles** en la misma colección `Invoices` (`UUID` null / `IsFiscalInvoice` false) — no hay recurso separado. +10. **`Activities` existe pero está vacío** en esta cuenta (0 filas) — el recurso que la doc pública usa de ejemplo no tiene datos aquí. +11. **500 transitorios** ocasionales que se resuelven con retry inmediato. +12. **Higiene de secretos:** el `.txt` del token estaba en la raíz del repo sin gitignorear (no trackeado aún) — se agregó `bind_token_api.txt` y `*.env` al `.gitignore` raíz. Recomendación vigente: moverlo a un gestor de secretos y borrarlo del correo/disco. + +--- + +## 9 · Implicaciones para el MVP + +Cruce contra el flujo objetivo del Discovery (#27): **Jira → cotización BIND → prefactura → validación humana → CFDI → envío**. + +### 9.1 Lo que la API ya sostiene (solo lectura, hoy) + +| Paso del flujo | Soporte confirmado | +|---|---| +| **Cotización** | `Quotes` legible con partidas, comercial (`EmployeeName`), moneda/TC y estatus (Activa/Cancelada/Surtida). La plataforma puede detectar cotizaciones nuevas y validar el prerequisito "cotización obligatoria" de Ara. | +| **Prefactura** | Listable vía `UUID eq null` / `IsFiscalInvoice eq false` → el dashboard "prefacturas pendientes de validación" es viable 100 % lectura. | +| **CFDI** | `UUID`, `Series`+`Number`, RFC, moneda, `ExchangeRate`, impuestos por partida (`Services[].VATRate`), uso CFDI (código), PPD/PUE (`CFDIPaymentTerm` en detalle), creador y fechas. | +| **Validaciones de oro** | • **PPD/PUE:** auditable por factura (detalle). La plataforma puede alertar "PUE detectado — ¿fue consciente?" apenas aparezca. • **IVA 16 %/0 %:** `VATRate` por partida + moneda + RFC → la regla "extranjero con IVA ≠ 0" (el error que Ara señaló en vivo) es detectable automáticamente. • **Días de crédito:** `Clients.CreditDays` (default) vs `PaymentTerms` (factura) — discrepancias detectables. | +| **Cobranza / aging** | `ExpirationDate` + saldo derivado (§4) + `Status` → aging y alertas internas sin recurso de pagos. | +| **Envío** | PDF real por API (`/{id}/pdf`) + correos del cliente (`Clients.Email`, `ContactEmails`) + las particularidades por cliente (Excel de Ara/Arturo) viven en la plataforma. | + +### 9.2 Restricciones de diseño que impone lo encontrado + +1. **Sync incremental obligatorio.** PPD/PUE y días de crédito viven en el **detalle** ⇒ 1 llamada por factura. Con ~55 facturas/mes es trivial, pero el histórico (>1,000) exige sincronizar por delta (`Date ge` la última corrida) y guardar en Postgres — nunca re-barrer todo el detalle. +2. **Trazabilidad cotización→factura la lleva la plataforma.** BIND no expone el vínculo; al orquestar la conversión (Etapa 2) la plataforma debe registrar el par `QuoteID→InvoiceID` en su propia BD (y/o convención en `Comments`, como hoy hacen con el ticket Jira). +3. **"Fecha de pago" = fecha de detección.** Sin pagos individuales, la plataforma registra cuándo *observó* el cambio a Pagada — suficiente para cobranza operativa; insuficiente para conciliación contable fina (que de todos modos es fase posterior). +4. **Catálogos internos a mapear:** `CFDIUse` (int→clave SAT) y códigos de `Status` no observados (3+). Confirmar con Pedro. +5. **Cliente HTTP:** paginar siempre (`$top=100`), retry en 500 transitorio, no usar `$select`, IDs estilo REST, contador local de cuota (sin headers de rate limit). +6. **Los complementos de pago (REP) no son visibles** — riesgo para el flujo PPD completo; escalar a Pedro (§10). + +### 9.3 Presupuesto de peticiones (viabilidad del sync) + +Escenario conservador: lista de facturas delta (1–2 req) + detalle solo de facturas nuevas/cambiadas (~3/día) + cotizaciones delta (1 req) + clientes delta (1 req) ⇒ **< 10 req por ciclo**. Con polling cada 15 min ≈ **~1,000 req/día**, 5 % del límite de 20K. Holgado. + +--- + +## 10 · Lo que quedó SIN validar y por qué + +| Pendiente | Por qué no se validó | Cómo cerrarlo | +|---|---|---| +| **Escritura** (crear cotización/prefactura, convertir, emitir CFDI, cancelar) | **Prohibido en esta actividad**: cuenta de producción, sin sandbox, efecto fiscal. Regla dura de solo-GET. | Doc detallada tras login (developers.bind.com.mx) con Pedro; luego dry-run + confirmación humana en Etapa 2, empezando por un documento de prueba interno coordinado con Arturo. | +| Si la factura creada desde una cotización hereda alguna referencia a ésta | Requiere ejecutar la conversión (= escritura). | Mismo camino que el punto anterior; o preguntar a Arturo si la UI muestra el vínculo. | +| Shape real del `/{id}/xml` (¿XML embebido? ¿URL?) | El body se descartó por política de no persistir datos reales en esta corrida. | 1 GET dirigido leyendo solo las **claves** del JSON (sin valores), en la próxima sesión técnica. | +| Mapa completo `CFDIUse` interno → clave SAT | No hay catálogo expuesto; solo se observaron códigos `3` y `23`. | Pedir tabla a Pedro o doc tras login. | +| Códigos de `Status` > 2 (¿parciales, vencidas?) | Los filtros 3–5 no devolvieron filas: o no existen o no hay ejemplares en la cuenta. | Doc tras login; observar en operación. | +| **Complementos de pago (REP)** para PPD | Ningún recurso visible con los nombres probados. | **Crítico** — preguntar a Pedro/soporte BIND; el flujo PPD del MVP lo necesita al menos en lectura. | +| Rate limit real (20K/día) y comportamiento al agotarlo | No hay headers de cuota y agotar el límite adrede sería irresponsable en producción. | Aceptar el dato de Noe (20K) y llevar contador local. | +| Shape de `Users`, `Orders`, `Providers`, `Banks`, `BankAccounts`, `PriceLists` | Fuera del flujo del MVP; `Users` además contiene datos personales. | Cuando conciliación (Anexo B) lo requiera. | +| Webhooks / eventos push | No documentados públicamente; no sondeables por GET. | Preguntar a Pedro; mientras, polling incremental. | + +--- + +## Anexo · Reproducir la validación + +```powershell +cd bind-api-sandbox +# .env debe tener BIND_API_TOKEN (nunca se versiona; .gitignore lo cubre) +npm run validate:real # ronda 1: auth + inventario + shapes + OData + byId +npx tsx src/validate-real-api.ts --round2 # pagos, estatus, prefactura, detalles, paginación +npx tsx src/validate-real-api.ts --round3 # tope $top, literales CFDI, pdf/xml +npx tsx src/validate-real-api.ts --round4 # aritmética del saldo + xml +``` + +- Presupuesto acumulado entre rondas (`VALIDATION_BUDGET`, default 120). El script aborta al agotarlo. +- El reporte `validation-output/report.json` está sanitizado (solo estructura) y además gitignoreado por defensa en profundidad. +- El script es GET-only por construcción: no contiene ningún código capaz de emitir escrituras. + +[REGISTRO #22]: ../bitacora/REGISTRO.md +[REGISTRO #29]: ../bitacora/REGISTRO.md diff --git a/bind-api-sandbox/package.json b/bind-api-sandbox/package.json index 6a8e7c2..5eb5643 100644 --- a/bind-api-sandbox/package.json +++ b/bind-api-sandbox/package.json @@ -11,6 +11,7 @@ "mock": "tsx src/mock-server/server.ts", "demo": "tsx src/demo.ts", "demo:prod": "BIND_BASE_URL=https://api.bind.com.mx tsx src/demo.ts", + "validate:real": "tsx src/validate-real-api.ts", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/bind-api-sandbox/src/client/BindClient.ts b/bind-api-sandbox/src/client/BindClient.ts index 206440c..edd4597 100644 --- a/bind-api-sandbox/src/client/BindClient.ts +++ b/bind-api-sandbox/src/client/BindClient.ts @@ -6,9 +6,22 @@ * - dry-run: loguea el request que se haría sin enviarlo (útil para revisar * un payload antes de aprobarlo manualmente). * - Retries con backoff exponencial en 429 y 5xx (no en 4xx fuera de 429). + * El API real emite 500 transitorios ocasionales — confirmado 6-jul-2026. * - Lleva contador local de requests para acercarse al límite de 20K/día - * con visibilidad temprana (cuota real la valida el servidor). + * con visibilidad temprana (la cuota NO es observable en headers — confirmado). * - Sin dependencias externas — usa fetch nativo de Node 20+. + * + * Reconciliado contra el API real (validación 6-jul-2026, ver VALIDACION-API.md): + * - Auth: SOLO `Authorization: Bearer` — el Ocp-Apim-Subscription-Key no se requiere. + * - GET por ID: estilo REST `/api/{Recurso}/{id}` (el estilo OData `(guid'...')` + * responde 404 en producción). El mock local sigue usando `(guid'...')`, por + * eso el estilo es configurable (`idStyle`). + * - Recursos reales: `Clients` (no Customers), `Quotes`, `Currencies`, + * `Warehouses`, `Locations`. NO existe recurso `Payments` — el acumulado + * pagado viene en cada factura (campo `Payments`). + * - `$select` NO funciona (500) y `$top` acepta máximo 100. + * - Token inválido → 500 con body "API Key es inválida" (no 401) — no + * reintentamos 500 cuyo body reporte api key inválida. */ import { buildQueryString, type ODataQuery } from "./odata.js"; @@ -20,14 +33,35 @@ import type { Payment, Product, } from "./types.js"; +import type { + BindCollection, + ClientDetail, + ClientListItem, + CurrencyInfo, + InvoiceDetail, + InvoiceListItem, + LocationInfo, + QuoteDetail, + QuoteListItem, + WarehouseInfo, +} from "./types.real.js"; export type ClientMode = "read-only" | "dry-run" | "write"; +/** + * Estilo del GET por ID: + * - "rest": /api/Invoices/{id} → lo que el API REAL acepta (confirmado 6-jul-2026). + * - "odata": /api/Invoices(guid'{id}') → lo que implementa el mock local. + */ +export type IdStyle = "rest" | "odata"; + export interface BindClientConfig { baseUrl: string; apiKey: string; subscriptionKey?: string; mode?: ClientMode; + /** Default "rest" (API real). Usa "odata" contra el mock local. */ + idStyle?: IdStyle; /** Máximo de reintentos para 429/5xx. */ maxRetries?: number; /** Logger opcional. Default: console. */ @@ -60,6 +94,7 @@ export class BindClient { private readonly apiKey: string; private readonly subscriptionKey?: string; private readonly mode: ClientMode; + private readonly idStyle: IdStyle; private readonly maxRetries: number; private readonly logger: Pick; private readonly fetchImpl: typeof fetch; @@ -72,19 +107,78 @@ export class BindClient { this.apiKey = cfg.apiKey; this.subscriptionKey = cfg.subscriptionKey; this.mode = cfg.mode ?? "read-only"; + this.idStyle = cfg.idStyle ?? "rest"; this.maxRetries = cfg.maxRetries ?? 3; this.logger = cfg.logger ?? console; this.fetchImpl = cfg.fetchImpl ?? globalThis.fetch; } - // --- Recursos del MVP -------------------------------------------------- + private byId(resource: string, id: string): string { + return this.idStyle === "rest" + ? `/api/${resource}/${id}` + : `/api/${resource}(guid'${id}')`; + } + + // --- Recursos REALES confirmados (validación 6-jul-2026) ---------------- + + /** GET /api/Invoices — lista con acumulados Payments/CreditNotes (saldo = Total − ambos). */ + invoiceList(query: ODataQuery = {}): Promise> { + return this.get>(`/api/Invoices${buildQueryString(query)}`); + } + + /** GET /api/Invoices/{id} — única fuente de PPD/PUE (CFDIPaymentTerm) y días de crédito. */ + invoiceDetail(id: string): Promise { + return this.get(this.byId("Invoices", id)); + } + + /** GET /api/Clients — así se llaman los clientes en el API real (no Customers). */ + clients(query: ODataQuery = {}): Promise> { + return this.get>(`/api/Clients${buildQueryString(query)}`); + } + + /** GET /api/Clients/{id} — trae CreditDays, contactos y (sic) Loctaion/LoctaionID. */ + clientDetail(id: string): Promise { + return this.get(this.byId("Clients", id)); + } + + /** GET /api/Quotes — cotizaciones (0=Activa, 1=Cancelada, 2=Surtida). */ + quotes(query: ODataQuery = {}): Promise> { + return this.get>(`/api/Quotes${buildQueryString(query)}`); + } + + /** GET /api/Quotes/{id} — partidas Items[]; SIN referencia a la factura generada. */ + quoteDetail(id: string): Promise { + return this.get(this.byId("Quotes", id)); + } + + currencies(query: ODataQuery = {}): Promise> { + return this.get>(`/api/Currencies${buildQueryString(query)}`); + } + + warehouses(query: ODataQuery = {}): Promise> { + return this.get>(`/api/Warehouses${buildQueryString(query)}`); + } + + locations(query: ODataQuery = {}): Promise> { + return this.get>(`/api/Locations${buildQueryString(query)}`); + } + + /** GET /api/Invoices/{id}/pdf — devuelve el PDF binario del CFDI (insumo del módulo de envío). */ + async invoicePdf(id: string): Promise { + return this.getBinary(`/api/Invoices/${id}/pdf`); + } + + // --- Recursos de la era mock (types.ts aproximados) ---------------------- + // El mock server sirve Customers/Payments con el schema aproximado previo a la + // validación. Se conservan para la demo local; NO usarlos contra producción + // (Customers → 404 real; Payments → 404 real — no existe el recurso). customers(query: ODataQuery = {}): Promise> { return this.get>(`/api/Customers${buildQueryString(query)}`); } customer(id: string): Promise { - return this.get(`/api/Customers(guid'${id}')`); + return this.get(this.byId("Customers", id)); } invoices(query: ODataQuery = {}): Promise> { @@ -92,7 +186,7 @@ export class BindClient { } invoice(id: string): Promise { - return this.get(`/api/Invoices(guid'${id}')`); + return this.get(this.byId("Invoices", id)); } payments(query: ODataQuery = {}): Promise> { @@ -133,6 +227,18 @@ export class BindClient { return this.request("GET", path); } + /** GET binario (PDF del CFDI). Cuenta contra la cuota como cualquier request. */ + private async getBinary(path: string): Promise { + this.rolloverIfNewDay(); + this.requestCount++; + const headers: Record = { Authorization: `Bearer ${this.apiKey}` }; + if (this.subscriptionKey) headers["Ocp-Apim-Subscription-Key"] = this.subscriptionKey; + const url = `${this.baseUrl}${path}`; + const res = await this.fetchImpl(url, { method: "GET", headers }); + if (!res.ok) throw new BindApiError(res.status, url, await safeJson(res)); + return res.arrayBuffer(); + } + private async request(method: string, path: string, body?: unknown): Promise { if (MUTATING.has(method) && this.mode === "read-only") { throw new BindReadOnlyViolation(method, path); diff --git a/bind-api-sandbox/src/client/types.real.ts b/bind-api-sandbox/src/client/types.real.ts new file mode 100644 index 0000000..5f851fb --- /dev/null +++ b/bind-api-sandbox/src/client/types.real.ts @@ -0,0 +1,306 @@ +/** + * Tipos CONFIRMADOS contra el API real de BIND (validación del 6-jul-2026, + * cuenta Balam, solo lectura). Fuente: VALIDACION-API.md + validation-output/report.json. + * + * Conviven con types.ts (la aproximación que consume el mock server): el mock + * queda intacto; el código que apunte a producción debe tipar con ESTOS. + * + * Notas duras del API real: + * - Los clientes son `Clients` (no `Customers`); no existe recurso `Payments`. + * - GET por ID es estilo REST (`/api/Invoices/{id}`), NO OData `(guid'...')`. + * - La lista y el detalle de un mismo recurso difieren en campos y hasta en + * nombres (`Serie` vs `Series`; `Status` int vs string+`StatusCode`). + * - `CFDIPaymentTerm` = Método de pago SAT (PPD/PUE) y `CFDIPaymentMethod` = + * Forma de pago SAT — nomenclatura invertida respecto al SAT. + * - `Loctaion`/`LoctaionID` es un typo real del API en el detalle de Clients. + * - Saldo por factura = Total − Payments − CreditNotes (misma fila de lista). + */ + +export type Guid = string; +export type IsoDateTime = string; // "2026-07-06T00:00:00" (sin zona en lo observado) + +// ─── Invoices ─────────────────────────────────────────────────────────────── + +/** Códigos de Invoices.Status confirmados vía filtros + detalle. */ +export enum InvoiceStatusCode { + Activa = 0, + Pagada = 1, + Cancelada = 2, +} + +/** Fila de GET /api/Invoices (lista, 32 campos). */ +export interface InvoiceListItem { + ID: Guid; + /** ⚠️ En el detalle este campo se llama `Series`. */ + Serie: string; + Number: number; + /** Folio fiscal. `null` ⇒ prefactura (sin timbrar). */ + UUID: Guid | null; + Date: IsoDateTime; + /** Vencimiento — insumo del aging. No existe `DueDate`. */ + ExpirationDate: IsoDateTime; + ClientID: Guid; + ClientName: string; + RFC: string; + Cost: number; + Subtotal: number; + Discount: number; + VAT: number; + IEPS: number; + ISRRet: number; + VATRet: number; + Total: number; + /** Acumulado PAGADO de la factura (no es una colección). */ + Payments: number; + /** Acumulado de notas de crédito aplicadas. */ + CreditNotes: number; + CurrencyID: Guid; + LocationID: Guid; + WarehouseID: Guid; + PriceListID: Guid; + /** Código INTERNO de BIND (se observaron 3, 23) — no es la clave SAT (G03…). */ + CFDIUse: number; + ExchangeRate: number; + VATRetRate: number; + Comments: string; + VATRate: number; + PurchaseOrder: string; + /** false ⇒ prefactura. */ + IsFiscalInvoice: boolean; + ShowIEPS: boolean; + Status: InvoiceStatusCode; +} + +/** Partida de servicios del detalle de factura. */ +export interface InvoiceServiceLine { + ID: Guid; + IndexNumber: number; + ServiceID: Guid; + Name: string; + Code: string; + Qty: number; + Price: number; + /** Tasa de IVA por partida — habilita la validación 16 % MXN / 0 % extranjero. */ + VATRate: number; + Discount: number; +} + +/** GET /api/Invoices/{id} (detalle, 50 campos). Campos exclusivos vs lista. */ +export interface InvoiceDetail { + ID: Guid; + UUID: Guid | null; + /** ⚠️ La lista lo llama `Serie`. */ + Series: string; + Number: number; + ClientID: Guid; + ClientName: string; + /** Días de crédito de la factura — solo en detalle. */ + PaymentTerms: number; + /** Etiqueta legible ("Activa" | "Pagada" | "Cancelada"). */ + Status: string; + StatusCode: InvoiceStatusCode; + ClientPhoneNumber: string | null; + ClientContact: string | null; + RFC: string; + CreatedByID: Guid; + CreatedByName: string; + CreationDate: IsoDateTime; + ApplicationDate: IsoDateTime; + PriceListID: Guid; + PriceListName: string; + LocationID: Guid; + LocationName: string; + WarehouseID: Guid; + WarehouseName: string; + /** ⚠️ FORMA de pago SAT (ej. "Transferencia Electrónica de Fondos", "Por Definir"). */ + CFDIPaymentMethod: string; + /** ⚠️ MÉTODO de pago SAT — PPD/PUE (ej. "PAGO EN UNA SOLA EXHIBICIÓN"). Puede venir vacío. */ + CFDIPaymentTerm: string; + CFDIAccountNumber: string; + /** Código de 3 letras ("MXN"/"USD") — a pesar del nombre. */ + CurrencyName: string; + ExchangeRate: number; + PurchaseOrder: string; + FiscalID: Guid; + Address: string; + Comments: string; + Subtotal: number; + Discount: number; + VAT: number; + IEPS: number; + VATRet: number; + ISRRet: number; + Payments: number; + CreditNotes: number; + Products: unknown[]; // partidas de producto (vacío en Balam — facturan servicios) + Services: InvoiceServiceLine[]; +} + +/** Saldo abierto por factura (Plan A operativo — ver VALIDACION-API.md §4). */ +export function invoiceOpenBalance(inv: Pick): number { + return inv.Total - inv.Payments - inv.CreditNotes; +} + +// ─── Clients ──────────────────────────────────────────────────────────────── + +/** Fila de GET /api/Clients (lista, 10 campos). */ +export interface ClientListItem { + ID: Guid; + Number: number; + ClientName: string; + LegalName: string; + RFC: string; + Email: string | null; + Phone: string | null; + NextContactDate: IsoDateTime | null; + LocationID: Guid; + RegimenFiscal: string; +} + +/** GET /api/Clients/{id} (detalle, 28 campos). */ +export interface ClientDetail { + ID: Guid; + RFC: string; + LegalName: string; + CommercialName: string; + /** Días de crédito default del cliente (30/45/90 del Discovery). */ + CreditDays: number; + CreditAmount: number; + PaymentMethod: string; + CreationDate: IsoDateTime; + Status: string; + SalesContact: string; + CreditContact: string; + /** ⚠️ Typo REAL del API (sic). */ + Loctaion: string; + /** ⚠️ Typo REAL del API (sic). */ + LoctaionID: Guid; + Comments: string; + PriceList: string; + PriceListID: Guid; + PaymentTermType: string; + Email: string | null; + Telephones: string | null; + Number: number; + AccountNumber: string | null; + DefaultDiscount: number | null; + ClientSource: string; + Account: string; + City: string; + State: string; + Addresses: unknown[]; + RegimenFiscal: string; +} + +// ─── Quotes ───────────────────────────────────────────────────────────────── + +/** Códigos de Quotes.Status confirmados (StatusText de la misma fila). */ +export enum QuoteStatusCode { + Activa = 0, + Cancelada = 1, + Surtida = 2, +} + +/** Fila de GET /api/Quotes (lista, 11 campos). */ +export interface QuoteListItem { + ID: Guid; + Number: string; + CreationDate: IsoDateTime; + ClientName: string; + Locations: string; + Comments: string | null; + TotalOriginalCurrency: number; + /** Nombre ("Peso mexicano") — el código de 3 letras vive en el detalle. */ + Currency: string; + Status: QuoteStatusCode; + Total: number; + StatusText: string | null; +} + +/** Partida del detalle de cotización. */ +export interface QuoteItem { + ID: Guid; + Code: string; + ProductID: Guid; + ProductName: string; + Unit: string; + Qty: number; + Price: number; + Amount: number; + IEPS: number; + VAT: number; + IndexNumber: number; +} + +/** GET /api/Quotes/{id} (detalle, 45 campos). + * ⚠️ NO trae referencia a la factura generada — la trazabilidad la lleva la plataforma. */ +export interface QuoteDetail { + ID: Guid; + QuoteNumber: string; + ClientName: string; + ClientContact: string; + ClientID: Guid; + ClientPhone: string | null; + LocationName: string; + LocationID: Guid; + PriceListName: string; + PriceListID: Guid; + EmployeeName: string; + EmployeeID: Guid; + CurrencyCode: string; + ExchangeRate: number; + CreationDate: IsoDateTime; + Status: QuoteStatusCode; + StatusText: string | null; + Subtotal: number; + Discount: number; + IEPS: number; + VAT: number; + VATRate: number; + ISR: number; + ISRRate: number; + Total: number; + BaseCurrency: boolean; + Comments: string | null; + OriginalCurrencyDiscountAmount: number; + OriginalCurrencySubtotal: number; + IsPercentage: boolean; + ContactEmails: string | null; + ExternalIDType: number; + VatRet: number; + Items: QuoteItem[]; +} + +// ─── Catálogos ────────────────────────────────────────────────────────────── + +export interface CurrencyInfo { + ID: Guid; + Name: string; + Code: string; // "MXN", "USD"… + ExchangeRate: number; +} + +export interface WarehouseInfo { + ID: Guid; + Name: string; + LocationID: Guid; + AvailableInOtherLoc: boolean; +} + +export interface LocationInfo { + ID: Guid; + Name: string; + Street: string; + ExtNumber: string; + IntNumber: string; + ZipCode: string; + Colonia: string; + City: string; + State: string; +} + +/** Respuesta de colección del API real: { value: [...] } sin count ni nextLink + * (el conteo total NO es accesible; paginar con $top=100 + $skip). */ +export interface BindCollection { + value: T[]; +} diff --git a/bind-api-sandbox/src/demo.ts b/bind-api-sandbox/src/demo.ts index e1a41a1..6b3dff1 100644 --- a/bind-api-sandbox/src/demo.ts +++ b/bind-api-sandbox/src/demo.ts @@ -20,9 +20,14 @@ import { and, eq, ge, guid, lt } from "./client/odata.js"; const cfg = { baseUrl: process.env.BIND_BASE_URL ?? "http://localhost:4010", - apiKey: process.env.BIND_API_KEY ?? "mock-bearer-token", + apiKey: process.env.BIND_API_TOKEN ?? process.env.BIND_API_KEY ?? "mock-bearer-token", subscriptionKey: process.env.BIND_SUBSCRIPTION_KEY, mode: (process.env.BIND_MODE as "read-only" | "dry-run" | "write") ?? "read-only", + // El mock local implementa el GET por ID estilo OData (guid'...'); el API + // real usa estilo REST /{id} (validado 6-jul-2026 — ver VALIDACION-API.md §6). + idStyle: (/localhost|127\.0\.0\.1/.test(process.env.BIND_BASE_URL ?? "localhost") + ? "odata" + : "rest") as "odata" | "rest", }; const client = new BindClient(cfg); diff --git a/bind-api-sandbox/src/validate-real-api.ts b/bind-api-sandbox/src/validate-real-api.ts new file mode 100644 index 0000000..88d4e60 --- /dev/null +++ b/bind-api-sandbox/src/validate-real-api.ts @@ -0,0 +1,857 @@ +/** + * Validación técnica de la API REAL de BIND ERP (producción, cuenta Balam). + * Actividad "Validación técnica de la API de BIND" — Etapa 0. + * + * REGLAS DURAS (no negociables): + * - SOLO LECTURA. Este archivo únicamente construye peticiones GET; no existe + * código capaz de emitir POST/PUT/PATCH/DELETE. + * - El token se lee de `.env` (BIND_API_TOKEN) y JAMÁS se imprime, se loguea + * ni se escribe en el reporte. El serializado final pasa por un scrub que + * además redacta cualquier patrón tipo RFC o email por si el sanitizador + * estructural dejara pasar algo. + * - El reporte solo conserva ESTRUCTURA: nombres de campos, tipos, formatos, + * conteos, códigos de estatus. Nunca valores reales (nombres, RFCs, montos, + * folios, correos). + * - Presupuesto duro de peticiones (default 120 < 150 acordado; el límite de + * BIND es 20K/día). Cada retry cuenta contra el presupuesto. + * + * Salida: validation-output/report.json (sanitizado; el folder está gitignoreado + * por defensa en profundidad) + resumen sanitizado en consola. + * + * Uso: npm run validate:real + */ + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, ".."); +const OUT_DIR = join(ROOT, "validation-output"); + +// ─── Configuración ────────────────────────────────────────────────────────── + +const env = loadEnv(); +const BASE = (env.BIND_BASE_URL ?? process.env.BIND_BASE_URL ?? "https://api.bind.com.mx").replace(/\/+$/, ""); +const TOKEN = env.BIND_API_TOKEN ?? env.BIND_API_KEY ?? process.env.BIND_API_TOKEN ?? ""; +const BUDGET = Number(env.VALIDATION_BUDGET ?? 120); +const PACE_MS = 120; // pausa entre peticiones — gentileza con producción + +function loadEnv(): Record { + const out: Record = {}; + try { + const raw = readFileSync(join(ROOT, ".env"), "utf8"); + for (const line of raw.split(/\r?\n/)) { + const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); + if (m && m[1] && m[2] !== undefined) out[m[1]] = m[2].replace(/^["']|["']$/g, ""); + } + } catch { + /* sin .env — se valida abajo */ + } + return out; +} + +// ─── Sonda HTTP (GET-only por construcción) ───────────────────────────────── + +let used = 0; + +interface Probe { + path: string; + status: number; + ok: boolean; + contentType: string | null; + headers: Record; // allowlist no sensible + bodyShape: "array" | "odata-value" | "object" | "empty" | "non-json"; + rowCount: number | null; + count: number | string | null; // odata.count viene como string en OData v3 + nextLink: boolean; + errorSnippet?: string; + /** SOLO en memoria — nunca va al reporte. */ + rows: unknown[] | null; +} + +const HEADER_KEEP = /rate|limit|quota|remain|retry-after|dataserviceversion|odata-version|content-type|www-authenticate|apim/i; + +async function probeGet(path: string, tokenOverride?: string | null): Promise { + if (used >= BUDGET) throw new Error(`Presupuesto de ${BUDGET} peticiones agotado — abortando por seguridad.`); + const token = tokenOverride === undefined ? TOKEN : tokenOverride; + + for (let attempt = 0; attempt < 2; attempt++) { + used++; + const headers: Record = { Accept: "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + method: "GET", // ÚNICO método en todo el archivo + headers, + signal: AbortSignal.timeout(25_000), + }); + } catch (err) { + await sleep(PACE_MS); + if (attempt === 0) continue; + return { + path, status: 0, ok: false, contentType: null, headers: {}, + bodyShape: "empty", rowCount: null, count: null, nextLink: false, + errorSnippet: `network: ${scrub(String((err as Error).message)).slice(0, 120)}`, rows: null, + }; + } + + const keep: Record = {}; + res.headers.forEach((v, k) => { if (HEADER_KEEP.test(k)) keep[k] = v; }); + + const text = await res.text(); + let json: unknown = null; + try { json = text ? JSON.parse(text) : null; } catch { /* non-json */ } + + if ((res.status === 429 || res.status >= 500) && attempt === 0) { + const ra = Number(res.headers.get("Retry-After") ?? 2); + console.log(` ⏳ ${res.status} en ${path} — retry en ${ra}s`); + await sleep(Math.min(ra, 10) * 1000); + continue; + } + + const { rows, bodyShape, count, nextLink } = extractRows(json, text); + const probe: Probe = { + path, status: res.status, ok: res.ok, + contentType: res.headers.get("content-type"), + headers: keep, bodyShape, + rowCount: rows ? rows.length : null, + count, nextLink, rows, + }; + if (!res.ok) { + const raw = typeof json === "object" && json !== null ? JSON.stringify(json) : text; + probe.errorSnippet = scrub(raw ?? "").slice(0, 300); + } + await sleep(PACE_MS); + return probe; + } + throw new Error("unreachable"); +} + +function extractRows(json: unknown, text: string): Pick { + if (json === null) return { rows: null, bodyShape: text.trim() ? "non-json" : "empty", count: null, nextLink: false }; + if (Array.isArray(json)) return { rows: json, bodyShape: "array", count: null, nextLink: false }; + if (typeof json === "object") { + const o = json as Record; + const value = o["value"]; + if (Array.isArray(value)) { + const count = (o["odata.count"] ?? o["@odata.count"] ?? null) as number | string | null; + const nextLink = Boolean(o["odata.nextLink"] ?? o["@odata.nextLink"]); + return { rows: value, bodyShape: "odata-value", count, nextLink }; + } + return { rows: [json], bodyShape: "object", count: null, nextLink: false }; + } + return { rows: null, bodyShape: "non-json", count: null, nextLink: false }; +} + +// ─── Sanitizador estructural ──────────────────────────────────────────────── + +interface FieldInfo { + name: string; + types: string[]; + formats: string[]; + nullable: boolean; + enumValues?: string[]; +} + +/** Campos cuyo VALOR es un código de proceso (no dato personal) y puede documentarse. */ +const ENUM_FIELD = /status|estatus|type|tipo|method|metodo|use|uso|currency|moneda|cfdi|way/i; +/** Nunca documentar valores de campos que huelan a monto/cantidad aunque matcheen arriba. */ +const ENUM_EXCLUDE = /total|amount|monto|price|cost|balance|exchange|sum|qty|quantity|rate|saldo/i; +/** Literales de catálogo SAT (PPD/PUE, uso CFDI) — seguros y valiosos; se permite más largo. */ +const SAT_CATALOG_FIELD = /cfdi(use|paymentmethod|paymentterm)|paymentmethod|regimenfiscal/i; + +function formatHint(v: unknown): string { + if (v === null || v === undefined) return "null"; + if (typeof v === "boolean") return "boolean"; + if (typeof v === "number") return Number.isInteger(v) ? "integer" : "decimal"; + if (Array.isArray(v)) return "array"; + if (typeof v === "object") return "object"; + const s = String(v); + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) return "guid/uuid"; + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) return "datetime-iso"; + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return "date-iso"; + if (/^\/Date\(-?\d+([+-]\d{4})?\)\/$/.test(s)) return "datetime-wcf(/Date(ms)/)"; + if (/^[\w.+-]+@[\w-]+\.[\w.]+$/.test(s)) return "email(REDACTADO)"; + if (/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/.test(s)) return "rfc(REDACTADO)"; + if (/^https?:\/\//.test(s)) return "url"; + if (/^[A-Z]{3}$/.test(s)) return "code-3letras"; + return `string(≈${lenBucket(s.length)})`; +} + +function lenBucket(n: number): string { + if (n === 0) return "vacío"; + if (n <= 10) return "corto"; + if (n <= 40) return "medio"; + return "largo"; +} + +function safeEnumValue(fieldName: string, v: unknown): string | null { + if (ENUM_EXCLUDE.test(fieldName)) return null; + if (!ENUM_FIELD.test(fieldName)) return null; + // Números: solo enteros pequeños (códigos de catálogo), jamás montos/decimales. + if (typeof v === "number") return Number.isInteger(v) && Math.abs(v) < 1000 ? String(v) : null; + const maxLen = SAT_CATALOG_FIELD.test(fieldName) ? 60 : 14; + if (typeof v !== "string" || v.length === 0 || v.length > maxLen) return null; + const h = formatHint(v); + if (/REDACTADO|guid|datetime|date-iso|url/.test(h)) return null; + return v; +} + +/** Analiza filas y devuelve SOLO estructura. Recorre objetos anidados un nivel (Lines[].Campo). */ +function analyzeRows(rows: unknown[], cap = 5): FieldInfo[] { + const acc = new Map; formats: Set; nullable: boolean; enums: Set }>(); + + const visit = (obj: Record, prefix: string) => { + for (const [k, v] of Object.entries(obj)) { + const name = prefix + k; + let rec = acc.get(name); + if (!rec) { rec = { types: new Set(), formats: new Set(), nullable: false, enums: new Set() }; acc.set(name, rec); } + if (v === null || v === undefined) { rec.nullable = true; rec.types.add("null"); continue; } + rec.types.add(Array.isArray(v) ? "array" : typeof v); + rec.formats.add(formatHint(v)); + const ev = safeEnumValue(k, v); + if (ev !== null && rec.enums.size < 10) rec.enums.add(ev); + if (!prefix && Array.isArray(v) && typeof v[0] === "object" && v[0] !== null) { + visit(v[0] as Record, `${k}[].`); + } else if (!prefix && typeof v === "object" && !Array.isArray(v)) { + visit(v as Record, `${k}.`); + } + } + }; + + for (const row of rows.slice(0, cap)) { + if (typeof row === "object" && row !== null && !Array.isArray(row)) { + visit(row as Record, ""); + } + } + + return [...acc.entries()].map(([name, r]) => { + const fi: FieldInfo = { + name, + types: [...r.types], + formats: [...r.formats], + nullable: r.nullable, + }; + if (r.enums.size > 0) fi.enumValues = [...r.enums]; + return fi; + }); +} + +function scrub(s: string): string { + let out = s; + if (TOKEN) out = out.replaceAll(TOKEN, "[TOKEN-REDACTADO]"); + return out + .replace(/[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}/g, "[RFC-REDACTADO]") + .replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "[EMAIL-REDACTADO]"); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +// ─── Reporte ──────────────────────────────────────────────────────────────── + +interface InventoryEntry { + resource: string; + path: string; + status: number; + bodyShape: string; + rowCount: number | null; + note: string; +} + +const report = { + meta: { + ranAt: new Date().toISOString(), + baseUrl: BASE, + tokenSource: ".env BIND_API_TOKEN (usuario BIND: Arturo Rosas, según correo de Pedro 6-jul-2026)", + budget: BUDGET, + requestsUsed: 0, + note: "Reporte sanitizado: solo estructura (campos, tipos, formatos, conteos, códigos). Sin valores reales.", + }, + auth: {} as Record, + inventory: [] as InventoryEntry[], + shapes: {} as Record, + odata: {} as Record, + byId: {} as Record, + multiCompany: {} as Record, + balanceQuestion: {} as Record, + headersObserved: {} as Record, + paymentsHunt: {} as Record, + statusSemantics: {} as Record, + prefactura: {} as Record, + pagination: {} as Record, + seriesHunt: {} as Record, +}; + +function projection(p: Probe): Omit { + const { rows: _rows, ...rest } = p; + return rest; +} + +// ─── Fases ────────────────────────────────────────────────────────────────── + +const PRIORITY_RESOURCES = [ + "Invoices", "Clients", "Customers", "Payments", + "Quotes", "Quotations", "Cotizaciones", + "Products", "Currencies", "Warehouses", "Locations", "Branches", "Sucursales", "Series", +]; + +const SECONDARY_RESOURCES = [ + "Activities", "CreditNotes", "Taxes", "PriceLists", "Prices", + "Orders", "SalesOrders", "PurchaseOrders", "Providers", "Suppliers", + "Banks", "BankAccounts", "Sellers", "Employees", "Users", + "Companies", "Expenses", "Inventory", "CFDI", "CFDIs", +]; + +async function phaseAuth(): Promise { + console.log("\n── Fase 1 · Autenticación"); + // Endpoint de referencia barato. Products está documentado públicamente. + const ok = await probeGet("/api/Products?$top=1"); + const noToken = await probeGet("/api/Products?$top=1", null); + const badToken = await probeGet("/api/Products?$top=1", "invalid-token-abc123"); + report.auth = { + scheme: "Authorization: Bearer (único header; sin Ocp-Apim-Subscription-Key)", + validToken: projection(ok), + missingToken: projection(noToken), + invalidToken: projection(badToken), + }; + Object.assign(report.headersObserved, ok.headers); + console.log(` token válido → ${ok.status} · sin token → ${noToken.status} · token corrupto → ${badToken.status}`); + if (!ok.ok) { + console.log(" ⚠️ El token de .env NO autenticó contra /api/Products. Revisar antes de seguir."); + } + return ok.ok ? "ok" : "fail"; +} + +async function phaseInventory(): Promise> { + console.log("\n── Fase 2 · Inventario de recursos (GET {recurso}?$top=1)"); + const results = new Map(); + for (const res of [...PRIORITY_RESOURCES, ...SECONDARY_RESOURCES]) { + let p = await probeGet(`/api/${res}?$top=1`); + let note = ""; + // Algunos endpoints podrían rechazar $top — reintenta plano solo para prioritarios. + if (p.status === 400 && PRIORITY_RESOURCES.includes(res)) { + const plain = await probeGet(`/api/${res}`); + if (plain.ok) { p = plain; note = "existe pero rechaza $top=1 (400)"; } + else note = "400 con y sin $top"; + } + if (p.status === 404) note ||= "no existe con este nombre"; + if (p.status === 401 || p.status === 403) note ||= "sin permiso para este token"; + if (p.ok) note ||= `responde ${p.bodyShape}`; + results.set(res, p); + report.inventory.push({ + resource: res, path: p.path, status: p.status, + bodyShape: p.bodyShape, rowCount: p.rowCount, note, + }); + Object.assign(report.headersObserved, p.headers); + console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res.padEnd(15)} → ${p.status}${note ? ` (${note})` : ""}`); + } + return results; +} + +async function phaseShapes(inventory: Map): Promise> { + console.log("\n── Fase 3 · Inventario de campos ($top=5, solo estructura)"); + const rowsByResource = new Map(); + const targets = [...PRIORITY_RESOURCES, "Companies", "CreditNotes", "Activities"] + .filter((r) => inventory.get(r)?.ok); + for (const res of targets) { + const p = await probeGet(`/api/${res}?$top=5`); + const rows = p.rows ?? []; + rowsByResource.set(res, rows); + report.shapes[res] = { + rowsAnalyzed: Math.min(rows.length, 5), + totalCount: p.count, + fields: analyzeRows(rows), + }; + console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res.padEnd(15)} → ${rows.length} filas analizadas, ${report.shapes[res].fields.length} campos`); + } + return rowsByResource; +} + +async function phaseOData(rowsByResource: Map): Promise { + console.log("\n── Fase 4 · Mecánica OData"); + // Elige el mejor recurso disponible para las pruebas. + const resource = ["Invoices", "Products", "Clients", "Customers"].find((r) => rowsByResource.has(r)); + if (!resource) { report.odata = { skipped: "ningún recurso disponible" }; return; } + const fields = report.shapes[resource]?.fields ?? []; + const idField = fields.find((f) => /^id$/i.test(f.name))?.name ?? fields.find((f) => f.formats.includes("guid/uuid"))?.name; + const dateField = fields.find((f) => f.formats.some((x) => x.startsWith("datetime")))?.name; + const numField = fields.find((f) => /integer|decimal/.test(f.formats.join()) && !/id/i.test(f.name))?.name; + const enumField = fields.find((f) => f.enumValues?.length); + + const odata: Record = { resourceUsed: resource, idField, dateField, numField }; + + // $top / $skip coherentes + const a = await probeGet(`/api/${resource}?$top=2`); + const b = await probeGet(`/api/${resource}?$top=1&$skip=1`); + if (idField && a.rows?.length === 2 && b.rows?.length === 1) { + const id = (r: unknown) => (r as Record)[idField]; + odata.topSkip = { works: id(a.rows[1]) === id(b.rows[0]), statuses: [a.status, b.status] }; + } else { + odata.topSkip = { works: null, statuses: [a.status, b.status], note: "sin filas suficientes para comparar" }; + } + + // $orderby + if (dateField) { + const o = await probeGet(`/api/${resource}?$top=3&$orderby=${encodeURIComponent(`${dateField} asc`)}`); + let sorted: boolean | null = null; + if (o.rows && o.rows.length >= 2) { + const vals = o.rows.map((r) => String((r as Record)[dateField] ?? "")); + sorted = vals.every((v, i) => i === 0 || v >= String(vals[i - 1])); + } + odata.orderby = { field: dateField, status: o.status, ascendingVerified: sorted }; + } + + // Conteo total: v3 ($inlinecount) vs v4 ($count) + const v3 = await probeGet(`/api/${resource}?$top=1&$inlinecount=allpages`); + const v4 = await probeGet(`/api/${resource}?$top=1&$count=true`); + odata.countMechanism = { + "v3 $inlinecount=allpages": { status: v3.status, countReturned: v3.count !== null, totalCount: v3.count }, + "v4 $count=true": { status: v4.status, countReturned: v4.count !== null, totalCount: v4.count }, + }; + + // $filter numérico + if (numField) { + const f = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${numField} ge 0`)}`); + odata.filterNumeric = { expr: `${numField} ge 0`, status: f.status, rows: f.rowCount }; + } + + // $filter por enum observado (código de proceso, no dato personal) + if (enumField?.enumValues?.[0] !== undefined) { + const isNum = /^\d+$/.test(enumField.enumValues[0]); + const lit = isNum ? enumField.enumValues[0] : `'${enumField.enumValues[0]}'`; + const f = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${enumField.name} eq ${lit}`)}`); + odata.filterEnum = { expr: `${enumField.name} eq ${lit}`, status: f.status, rows: f.rowCount }; + } + + // $filter por fecha: sintaxis v3 (datetime'...') vs v4 (literal ISO) + if (dateField) { + const fv3 = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${dateField} ge datetime'2020-01-01T00:00:00'`)}`); + let fv4: Probe | null = null; + if (!fv3.ok) fv4 = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${dateField} ge 2020-01-01T00:00:00Z`)}`); + odata.filterDate = { + "v3 datetime'...'": fv3.status, + ...(fv4 ? { "v4 ISO literal": fv4.status } : {}), + verdict: fv3.ok ? "sintaxis OData v3" : fv4?.ok ? "sintaxis OData v4" : "ninguna funcionó", + }; + } + + // $select + if (idField) { + const s = await probeGet(`/api/${resource}?$top=1&$select=${idField}`); + odata.select = { status: s.status, works: s.ok }; + // Página default (barata: solo IDs) — tamaño de página y nextLink + if (s.ok) { + const d = await probeGet(`/api/${resource}?$select=${idField}`); + odata.defaultPage = { rowsReturned: d.rowCount, nextLinkPresent: d.nextLink, totalCount: d.count, status: d.status }; + } + } + + report.odata = odata; + console.log(` recurso de prueba: ${resource} · resultados en reporte`); +} + +async function phaseById(rowsByResource: Map): Promise { + console.log("\n── Fase 5 · GET por ID"); + const resource = ["Invoices", "Clients", "Customers", "Products"].find((r) => rowsByResource.has(r) && (rowsByResource.get(r)?.length ?? 0) > 0); + if (!resource) { report.byId = { skipped: "sin filas para tomar un ID" }; return; } + const fields = report.shapes[resource]?.fields ?? []; + const idField = fields.find((f) => /^id$/i.test(f.name))?.name; + if (!idField) { report.byId = { skipped: "sin campo ID identificable" }; return; } + const firstId = String((rowsByResource.get(resource)![0] as Record)[idField] ?? ""); + if (!firstId) { report.byId = { skipped: "ID vacío" }; return; } + + const odataStyle = await probeGet(`/api/${resource}(guid'${firstId}')`); + let restStyle: Probe | null = null; + if (!odataStyle.ok) restStyle = await probeGet(`/api/${resource}/${firstId}`); + report.byId = { + resource, + "odata (guid'...')": odataStyle.status, + ...(restStyle ? { "rest (/{id})": restStyle.status } : {}), + verdict: odataStyle.ok ? "estilo OData key" : restStyle?.ok ? "estilo REST /{id}" : "ninguno funcionó", + // ¿El detalle trae más campos que la lista? (p.ej. Lines embebidas) + detailFieldCount: odataStyle.ok || restStyle?.ok + ? analyzeRows((odataStyle.ok ? odataStyle : restStyle!).rows ?? []).length + : null, + listFieldCount: fields.length, + }; + if (odataStyle.ok || restStyle?.ok) { + const detail = (odataStyle.ok ? odataStyle : restStyle!).rows ?? []; + report.shapes[`${resource}(detalle por ID)`] = { + rowsAnalyzed: detail.length, + totalCount: null, + fields: analyzeRows(detail), + }; + } + console.log(` ${resource} por ID → OData:${odataStyle.status}${restStyle ? ` / REST:${restStyle.status}` : ""}`); +} + +function phaseBalanceVerdict(): void { + const inv = report.shapes["Invoices"] ?? report.shapes["Invoices(detalle por ID)"]; + if (!inv) { report.balanceQuestion = { verdict: "SIN VALIDAR — Invoices no accesible" }; return; } + const all = [ + ...(report.shapes["Invoices"]?.fields ?? []), + ...(report.shapes["Invoices(detalle por ID)"]?.fields ?? []), + ]; + const balanceish = [...new Set(all.filter((f) => /balance|saldo|due|paid|pending|remain|credit|debt|payment/i.test(f.name)).map((f) => f.name))]; + report.balanceQuestion = { + fieldsMatching: balanceish, + verdict: balanceish.length > 0 + ? "REVISAR nombres arriba — hay candidatos a saldo por factura (Plan A probable)" + : "Sin campo de saldo visible en Invoices (apunta a Plan B: total − pagos)", + }; +} + +function phaseMultiCompany(inventory: Map): void { + const companies = inventory.get("Companies"); + const companyFields: string[] = []; + for (const [res, shape] of Object.entries(report.shapes)) { + for (const f of shape.fields) { + if (/company|empresa/i.test(f.name)) companyFields.push(`${res}.${f.name}`); + } + } + report.multiCompany = { + companiesEndpoint: companies ? { status: companies.status, rowCount: companies.rowCount } : "no sondeado", + companyLikeFields: companyFields, + note: "1 token = 1 usuario BIND (kickoff #22). Si Companies no existe o regresa 1 fila, el token está acotado a la empresa del usuario (Balam).", + }; +} + +// ─── Fases de ronda 2 (sondeos dirigidos) ───────────────────────────── + +async function phasePaymentsHunt(): Promise { + console.log("\n── Ronda 2 · Búsqueda del recurso de pagos"); + const candidates = [ + "Payment", "ClientPayments", "CustomerPayments", "Incomes", "Income", + "Deposits", "Collections", "PaymentComplements", "Complements", "CashReceipts", + "AccountsReceivable", "Receivables", + ]; + const found: Record = {}; + for (const c of candidates) { + const p = await probeGet(`/api/${c}?$top=1`); + found[c] = p.status; + console.log(` [${String(used).padStart(3)}/${BUDGET}] ${c.padEnd(20)} → ${p.status}`); + if (p.ok && p.rows) { + report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) }; + } + } + // Sub-recurso bajo factura: /api/Invoices/{id}/Payments + const inv = await probeGet("/api/Invoices?$top=1"); + const invId = inv.rows?.[0] ? String((inv.rows[0] as Record)["ID"] ?? "") : ""; + const subProbes: Record = {}; + if (invId) { + for (const sub of ["Payments", "payments", "CreditNotes"]) { + const p = await probeGet(`/api/Invoices/${invId}/${sub}`); + subProbes[`Invoices/{id}/${sub}`] = p.status; + console.log(` [${String(used).padStart(3)}/${BUDGET}] Invoices/{id}/${sub.padEnd(12)} → ${p.status}`); + if (p.ok && p.rows?.length) { + report.shapes[`Invoices/{id}/${sub}`] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) }; + } + } + } + report.paymentsHunt = { + collectionCandidates: found, + subResources: subProbes, + invoiceEmbeddedField: "Invoices.Payments es numérico (acumulado pagado) — ver shapes", + }; +} + +async function phaseStatusSemantics(): Promise { + console.log("\n── Ronda 2 · Semántica de estatus"); + // Quotes: la lista trae Status(int) + StatusText(string) en la misma fila — mapeo barato. + const quoteMap: Record = {}; + for (const code of [0, 1, 2, 3, 4, 5]) { + const p = await probeGet(`/api/Quotes?$top=1&$filter=${encodeURIComponent(`Status eq ${code}`)}`); + if (p.ok && p.rows?.length) { + const r = p.rows[0] as Record; + quoteMap[String(code)] = String(r["StatusText"] ?? "(sin StatusText)"); + } else if (!p.ok) { + quoteMap[String(code)] = `error ${p.status}`; + } + } + console.log(` Quotes.Status → ${JSON.stringify(quoteMap)}`); + + // Invoices: lista trae Status(int); el label vive en el detalle (Status string + StatusCode int). + const invoiceMap: Record = {}; + for (const code of [0, 1, 2, 3, 4, 5]) { + const list = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent(`Status eq ${code}`)}`); + if (!list.ok || !list.rows?.length) continue; + const id = String((list.rows[0] as Record)["ID"] ?? ""); + if (!id) continue; + const det = await probeGet(`/api/Invoices/${id}`); + if (det.ok && det.rows?.length) { + const d = det.rows[0] as Record; + invoiceMap[String(code)] = `${String(d["Status"] ?? "?")} (StatusCode=${String(d["StatusCode"] ?? "?")})`; + } + } + console.log(` Invoices.Status → ${JSON.stringify(invoiceMap)}`); + report.statusSemantics = { + quotesStatusToText: quoteMap, + invoicesStatusToLabel: invoiceMap, + note: "Labels vienen del propio API (campo StatusText / Status del detalle); son códigos de proceso, no datos personales.", + }; +} + +async function phasePrefactura(): Promise { + console.log("\n── Ronda 2 · ¿Prefacturas visibles? (UUID null / IsFiscalInvoice false)"); + const uuidNull = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent("UUID eq null")}`); + const notFiscal = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent("IsFiscalInvoice eq false")}`); + report.prefactura = { + "filter UUID eq null": { status: uuidNull.status, rows: uuidNull.rowCount }, + "filter IsFiscalInvoice eq false": { status: notFiscal.status, rows: notFiscal.rowCount }, + interpretation: + (uuidNull.rowCount ?? 0) > 0 || (notFiscal.rowCount ?? 0) > 0 + ? "Hay documentos sin timbrar visibles en /api/Invoices — prefactura distinguible vía UUID/IsFiscalInvoice" + : "Con los filtros probados no aparecieron prefacturas — posible que /api/Invoices solo exponga CFDI timbrados (validar en UI con Arturo)", + }; + console.log(` UUID null → ${uuidNull.status}/${uuidNull.rowCount} filas · IsFiscalInvoice false → ${notFiscal.status}/${notFiscal.rowCount} filas`); +} + +async function phaseDetailShapes(): Promise { + console.log("\n── Ronda 2 · Detalle por ID de Clients y Quotes"); + for (const res of ["Clients", "Quotes"]) { + const list = await probeGet(`/api/${res}?$top=1`); + const id = list.rows?.[0] ? String((list.rows[0] as Record)["ID"] ?? "") : ""; + if (!id) continue; + const det = await probeGet(`/api/${res}/${id}`); + console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res}/{id} → ${det.status} (${det.ok ? analyzeRows(det.rows ?? []).length : 0} campos)`); + if (det.ok && det.rows?.length) { + report.shapes[`${res}(detalle por ID)`] = { + rowsAnalyzed: det.rows.length, + totalCount: null, + fields: analyzeRows(det.rows), + }; + } + } +} + +async function phasePagination(): Promise { + console.log("\n── Ronda 2 · Paginación"); + const plain = await probeGet("/api/Quotes"); // colección chica conocida; mide page size default + const top101 = await probeGet("/api/Quotes?$top=101"); + report.pagination = { + "GET sin $top (Quotes)": { rows: plain.rowCount, nextLink: plain.nextLink, count: plain.count, status: plain.status }, + "GET $top=101 (Quotes)": { rows: top101.rowCount, nextLink: top101.nextLink, status: top101.status }, + note: "Si rows < total esperado y no hay nextLink, la paginación es por $top/$skip manual.", + }; + console.log(` sin $top → ${plain.rowCount} filas (nextLink=${plain.nextLink}) · $top=101 → ${top101.rowCount} filas`); +} + +async function phaseSeriesHunt(): Promise { + console.log("\n── Ronda 2 · Series de facturación"); + const out: Record = {}; + for (const c of ["InvoiceSeries", "Folios", "DocumentSeries"]) { + const p = await probeGet(`/api/${c}?$top=1`); + out[c] = p.status; + if (p.ok && p.rows?.length) { + report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) }; + } + } + report.seriesHunt = out; + console.log(` ${JSON.stringify(out)}`); +} + +/** Carga el reporte previo y purga enumValues de campos tipo monto (fuga corregida). */ +function loadPreviousReport(): boolean { + try { + const prev = JSON.parse(readFileSync(join(OUT_DIR, "report.json"), "utf8")) as typeof report; + Object.assign(report, prev); + for (const shape of Object.values(report.shapes)) { + for (const f of shape.fields) { + if (f.enumValues && ENUM_EXCLUDE.test(f.name)) delete f.enumValues; + } + } + return true; + } catch { + return false; + } +} + +// ─── Fases de ronda 3 (cabos sueltos) ─────────────────────────────────────── + +async function phaseCaps(): Promise { + console.log("\n── Ronda 3 · Límites de $top y tamaño de colecciones"); + const top100 = await probeGet("/api/Quotes?$top=100"); + const skip100 = await probeGet("/api/Invoices?$top=1&$skip=100"); + const skip1000 = await probeGet("/api/Invoices?$top=1&$skip=1000"); + report.pagination = { + ...(report.pagination as Record), + "GET $top=100 (Quotes)": { status: top100.status, rows: top100.rowCount }, + topCapVerdict: top100.ok ? "$top acepta hasta 100; 101 → 500" : `$top=100 también falla (${top100.status})`, + invoicesSizeBracket: { + "skip=100 devuelve fila": (skip100.rowCount ?? 0) > 0, + "skip=1000 devuelve fila": (skip1000.rowCount ?? 0) > 0, + note: "brackets aproximados del total de facturas históricas, sin descargar la colección", + }, + }; + console.log(` $top=100 → ${top100.status} · skip100 → ${skip100.rowCount} · skip1000 → ${skip1000.rowCount}`); +} + +async function phaseCfdiLiterals(): Promise { + console.log("\n── Ronda 3 · Literales CFDI (PPD/PUE, uso CFDI) — sanitizador corregido"); + const list = await probeGet("/api/Invoices?$top=2"); + const rows = list.rows ?? []; + const detailRows: unknown[] = []; + for (const r of rows) { + const id = String((r as Record)["ID"] ?? ""); + if (!id) continue; + const det = await probeGet(`/api/Invoices/${id}`); + if (det.ok && det.rows) detailRows.push(...det.rows); + } + if (detailRows.length) { + report.shapes["Invoices(detalle por ID)"] = { + rowsAnalyzed: detailRows.length, + totalCount: null, + fields: analyzeRows(detailRows), + }; + const f = report.shapes["Invoices(detalle por ID)"].fields.find((x) => x.name === "CFDIPaymentMethod"); + console.log(` CFDIPaymentMethod literales → ${JSON.stringify(f?.enumValues ?? [])}`); + } +} + +async function phasePaymentsHuntEs(): Promise { + console.log("\n── Ronda 3 · Pagos: nombres en español y variantes finales"); + const extra: Record = {}; + for (const c of ["Cobros", "Pagos", "InvoicePayments", "PaymentsReceived"]) { + const p = await probeGet(`/api/${c}?$top=1`); + extra[c] = p.status; + if (p.ok && p.rows?.length) { + report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) }; + } + } + report.paymentsHunt = { ...(report.paymentsHunt as Record), spanishAndFinal: extra }; + console.log(` ${JSON.stringify(extra)}`); +} + +async function phaseDocEndpoints(): Promise { + console.log("\n── Ronda 3 · ¿Descarga de PDF/XML del CFDI? (solo estatus; el contenido se descarta)"); + const list = await probeGet("/api/Invoices?$top=1"); + const id = list.rows?.[0] ? String((list.rows[0] as Record)["ID"] ?? "") : ""; + const out: Record = {}; + if (id) { + for (const sub of ["pdf", "xml", "PDF", "cfdi"]) { + const p = await probeGet(`/api/Invoices/${id}/${sub}`); + out[`Invoices/{id}/${sub}`] = { status: p.status, contentType: p.contentType }; + if (p.ok) break; // con uno confirmado basta + } + } + (report as Record)["documentDownload"] = out; + console.log(` ${JSON.stringify(out)}`); +} + +// ─── Ronda 4: verificación aritmética del saldo (en memoria, sin persistir montos) ─── + +async function phaseBalanceArithmetic(): Promise { + console.log("\n── Ronda 4 · Verificación del saldo: ¿Payments acumula lo pagado? (aritmética en memoria)"); + const paid = await probeGet(`/api/Invoices?$top=5&$filter=${encodeURIComponent("Status eq 1")}`); + const active = await probeGet(`/api/Invoices?$top=5&$filter=${encodeURIComponent("Status eq 0")}`); + const near = (a: number, b: number) => Math.abs(a - b) < 0.01; + const summarize = (rows: unknown[]) => + rows.map((r) => { + const o = r as Record; + const total = Number(o["Total"] ?? NaN); + const pay = Number(o["Payments"] ?? NaN); + const cn = Number(o["CreditNotes"] ?? 0); + return { settled: near(pay + cn, total), residualPositive: total - pay - cn > 0.01 }; + }); + const paidChecks = summarize(paid.rows ?? []); + const activeChecks = summarize(active.rows ?? []); + report.balanceQuestion = { + ...(report.balanceQuestion as Record), + arithmetic: { + paidSample: { n: paidChecks.length, allSettled: paidChecks.every((c) => c.settled) }, + activeSample: { n: activeChecks.length, allWithResidual: activeChecks.every((c) => c.residualPositive) }, + formula: "SaldoPorFactura = Total − Payments − CreditNotes (campos de la MISMA fila de /api/Invoices)", + }, + }; + console.log( + ` pagadas: ${paidChecks.length} muestras, todas saldadas=${paidChecks.every((c) => c.settled)} · activas: ${activeChecks.length} muestras, todas con residual=${activeChecks.every((c) => c.residualPositive)}`, + ); + + // XML del CFDI (quedó sin probar en ronda 3 por el break temprano) + const id = paid.rows?.[0] ? String((paid.rows[0] as Record)["ID"] ?? "") : ""; + if (id) { + const xml = await probeGet(`/api/Invoices/${id}/xml`); + const doc = ((report as Record)["documentDownload"] ?? {}) as Record; + doc["Invoices/{id}/xml"] = { status: xml.status, contentType: xml.contentType }; + (report as Record)["documentDownload"] = doc; + console.log(` xml → ${xml.status} (${xml.contentType})`); + } +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +async function main() { + const round2 = process.argv.includes("--round2"); + const round3 = process.argv.includes("--round3"); + const round4 = process.argv.includes("--round4"); + console.log(`BIND API · validación técnica SOLO LECTURA${round2 ? " · RONDA 2" : round3 ? " · RONDA 3" : round4 ? " · RONDA 4" : ""}`); + console.log(`Base: ${BASE} · presupuesto: ${BUDGET} peticiones`); + if (!TOKEN) { + console.error("❌ No hay BIND_API_TOKEN en bind-api-sandbox/.env — abortando."); + process.exitCode = 1; + return; + } + console.log("Token cargado desde .env (no se imprime)."); + if (/localhost|127\.0\.0\.1/.test(BASE)) { + console.log("⚠️ Base URL apunta al mock local; esto NO valida producción."); + } + + if (round2 || round3 || round4) { + if (!loadPreviousReport()) { + console.error("❌ --round2/--round3/--round4 requieren validation-output/report.json previo."); + process.exitCode = 1; + return; + } + used = report.meta.requestsUsed; // presupuesto acumulado entre rondas + if (round2) { + await phasePaymentsHunt(); + await phaseStatusSemantics(); + await phasePrefactura(); + await phaseDetailShapes(); + await phasePagination(); + await phaseSeriesHunt(); + } else if (round3) { + await phaseCaps(); + await phaseCfdiLiterals(); + await phasePaymentsHuntEs(); + await phaseDocEndpoints(); + } else { + await phaseBalanceArithmetic(); + } + phaseBalanceVerdict(); + finish(); + return; + } + + const auth = await phaseAuth(); + if (auth !== "ok") { + finish(); + return; + } + const inventory = await phaseInventory(); + const rowsByResource = await phaseShapes(inventory); + await phaseOData(rowsByResource); + await phaseById(rowsByResource); + phaseBalanceVerdict(); + phaseMultiCompany(inventory); + finish(); +} + +function finish() { + report.meta.requestsUsed = used; + mkdirSync(OUT_DIR, { recursive: true }); + const serialized = scrub(JSON.stringify(report, null, 2)); + writeFileSync(join(OUT_DIR, "report.json"), serialized, "utf8"); + console.log(`\n✔ Reporte sanitizado escrito en validation-output/report.json`); + console.log(`✔ Peticiones usadas: ${used}/${BUDGET}`); +} + +main().catch((err) => { + console.error("Error fatal:", scrub(String(err?.message ?? err))); + finish(); + process.exitCode = 1; +});