Validación técnica de la API de BIND contra producción (GET-only)

Plan A del saldo CONFIRMADO (Total − Payments − CreditNotes, misma fila);
flujo MVP sostenible 100% en lectura; PDF del CFDI por API. Hallazgo duro:
sin recurso de pagos individuales ni REP (escalar a Pedro). Cliente extendido
(Quotes, Currencies, Locations, pdf/xml, GET por ID estilo REST) y tipos
reales en types.real.ts. Detalle en bind-api-sandbox/VALIDACION-API.md;
reporte crudo y token gitignoreados.
This commit is contained in:
JohannVelazquez
2026-07-06 17:17:27 -06:00
parent 12006b5fa4
commit 9ccfbe262e
10 changed files with 1697 additions and 27 deletions
+14 -4
View File
@@ -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 <token> — 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
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
.env
*.log
validation-output/
+41 -18
View File
@@ -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 <API_KEY>` + `Ocp-Apim-Subscription-Key: <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 <token>` — 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 = "<key del perfil de usuario de Balam>"
$env:BIND_SUBSCRIPTION_KEY = "<si aplica>"
$env:BIND_API_TOKEN = "<token del perfil de Arturo — NUNCA versionarlo>"
$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.
+357
View File
@@ -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 <token>
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 35: 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 (12 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 35 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
+1
View File
@@ -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": {
+110 -4
View File
@@ -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<Console, "info" | "warn" | "error">;
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<BindCollection<InvoiceListItem>> {
return this.get<BindCollection<InvoiceListItem>>(`/api/Invoices${buildQueryString(query)}`);
}
/** GET /api/Invoices/{id} — única fuente de PPD/PUE (CFDIPaymentTerm) y días de crédito. */
invoiceDetail(id: string): Promise<InvoiceDetail> {
return this.get<InvoiceDetail>(this.byId("Invoices", id));
}
/** GET /api/Clients — así se llaman los clientes en el API real (no Customers). */
clients(query: ODataQuery = {}): Promise<BindCollection<ClientListItem>> {
return this.get<BindCollection<ClientListItem>>(`/api/Clients${buildQueryString(query)}`);
}
/** GET /api/Clients/{id} — trae CreditDays, contactos y (sic) Loctaion/LoctaionID. */
clientDetail(id: string): Promise<ClientDetail> {
return this.get<ClientDetail>(this.byId("Clients", id));
}
/** GET /api/Quotes — cotizaciones (0=Activa, 1=Cancelada, 2=Surtida). */
quotes(query: ODataQuery = {}): Promise<BindCollection<QuoteListItem>> {
return this.get<BindCollection<QuoteListItem>>(`/api/Quotes${buildQueryString(query)}`);
}
/** GET /api/Quotes/{id} — partidas Items[]; SIN referencia a la factura generada. */
quoteDetail(id: string): Promise<QuoteDetail> {
return this.get<QuoteDetail>(this.byId("Quotes", id));
}
currencies(query: ODataQuery = {}): Promise<BindCollection<CurrencyInfo>> {
return this.get<BindCollection<CurrencyInfo>>(`/api/Currencies${buildQueryString(query)}`);
}
warehouses(query: ODataQuery = {}): Promise<BindCollection<WarehouseInfo>> {
return this.get<BindCollection<WarehouseInfo>>(`/api/Warehouses${buildQueryString(query)}`);
}
locations(query: ODataQuery = {}): Promise<BindCollection<LocationInfo>> {
return this.get<BindCollection<LocationInfo>>(`/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<ArrayBuffer> {
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<ODataCollection<Customer>> {
return this.get<ODataCollection<Customer>>(`/api/Customers${buildQueryString(query)}`);
}
customer(id: string): Promise<Customer> {
return this.get<Customer>(`/api/Customers(guid'${id}')`);
return this.get<Customer>(this.byId("Customers", id));
}
invoices(query: ODataQuery = {}): Promise<ODataCollection<Invoice>> {
@@ -92,7 +186,7 @@ export class BindClient {
}
invoice(id: string): Promise<Invoice> {
return this.get<Invoice>(`/api/Invoices(guid'${id}')`);
return this.get<Invoice>(this.byId("Invoices", id));
}
payments(query: ODataQuery = {}): Promise<ODataCollection<Payment>> {
@@ -133,6 +227,18 @@ export class BindClient {
return this.request<T>("GET", path);
}
/** GET binario (PDF del CFDI). Cuenta contra la cuota como cualquier request. */
private async getBinary(path: string): Promise<ArrayBuffer> {
this.rolloverIfNewDay();
this.requestCount++;
const headers: Record<string, string> = { 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<T>(method: string, path: string, body?: unknown): Promise<T> {
if (MUTATING.has(method) && this.mode === "read-only") {
throw new BindReadOnlyViolation(method, path);
+306
View File
@@ -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<InvoiceListItem, "Total" | "Payments" | "CreditNotes">): 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<T> {
value: T[];
}
+6 -1
View File
@@ -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);
+857
View File
@@ -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<string, string> {
const out: Record<string, string> = {};
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<string, string>; // 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<Probe> {
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<string, string> = { 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<string, string> = {};
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<Probe, "rows" | "bodyShape" | "count" | "nextLink"> {
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<string, unknown>;
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<string, { types: Set<string>; formats: Set<string>; nullable: boolean; enums: Set<string> }>();
const visit = (obj: Record<string, unknown>, 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<string, unknown>, `${k}[].`);
} else if (!prefix && typeof v === "object" && !Array.isArray(v)) {
visit(v as Record<string, unknown>, `${k}.`);
}
}
};
for (const row of rows.slice(0, cap)) {
if (typeof row === "object" && row !== null && !Array.isArray(row)) {
visit(row as Record<string, unknown>, "");
}
}
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<void> {
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<string, unknown>,
inventory: [] as InventoryEntry[],
shapes: {} as Record<string, { rowsAnalyzed: number; totalCount: number | string | null; fields: FieldInfo[] }>,
odata: {} as Record<string, unknown>,
byId: {} as Record<string, unknown>,
multiCompany: {} as Record<string, unknown>,
balanceQuestion: {} as Record<string, unknown>,
headersObserved: {} as Record<string, string>,
paymentsHunt: {} as Record<string, unknown>,
statusSemantics: {} as Record<string, unknown>,
prefactura: {} as Record<string, unknown>,
pagination: {} as Record<string, unknown>,
seriesHunt: {} as Record<string, unknown>,
};
function projection(p: Probe): Omit<Probe, "rows"> {
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<string> {
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 <token> (ú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<Map<string, Probe>> {
console.log("\n── Fase 2 · Inventario de recursos (GET {recurso}?$top=1)");
const results = new Map<string, Probe>();
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<string, Probe>): Promise<Map<string, unknown[]>> {
console.log("\n── Fase 3 · Inventario de campos ($top=5, solo estructura)");
const rowsByResource = new Map<string, unknown[]>();
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<string, unknown[]>): Promise<void> {
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<string, unknown> = { 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<string, unknown>)[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<string, unknown>)[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<string, unknown[]>): Promise<void> {
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<string, unknown>)[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<string, Probe>): 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<void> {
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<string, number> = {};
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<string, unknown>)["ID"] ?? "") : "";
const subProbes: Record<string, number> = {};
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<void> {
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<string, string> = {};
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<string, unknown>;
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<string, string> = {};
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<string, unknown>)["ID"] ?? "");
if (!id) continue;
const det = await probeGet(`/api/Invoices/${id}`);
if (det.ok && det.rows?.length) {
const d = det.rows[0] as Record<string, unknown>;
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<void> {
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<void> {
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<string, unknown>)["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<void> {
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<void> {
console.log("\n── Ronda 2 · Series de facturación");
const out: Record<string, number> = {};
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<void> {
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<string, unknown>),
"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<void> {
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<string, unknown>)["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<void> {
console.log("\n── Ronda 3 · Pagos: nombres en español y variantes finales");
const extra: Record<string, number> = {};
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<string, unknown>), spanishAndFinal: extra };
console.log(` ${JSON.stringify(extra)}`);
}
async function phaseDocEndpoints(): Promise<void> {
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<string, unknown>)["ID"] ?? "") : "";
const out: Record<string, unknown> = {};
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<string, unknown>)["documentDownload"] = out;
console.log(` ${JSON.stringify(out)}`);
}
// ─── Ronda 4: verificación aritmética del saldo (en memoria, sin persistir montos) ───
async function phaseBalanceArithmetic(): Promise<void> {
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<string, unknown>;
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<string, unknown>),
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<string, unknown>)["ID"] ?? "") : "";
if (id) {
const xml = await probeGet(`/api/Invoices/${id}/xml`);
const doc = ((report as Record<string, unknown>)["documentDownload"] ?? {}) as Record<string, unknown>;
doc["Invoices/{id}/xml"] = { status: xml.status, contentType: xml.contentType };
(report as Record<string, unknown>)["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;
});