Compare commits

...

4 Commits

Author SHA1 Message Date
JohannVelazquez 8967fa2fed Bitácora #32: validación API BIND completada (adelantada); pendientes de escalación a Pedro 2026-07-06 17:17:27 -06:00
JohannVelazquez fde0b9333b Prototipo Etapa 0: HTML navegable con el flujo real del Discovery (Jira → cotización → prefactura → CFDI → envío) 2026-07-06 17:17:27 -06:00
JohannVelazquez 9ccfbe262e 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.
2026-07-06 17:17:27 -06:00
JohannVelazquez 12006b5fa4 Bitácora: correo de Noé pide salvaguardas sobre el token (posible reemplazo por usuario solo-lectura) 2026-07-06 16:38:50 -06:00
15 changed files with 3206 additions and 29 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
> **Fuente de la verdad del proyecto.** Este repositorio concentra todo: propuesta, comunicaciones, fuentes y prototipos. Si pasa algo (llamada, correo, mensaje, decisión), se registra en la [bitácora](bitacora/). Empieza por aquí.
**Estado:** 🟢 **Discovery en marcha (Etapa 0)** — contrato firmado (26-jun), kickoff realizado (1-jul), **primera sesión de Discovery realizada (6-jul: proceso de facturación mapeado end-to-end con Ara y Arturo)**. Cambios de reglas del 6-jul: **lista blanca eliminada** (recordatorios a todos) y **cotización BIND obligatoria**. Johann trabaja el **prototipo** con el flujo real. ✅ **Token BIND ENTREGADO** (6-jul, 12:40 pm, por correo) — arranca la validación técnica de la API. Sesión de **cobranza** propuesta para el mar 7-jul (Erika confirma agenda). Bloqueadores restantes: (1) repo GitHub y Azure; (2) confirmación de Balam a la propuesta v1.2 (enviada 2-jul) para emitir la **factura de 30 h**.
**Estado:** 🟢 **Discovery en marcha (Etapa 0)** — contrato firmado (26-jun), kickoff realizado (1-jul), **primera sesión de Discovery realizada (6-jul: proceso de facturación mapeado end-to-end con Ara y Arturo)**. Cambios de reglas del 6-jul: **lista blanca eliminada** (recordatorios a todos) y **cotización BIND obligatoria**. Johann trabaja el **prototipo** con el flujo real. ✅ **Token BIND entregado y VALIDACIÓN TÉCNICA de la API completada** (6-jul, adelantada al plan): **Plan A del saldo confirmado** (sin horas extra), flujo MVP sostenible 100% en lectura, PDF del CFDI por API; hueco: sin recurso de pagos/REP (escalar a Pedro). Detalle en [`bind-api-sandbox/VALIDACION-API.md`](bind-api-sandbox/VALIDACION-API.md). Sesión de **cobranza** propuesta para el mar 7-jul (Erika confirma agenda). Bloqueadores restantes: (1) repo GitHub y Azure; (2) confirmación de Balam a la propuesta v1.2 (enviada 2-jul) para emitir la **factura de 30 h**.
_Última actualización: 2026-07-06._
---
+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;
});
+5 -1
View File
@@ -11,7 +11,11 @@ _Última actualización: 2026-07-06 (Discovery realizado: proceso mapeado, lista
- [ ] 🎨 **Trabajar el prototipo (Etapa 0)** reflejando el flujo real levantado el 6-jul: Jira (disparador) → cotización BIND → prefactura → validación humana → CFDI → envío. Incluir las validaciones de oro: **PPD default** (PUE consciente — ya les costó un requerimiento del SAT), **IVA 16% MXN / 0% extranjero** (BIND no lo automatiza), prefactura siempre antes de timbrar, alta de cliente como flujo aparte. Ver [REGISTRO #27](REGISTRO.md).
- [x] ~~📅 **Proponer a Erika usar la sesión del martes 7-jul para COBRANZA**~~**Propuesto (6-jul, 3:01 pm):** Erika revisa la agenda con los involucrados y confirma. Ideal que ocurra antes del viernes 10 (validación del prototipo). Ver [REGISTRO #30](REGISTRO.md).
- [x] ~~📧 **Responder el correo de Pedro**~~**Enviado (6-jul, ~3:28 pm):** acuse de recibo + usuario de Arturo documentado + arranque de trabajo con la API, sin comprometer entregas adicionales. Ver [REGISTRO #29](REGISTRO.md).
- [ ] 🔑 **Validar la API de BIND con el token real** (mar 7 mié 8 del plan; puede adelantarse) — guardar el token en **gestor de secretos** (no dejarlo en el correo/.txt), correr el sandbox (`bind-api-sandbox/`) contra `api.bind.com.mx` en **solo lectura**, documentar cobertura de endpoints (facturas, clientes, pagos, cotizaciones) y confirmar si caduca. Entregar resumen de hallazgos. Ver [REGISTRO #28](REGISTRO.md).
- [ ] 📧⚠️ **Responder el correo de Noé (importancia alta, 4:33 pm)** describiendo las **salvaguardas** ya operando: solo-lectura bloqueado en código, consultas acotadas, token en gestor de secretos fuera del repo, sin datos reales en documentos, escritura solo con autorización + candados de la propuesta. Borrador listo. Ver [REGISTRO #31](REGISTRO.md).
- [ ] ⚠️ **No atar nada al token actual:** Pedro está revisando permisos y puede **reemplazarlo** por uno de un usuario nuevo de solo lectura — mantener el token como variable de entorno intercambiable (ya es así en el sandbox). Ver [REGISTRO #31](REGISTRO.md).
- [x] ~~🔑 **Validar la API de BIND con el token real**~~ → ✅ **Hecha (6-jul, adelantada un día):** 117 peticiones GET-only, Plan A del saldo CONFIRMADO, flujo MVP 100% sostenible en lectura, PDF por API. Hallazgo duro: **sin recurso de pagos/REP**. Documento: [`../bind-api-sandbox/VALIDACION-API.md`](../bind-api-sandbox/VALIDACION-API.md). Ver [REGISTRO #32](REGISTRO.md).
- [ ] 📨 **Escalar a Pedro las preguntas técnicas de la API** (juntas, esta semana): (1) ¿existe endpoint de **pagos individuales / complementos de pago (REP)** no documentado? — es el hueco más relevante para el flujo PPD; (2) **tabla de mapeo `CFDIUse`** interno → clave SAT; (3) shape del endpoint `/{id}/xml`; (4) ¿webhooks/eventos o solo polling? Ver [REGISTRO #32](REGISTRO.md) §10.
- [ ] 🔐 **Higiene del token:** moverlo a un gestor de secretos y **borrar `bind_token_api.txt`** de la raíz del repo y del correo/descargas (está gitignoreado, pero sigue en disco).
- [ ] ⚠️ **Aclarar expectativa de recordatorios a clientes:** lo que Ara/Arturo describieron el 6-jul son recordatorios automáticos **a clientes**, que están **diferidos al Anexo B** (el MVP trae alertas *internas*). Aclararlo pronto o anticipar que pidan ese módulo al cierre del MVP. Igual con la idea de **agentes observando Jira** (Nivel 3, fase posterior) y el deseo **multi-empresa** (Regiotour, Elmstone). Ver [REGISTRO #27](REGISTRO.md).
- [x] ~~**Ajustar la propuesta** (30 h explícitas, sin "Etapa 0", términos comerciales en una sección)~~**Hecho (1-jul):** propuesta **v1.2** — se agregó el concepto **"Facturación inicial: 30 h / $18,000 + IVA"** (cubre Etapa 0 + inicio de Etapa 1; Etapa 0 se mantiene en 1822 h) y se consolidó **§3.3 Condiciones comerciales**. Fuente MD actualizada; **falta regenerar el PDF** (lo hace el agente de PDF de Johann con el mismo cambio).
- [x] ~~📧 **Enviar el correo** (Noé, CC Pedro, Ara, Erika) con la propuesta v1.2 ajustada, vinculando la **facturación inicial de 30 h** al acuerdo~~**Hecho (2-jul, 5:39 PM):** enviado con `Propuesta-Balam.pdf` adjunto, sin esperar el correo de Erika+Paola. Ver [REGISTRO #26](REGISTRO.md).
+41
View File
@@ -602,6 +602,47 @@ Johann plantea a Erika que la sesión de hoy solo alcanzó para **facturación y
---
## 31 · Jul 6, 2026 — 4:33 PM · Correo · Noé → Johann, Pedro (CC Erika) · salvaguardas sobre el token ⚠️
> **Importancia alta.** Evidencia: `../fuentes/2026-07-06 - Correo - Pedro entrega token BIND (usuario Arturo Rosas).md` (respuesta de Noé en el hilo).
Noé responde al hilo de la entrega del token con dos instrucciones:
1. **A Johann:** mientras Pedro revisa si el acceso es de lectura o también de **escritura**, aplicar *"las salvaguardas necesarias para evitar alguna afectación operativa"*.
2. **A Pedro:** si es necesario, **crear un usuario específico de solo lectura** en BIND para esta etapa de exploración — *"la cuenta de Arturo tiene algunos privilegios elevados que creo que no serían necesarios en este momento"*.
**Implicación:** el token de Arturo **podría ser reemplazado** por uno de un usuario nuevo de solo lectura.
> **Lectura estratégica:**
> - La preocupación de Noé es **exactamente el diseño ya construido**: el sandbox opera en modo solo-lectura por defecto con bloqueo de escrituras en código, dry-run, cuota vigilada y token fuera del repo (`.gitignore` ya cubre `bind_token_api.txt` y `*.env`). Responder describiendo esas salvaguardas convierte un correo de riesgo en una oportunidad de confianza con el CTO.
> - Ojo operativo: si Pedro crea un usuario nuevo, **el token cambiará** — no invertir esfuerzo en atar nada al token actual; el cliente ya está parametrizado por variable de entorno, el cambio es trivial.
> - Coherente con la estrategia "lectura primero" de la propuesta: nadie está pidiendo frenar la validación, solo asegurar el perímetro.
---
## 32 · Jul 6, 2026 — tarde · Validación técnica de la API de BIND con la cuenta real ⭐ ✅
> Actividad del plan "Validación técnica de la API de BIND" (mar 7 mié 8) — **ejecutada un día antes**, el mismo día en que llegó el token. Evidencia completa: [`../bind-api-sandbox/VALIDACION-API.md`](../bind-api-sandbox/VALIDACION-API.md) (117 peticiones, exclusivamente GET, solo estructura — cero datos reales persistidos).
**Veredictos clave:**
1.**Plan A del saldo CONFIRMADO** — el riesgo central de la propuesta quedó resuelto a favor: cada fila de `Invoices` trae `Total`, `Payments` (acumulado) y `CreditNotes`; el saldo por factura es una resta local, verificada aritméticamente. **No se activa el Plan C (+68 h)** — la inversión apunta a la parte baja del rango.
2.**El flujo del MVP es 100% sostenible en lectura:** cotizaciones legibles (con partidas y comercial), prefacturas distinguibles (`UUID null` / `IsFiscalInvoice false`), PPD/PUE y días de crédito auditables (en el detalle), IVA por partida (habilita la validación 16%/0%), **PDF del CFDI descargable por API** (insumo del módulo de envío), aging viable con `ExpirationDate` + saldo.
3.**No existe recurso de pagos individuales ni de complementos de pago (REP)** — 20 nombres probados, todos 404. La cobranza operativa del MVP funciona igual (detecta pago por transición de estatus/residual), pero la fecha valor del pago y los REP del flujo PPD no son visibles. **Escalar a Pedro.**
4. ⚠️ **Sin vínculo cotización→factura en la API** — la trazabilidad la llevará la plataforma (registrar el par al orquestar la conversión).
5. ⚠️ **Rarezas a cablear con cuidado:** token inválido responde 500 (no 401); `$select` y conteos no funcionan (paginación manual con `$top=100`); nomenclatura CFDI **invertida** vs SAT (`CFDIPaymentTerm` = PPD/PUE); `CFDIUse` es código interno (falta tabla de mapeo); typo real del API (`Loctaion`); campos clave solo en el detalle → **sync incremental obligatorio**.
6.**Token acotado a una sola empresa** (la de Arturo → Balam) — multi-empresa confirmado fuera del alcance por API.
7. **Presupuesto de sync:** ~1,000 req/día con polling cada 15 min ≈ 5% del límite de 20K. Holgado.
**Preguntas que quedan para Pedro/doc (§10 del documento):** endpoint de pagos/REP no descubierto · tabla `CFDIUse` interno→clave SAT · shape del endpoint `/xml` · códigos de `Status` >2 · webhooks/eventos.
> **Lectura estratégica:**
> - **El Discovery técnico ya pagó:** el Plan A confirmado elimina la mayor incertidumbre de horas de la propuesta, y todo lo que el MVP necesita en lectura existe. La actividad del plan se cerró **anticipada** — buen dato para el corte de Erika del lunes.
> - El hueco de REP/pagos es real pero **no bloquea el MVP** (cobranza operativa funciona por transición de estatus); importa para el flujo PPD completo y para conciliación (Anexo B). Escalarlo con Pedro esta semana, idealmente junto con la tabla de `CFDIUse`.
> - Las salvaguardas prometidas a Noé ([#31](#31--jul-6-2026--433-pm--correo--noé--johann-pedro-cc-erika--salvaguardas-sobre-el-token-)) se cumplieron al pie: GET-only por construcción, presupuesto de peticiones, reporte sanitizado, token fuera de git.
---
## Resumen ejecutivo del hilo (para contexto rápido)
### Datos duros confirmados por Balam
@@ -30,6 +30,16 @@
---
**Respuesta de Noé (4:33 PM, a Johann y Pedro, CC Erika — ⚠️ importancia alta):**
> Buenas tardes Johan,
>
> Pedro va a revisar si el acceso es de lectura o también de escritura, por lo que te pido mientras tanto **las salvaguardas necesarias para evitar alguna afectación operativa**.
>
> @Pedro Alberto Ayala Elizondo, si es necesario crea un usuario específico de solo lectura en Bind para esta etapa de exploración, si la única forma de dar lectura es crear un nuevo usuario y token adelante; la cuenta de Arturo tiene algunos privilegios elevados que creo que no serían necesario en este momento.
>
> Saludos.
**Notas:**
- Confirma que el token salió del **usuario de Arturo Rosas** — conforme al acuerdo del kickoff (token de Arturo para desarrollo; el de Ara para Power BI). Cierra el pendiente de documentar cuál llave es para qué.
- El token viaja en `.txt` adjunto al correo → pasarlo a **gestor de secretos** y no conservarlo en el buzón/descargas.
File diff suppressed because it is too large Load Diff