Validación técnica de la API de BIND contra producción (GET-only)
Plan A del saldo CONFIRMADO (Total − Payments − CreditNotes, misma fila); flujo MVP sostenible 100% en lectura; PDF del CFDI por API. Hallazgo duro: sin recurso de pagos individuales ni REP (escalar a Pedro). Cliente extendido (Quotes, Currencies, Locations, pdf/xml, GET por ID estilo REST) y tipos reales en types.real.ts. Detalle en bind-api-sandbox/VALIDACION-API.md; reporte crudo y token gitignoreados.
This commit is contained in:
@@ -4,6 +4,10 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Secretos — NUNCA versionar (token API de BIND, producción)
|
||||||
|
bind_token_api.txt
|
||||||
|
*.env
|
||||||
|
|
||||||
# Python (skill proposal-pdf)
|
# Python (skill proposal-pdf)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
# --- Configuración del sandbox de BIND ---
|
# --- Configuración del sandbox de BIND ---
|
||||||
#
|
#
|
||||||
# Por default el cliente apunta al mock local (puerto 4010).
|
# Por default el cliente apunta al mock local (puerto 4010).
|
||||||
# Cuando Pedro entregue el API key real, copia este archivo a .env y
|
# Para apuntar al API REAL: BIND_BASE_URL=https://api.bind.com.mx y pon el
|
||||||
# cambia BIND_BASE_URL a https://api.bind.com.mx + llena las credenciales.
|
# 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
|
# 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
|
# 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
|
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_API_KEY=mock-bearer-token
|
||||||
BIND_SUBSCRIPTION_KEY=mock-subscription-key
|
BIND_SUBSCRIPTION_KEY=
|
||||||
|
|
||||||
# read-only | dry-run | write
|
# read-only | dry-run | write
|
||||||
# - read-only: solo GET. Bloquea POST/PUT/PATCH/DELETE en el cliente.
|
# - read-only: solo GET. Bloquea POST/PUT/PATCH/DELETE en el cliente.
|
||||||
@@ -20,3 +27,6 @@ BIND_MODE=read-only
|
|||||||
|
|
||||||
# Puerto del mock server
|
# Puerto del mock server
|
||||||
MOCK_PORT=4010
|
MOCK_PORT=4010
|
||||||
|
|
||||||
|
# Presupuesto de peticiones del script de validación (src/validate-real-api.ts)
|
||||||
|
VALIDATION_BUDGET=120
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
validation-output/
|
||||||
|
|||||||
+41
-18
@@ -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.
|
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.
|
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 |
|
| Tema | Hallazgo |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Base URL** | `https://api.bind.com.mx` |
|
| **Base URL** | `https://api.bind.com.mx` ✅ confirmado |
|
||||||
| **Estilo** | REST con sintaxis **OData v3** (filtros `$filter`, `$top`, `$skip`, `$orderby`, `$count`, IDs como `guid'...'`) |
|
| **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** | Dos headers: `Authorization: Bearer <API_KEY>` + `Ocp-Apim-Subscription-Key: <SUBSCRIPTION_KEY>` (este último cuando aplica) |
|
| **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* |
|
| **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** (confirmado por Noe, 25-may-2026) |
|
| **Rate limit** | **20,000 peticiones / día** — no observable en headers; llevar contador local |
|
||||||
| **Sandbox oficial** | **No existe.** BIND recomienda Postman contra producción → razón #1 de este sandbox |
|
| **Sandbox oficial** | **No existe.** Este mock local sigue siendo la única red de pruebas sin efecto fiscal |
|
||||||
| **Portal dev** | [developers.bind.com.mx](https://developers.bind.com.mx) (login requerido para ver schemas detallados) |
|
| **PAC para CFDI** | Integrado en BIND; el PDF del CFDI se descarga vía `GET /api/Invoices/{id}/pdf` ✅ |
|
||||||
| **PAC para CFDI** | Integrado dentro del propio BIND — la plataforma de Balam **no toca el SAT**, solo orquesta |
|
| **Recursos confirmados (200)** | `Invoices`, **`Clients`** (no Customers), **`Quotes`**, `Products`, `Currencies`, `Warehouses`, `Locations`, `Activities`, `PriceLists`, `Orders`, `Providers`, `Banks`, `BankAccounts`, `Users` |
|
||||||
| **Recursos confirmados** | `Activities`, `Customers`, `Products` (y según el discovery doc: `Invoices`, `Payments`, `Quotes` muy probables) |
|
| **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
|
### Por qué un sandbox propio y no Postman
|
||||||
|
|
||||||
@@ -75,18 +85,26 @@ Stats del cliente
|
|||||||
{ requestsToday: 6, quota: 20000, remaining: 19994, mode: 'read-only' }
|
{ 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
|
```powershell
|
||||||
$env:BIND_BASE_URL = "https://api.bind.com.mx"
|
$env:BIND_BASE_URL = "https://api.bind.com.mx"
|
||||||
$env:BIND_API_KEY = "<key del perfil de usuario de Balam>"
|
$env:BIND_API_TOKEN = "<token del perfil de Arturo — NUNCA versionarlo>"
|
||||||
$env:BIND_SUBSCRIPTION_KEY = "<si aplica>"
|
|
||||||
$env:BIND_MODE = "read-only" # mantenlo así hasta tener autorización para escribir
|
$env:BIND_MODE = "read-only" # mantenlo así hasta tener autorización para escribir
|
||||||
npm run demo
|
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
|
## 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
|
## 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 schema del MOCK (`types.ts`) sigue siendo la aproximación previa** — el contrato
|
||||||
- **El mock acepta cualquier Bearer**, solo valida que exista. No es un servidor de auth real, es un placeholder.
|
confirmado contra producción vive en **`src/client/types.real.ts`** y en
|
||||||
- **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.
|
[VALIDACION-API.md](VALIDACION-API.md). Pendiente (opcional): regenerar el seed del
|
||||||
- **No reproduce la lógica de `$expand`** — si BIND lo soporta para traer `Lines` o `Customer` embebidos, hay que extender.
|
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.
|
Cuando alguno de estos límites se vuelva una piedra en el zapato, se extiende. Hoy es deliberadamente mínimo.
|
||||||
|
|||||||
@@ -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 3–5: sin filas (o no existen o no hay ejemplares). ⚠️ `Status` **no distingue** prefactura de timbrada — el discriminador fiable es `UUID eq null` / `IsFiscalInvoice eq false` (ambos filtros devuelven filas: **las prefacturas sí son visibles por API**).
|
||||||
|
|
||||||
|
### 3.2 `Invoices/{id}` — detalle (50 campos; los adicionales)
|
||||||
|
|
||||||
|
| Campo | Tipo/formato | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `Series` | string | La lista lo llama `Serie`. |
|
||||||
|
| `Status` / `StatusCode` | string / integer | Etiqueta + código (p. ej. "Pagada" / `1`). |
|
||||||
|
| **`PaymentTerms`** | integer | **Días de crédito** de la factura. Solo en detalle. |
|
||||||
|
| **`CFDIPaymentTerm`** | string | ⚠️ **El método de pago SAT (PPD/PUE)** — se observó el literal "PAGO EN UNA SOLA EXHIBICIÓN" (=PUE). Puede venir vacío. Solo en detalle. |
|
||||||
|
| **`CFDIPaymentMethod`** | string | ⚠️ **La forma de pago SAT** (se observaron "Transferencia Electrónica de Fondos", "Por Definir"). Nomenclatura **invertida** respecto al SAT — ver hallazgos. |
|
||||||
|
| `CFDIAccountNumber` | string | Nº de cuenta (últimos dígitos), puede venir vacío. |
|
||||||
|
| `CurrencyName` | string 3 letras | Código de moneda (`MXN`/`USD`) — en el detalle es el código, no el nombre. |
|
||||||
|
| `ClientPhoneNumber`, `ClientContact` | string \| null | |
|
||||||
|
| `CreatedByID` / `CreatedByName` | guid / string | Quién creó el documento (auditoría). |
|
||||||
|
| `CreationDate` / `ApplicationDate` | datetime ISO | |
|
||||||
|
| `PriceListName`, `LocationName`, `WarehouseName` | string | Denormalizados. |
|
||||||
|
| `FiscalID` | guid | |
|
||||||
|
| `Address` | string largo | Dirección fiscal del receptor. |
|
||||||
|
| `Products` | array | Partidas de productos (vacío en la muestra — Balam factura servicios). |
|
||||||
|
| `Services` | array | **Partidas de servicios.** |
|
||||||
|
| `Services[].ID`, `Services[].ServiceID` | guid | |
|
||||||
|
| `Services[].IndexNumber` | integer | Orden de la partida. |
|
||||||
|
| `Services[].Name`, `Services[].Code` | string | Concepto (p. ej. el 029 "consultoría y servicios" del Discovery). |
|
||||||
|
| `Services[].Qty`, `Services[].Price` | decimal | |
|
||||||
|
| `Services[].VATRate` | decimal | **Tasa de IVA por partida** — habilita la validación 16 % / 0 %. |
|
||||||
|
| `Services[].Discount` | decimal | |
|
||||||
|
|
||||||
|
> El detalle **no** trae `CFDIUse` (solo la lista) ni un campo de saldo precalculado.
|
||||||
|
|
||||||
|
### 3.3 `Quotes` — lista (11 campos) y detalle (45)
|
||||||
|
|
||||||
|
**Lista:** `ID` (guid), `Number` (string), `CreationDate` (datetime), `ClientName`, `Locations` (string), `Comments`, `TotalOriginalCurrency` (decimal), `Currency` (nombre, p. ej. "Peso mexicano"), `Total` (decimal), `Status` (integer), `StatusText` (string).
|
||||||
|
|
||||||
|
**Semántica de `Quotes.Status`** (mapeada vía `$filter` + `StatusText` de la misma fila):
|
||||||
|
|
||||||
|
| Código | `StatusText` |
|
||||||
|
|---|---|
|
||||||
|
| `0` | Activa |
|
||||||
|
| `1` | Cancelada |
|
||||||
|
| `2` | Surtida |
|
||||||
|
|
||||||
|
**Detalle `Quotes/{id}` agrega:** `QuoteNumber`, `ClientID`/`ClientContact`/`ClientPhone`, `LocationName/ID`, `PriceListName/ID`, `EmployeeName/ID` (comercial que cotizó), `CurrencyCode` (3 letras), `ExchangeRate`, `Subtotal`, `Discount`, `IEPS`, `VAT`/`VATRate`, `ISR`/`ISRRate`, `VatRet`, `Total`, `BaseCurrency` (bool), `OriginalCurrencySubtotal`, `OriginalCurrencyDiscountAmount`, `IsPercentage` (bool), **`ContactEmails`**, `ExternalIDType` (int), `Comments`, y partidas **`Items[]`**: `ID`, `Code`, `ProductID`, `ProductName`, `Unit`, `Qty`, `Price`, `Amount`, `IEPS`, `VAT`, `IndexNumber`.
|
||||||
|
|
||||||
|
> ⚠️ **No hay campo que ligue la cotización con la factura generada** (ni `InvoiceID` en Quote, ni `QuoteID` en Invoice). "Surtida" dice que se convirtió, pero no *a qué* factura. Ver implicaciones (§9.2).
|
||||||
|
|
||||||
|
### 3.4 `Clients` — lista (10 campos) y detalle (28)
|
||||||
|
|
||||||
|
**Lista:** `ID` (guid), `Number` (int), `ClientName`, `LegalName`, `RFC`, `Email`, `Phone`, `NextContactDate`, `LocationID` (guid), `RegimenFiscal` (string).
|
||||||
|
|
||||||
|
**Detalle `Clients/{id}` agrega:**
|
||||||
|
|
||||||
|
| Campo | Tipo | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| `CommercialName` | string | |
|
||||||
|
| **`CreditDays`** | integer | **Días de crédito default del cliente** (los 30/45/90 del Discovery). |
|
||||||
|
| `CreditAmount` | decimal | Límite de crédito. |
|
||||||
|
| `PaymentMethod` | string | Forma de pago default (se observó "Efectivo"). |
|
||||||
|
| `PaymentTermType` | string | Puede venir vacío. |
|
||||||
|
| `Status` | string | "Activo"/… |
|
||||||
|
| `SalesContact` / `CreditContact` | string | Contactos comercial y de cobranza. |
|
||||||
|
| **`Loctaion` / `LoctaionID`** | string / guid | ⚠️ **Typo real del API** ("Loctaion", sic) — el cliente tipado debe usar el nombre con typo. |
|
||||||
|
| `PriceList` / `PriceListID` | string / guid | |
|
||||||
|
| `Email`, `Telephones` | string \| null | Correos configurados (los que usa el botón "enviar email" de BIND). |
|
||||||
|
| `AccountNumber`, `DefaultDiscount`, `ClientSource`, `Account` | varios | |
|
||||||
|
| `City`, `State`, `Addresses[]` | string / array | |
|
||||||
|
| `RegimenFiscal` | string | Régimen fiscal SAT. |
|
||||||
|
| `CreationDate` | datetime | |
|
||||||
|
|
||||||
|
> **No se observó** un campo "uso CFDI default por cliente" — el `CFDIUse` vive en la factura. La regla "gastos en general vs sin efectos fiscales" tendrá que derivarse de otra señal (p. ej. RFC extranjero/`XEXX010101000`, país, o configuración en la plataforma).
|
||||||
|
|
||||||
|
### 3.5 Catálogos
|
||||||
|
|
||||||
|
- **`Currencies`:** `ID` (guid), `Name`, `Code` (3 letras), `ExchangeRate` (decimal). 4 filas en la cuenta.
|
||||||
|
- **`Warehouses`:** `ID`, `Name` ("Matriz"), `LocationID`, `AvailableInOtherLoc` (bool). 1 fila — confirma el "hoy solo matriz" del Discovery.
|
||||||
|
- **`Locations`:** `ID`, `Name`, `Street`, `ExtNumber`, `IntNumber`, `ZipCode`, `Colonia`, `City`, `State`. 1 fila.
|
||||||
|
- **`Products`:** 30 campos, incl. `Code`, `Title`, `Cost`, `CostType(+Text)`, `CurrentInventory`, **`ChargeVAT`** (bool), `Unit`, `CurrencyID/Code`, `PricingType(+Text)`, `PurchaseType(+Text)`, `IEPSRate`, `Type(+Text)`, `SKU`, categorías.
|
||||||
|
|
||||||
|
### 3.6 Documentos del CFDI
|
||||||
|
|
||||||
|
| Endpoint | Estatus | Content-Type | Nota |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET /api/Invoices/{id}/pdf` | 200 | `application/pdf` | **PDF real descargable por API** — insumo directo del módulo de envío. |
|
||||||
|
| `GET /api/Invoices/{id}/xml` | 200 | `application/json` | Responde 200 pero como JSON — probablemente envuelve el XML o una URL. El contenido se descartó por política de no persistir datos; **shape pendiente** (§10). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 · La pregunta del saldo — veredicto
|
||||||
|
|
||||||
|
**Plan A (operativo). No se necesita Plan B ni Plan C.**
|
||||||
|
|
||||||
|
- No existe un campo literal `Balance`/`Saldo`, **pero** cada fila de `/api/Invoices` trae `Total`, **`Payments`** (acumulado pagado) y **`CreditNotes`** (acumulado de notas de crédito):
|
||||||
|
|
||||||
|
$$\text{SaldoPorFactura} = \text{Total} - \text{Payments} - \text{CreditNotes}$$
|
||||||
|
|
||||||
|
- **Verificación aritmética (en memoria, sin persistir montos):** en 5/5 facturas con `Status=1` (Pagada), `Payments + CreditNotes ≈ Total` (diferencia < 0.01); en 5/5 con `Status=0` (Activa), el residual es positivo. La fórmula cuadra en ambas poblaciones.
|
||||||
|
- Es "Plan A" en el sentido operativo del riesgo de la propuesta: **una sola llamada a `/api/Invoices` basta** para calcular saldo y aging de toda la cartera — no hay que correlacionar una colección de pagos (Plan B) ni capturar nada a mano (Plan C).
|
||||||
|
- Matiz honesto: BIND no expone el número ya restado; la resta la hace la plataforma. El costo es cero (mismos campos, misma fila).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 · Payments — el hallazgo duro
|
||||||
|
|
||||||
|
**No existe recurso consultable de pagos individuales.** Se probaron 17 nombres de colección y 3 sub-recursos (§2) — todos 404.
|
||||||
|
|
||||||
|
Lo que **sí** hay:
|
||||||
|
|
||||||
|
| Necesidad del MVP | ¿Cubierta? | Cómo |
|
||||||
|
|---|---|---|
|
||||||
|
| Saldo por factura | ✅ | `Total − Payments − CreditNotes` (§4). |
|
||||||
|
| ¿Factura pagada? | ✅ | `Status = 1` y/o residual ≈ 0. |
|
||||||
|
| Aging / vencimiento | ✅ | `ExpirationDate` + saldo. |
|
||||||
|
| **Fecha y monto de cada abono individual** | ❌ | No visible por API con este token. |
|
||||||
|
| Complementos de pago (REP) de facturas PPD | ❌ | Ningún recurso visible (`PaymentComplements`, `Complements` → 404). |
|
||||||
|
|
||||||
|
**Implicación:** el motor de cobranza puede detectar *que* una factura se pagó (transición de `Status`/residual entre sincronizaciones) y registrar el *timestamp de detección* en la plataforma, pero no la fecha valor del pago según BIND. Preguntar a Pedro/soporte BIND si existe un endpoint de pagos/REP no descubierto (la doc completa está tras login en developers.bind.com.mx) — ver §10.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 · OData y paginación
|
||||||
|
|
||||||
|
| Mecanismo | ¿Funciona? | Evidencia |
|
||||||
|
|---|---|---|
|
||||||
|
| `$top` | ✅ con tope | `$top=100` → 200; **`$top=101` → 500**. |
|
||||||
|
| `$skip` | ✅ | `$top=1&$skip=1` devuelve la fila siguiente (verificado por ID). |
|
||||||
|
| `$orderby` | ✅ | `Date asc` → orden ascendente verificado. |
|
||||||
|
| `$filter eq` (int) | ✅ | `Status eq 1`, `CFDIUse eq 3` → 200. |
|
||||||
|
| `$filter eq null` | ✅ | `UUID eq null` → 200 con filas. |
|
||||||
|
| `$filter ge` + fecha | ✅ **sintaxis v3** | `Date ge datetime'2020-01-01T00:00:00'` → 200. |
|
||||||
|
| `$select` | ❌ | → **500**. No se pueden proyectar columnas; el payload siempre viene completo. |
|
||||||
|
| `$inlinecount=allpages` (v3) | ❌ | → 500. |
|
||||||
|
| `$count=true` (v4) | ⚠️ | → 200 pero **ignorado**: no devuelve conteo. |
|
||||||
|
| `odata.nextLink` | ❌ | Nunca apareció. |
|
||||||
|
|
||||||
|
**Paginación:** no hay `nextLink` ni conteo total ⇒ **paginación manual** con `$top=100&$skip=N` hasta recibir página corta. ⚠️ `GET` sin `$top` devuelve la colección completa en una respuesta (se observó con una colección de 41 filas) — con colecciones grandes es un riesgo de payload; **siempre** paginar. Sondeo por `$skip` (sin descargar): la colección histórica de `Invoices` supera las 1,000 filas.
|
||||||
|
|
||||||
|
**Rate limit:** no se observó **ningún header** de cuota (`X-RateLimit-*`, `Retry-After` en 200s) — el límite de 20K/día no es observable por request; hay que llevarlo con contador local (como ya hace `BindClient`).
|
||||||
|
|
||||||
|
**Estabilidad:** ~3 respuestas `500` transitorias en 117 peticiones (resueltas al primer retry). El retry con backoff **no es opcional** en producción. Nota: BIND usa 500 también para errores de sintaxis OData y token inválido — distinguir por body/contexto antes de reintentar a ciegas.
|
||||||
|
|
||||||
|
**GET por ID:** estilo **REST** — `GET /api/Invoices/{id}` → 200; el estilo OData `Invoices(guid'...')` → **404**. (El cliente del sandbox asumía el estilo OData; ya se corrigió.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 · Multi-empresa
|
||||||
|
|
||||||
|
- `Companies` → 404; **ningún** recurso expone campo `Company`/`Empresa`.
|
||||||
|
- `Locations` y `Warehouses` devuelven **1 fila** (Matriz).
|
||||||
|
- Conclusión: **el token está acotado a la empresa del usuario que lo generó** (Arturo → Balam). La distinción multi-empresa del portal Jira (Balam/Regiotour/Elmstone, Discovery #27) **no viaja a BIND por este token**: para facturar otras empresas se necesitaría una cuenta BIND distinta con su propio token. Anotado para el roadmap — coherente con dejar multi-empresa fuera del MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8 · Hallazgos inesperados
|
||||||
|
|
||||||
|
1. **Token inválido → 500** (no 401/403), con mensaje `"API Key es inválida"` en el body. El 401 solo aparece cuando *falta* el header.
|
||||||
|
2. **`$select` no funciona** (500): no se puede reducir payload por columnas.
|
||||||
|
3. **Conteo total inaccesible**: `$inlinecount` truena (500) y `$count=true` se ignora — el total solo se conoce paginando hasta el final.
|
||||||
|
4. **No hay recurso de pagos** (17 nombres → 404) — el acumulado vive dentro de la factura (§5).
|
||||||
|
5. **Nomenclatura CFDI invertida respecto al SAT:** `CFDIPaymentTerm` = *Método de pago* SAT (PPD/PUE); `CFDIPaymentMethod` = *Forma de pago* SAT (transferencia, efectivo…). Cablearlo al revés rompería la validación de oro PPD/PUE.
|
||||||
|
6. **`CFDIUse` es un código interno** (enteros `3`, `23`), no la clave SAT (`G03`, `S01`…). Se necesita la tabla de mapeo (pedir a Pedro o doc tras login).
|
||||||
|
7. **Typo real en el API:** el detalle de `Clients` trae `Loctaion`/`LoctaionID` (sic).
|
||||||
|
8. **Inconsistencias lista vs detalle:** `Serie` (lista) vs `Series` (detalle); `Status` int (lista) vs `Status` string + `StatusCode` int (detalle); `CurrencyID` (lista) vs `CurrencyName` con el código (detalle); PPD/PUE y días de crédito **solo** en el detalle.
|
||||||
|
9. **Prefacturas visibles** en la misma colección `Invoices` (`UUID` null / `IsFiscalInvoice` false) — no hay recurso separado.
|
||||||
|
10. **`Activities` existe pero está vacío** en esta cuenta (0 filas) — el recurso que la doc pública usa de ejemplo no tiene datos aquí.
|
||||||
|
11. **500 transitorios** ocasionales que se resuelven con retry inmediato.
|
||||||
|
12. **Higiene de secretos:** el `.txt` del token estaba en la raíz del repo sin gitignorear (no trackeado aún) — se agregó `bind_token_api.txt` y `*.env` al `.gitignore` raíz. Recomendación vigente: moverlo a un gestor de secretos y borrarlo del correo/disco.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9 · Implicaciones para el MVP
|
||||||
|
|
||||||
|
Cruce contra el flujo objetivo del Discovery (#27): **Jira → cotización BIND → prefactura → validación humana → CFDI → envío**.
|
||||||
|
|
||||||
|
### 9.1 Lo que la API ya sostiene (solo lectura, hoy)
|
||||||
|
|
||||||
|
| Paso del flujo | Soporte confirmado |
|
||||||
|
|---|---|
|
||||||
|
| **Cotización** | `Quotes` legible con partidas, comercial (`EmployeeName`), moneda/TC y estatus (Activa/Cancelada/Surtida). La plataforma puede detectar cotizaciones nuevas y validar el prerequisito "cotización obligatoria" de Ara. |
|
||||||
|
| **Prefactura** | Listable vía `UUID eq null` / `IsFiscalInvoice eq false` → el dashboard "prefacturas pendientes de validación" es viable 100 % lectura. |
|
||||||
|
| **CFDI** | `UUID`, `Series`+`Number`, RFC, moneda, `ExchangeRate`, impuestos por partida (`Services[].VATRate`), uso CFDI (código), PPD/PUE (`CFDIPaymentTerm` en detalle), creador y fechas. |
|
||||||
|
| **Validaciones de oro** | • **PPD/PUE:** auditable por factura (detalle). La plataforma puede alertar "PUE detectado — ¿fue consciente?" apenas aparezca. • **IVA 16 %/0 %:** `VATRate` por partida + moneda + RFC → la regla "extranjero con IVA ≠ 0" (el error que Ara señaló en vivo) es detectable automáticamente. • **Días de crédito:** `Clients.CreditDays` (default) vs `PaymentTerms` (factura) — discrepancias detectables. |
|
||||||
|
| **Cobranza / aging** | `ExpirationDate` + saldo derivado (§4) + `Status` → aging y alertas internas sin recurso de pagos. |
|
||||||
|
| **Envío** | PDF real por API (`/{id}/pdf`) + correos del cliente (`Clients.Email`, `ContactEmails`) + las particularidades por cliente (Excel de Ara/Arturo) viven en la plataforma. |
|
||||||
|
|
||||||
|
### 9.2 Restricciones de diseño que impone lo encontrado
|
||||||
|
|
||||||
|
1. **Sync incremental obligatorio.** PPD/PUE y días de crédito viven en el **detalle** ⇒ 1 llamada por factura. Con ~55 facturas/mes es trivial, pero el histórico (>1,000) exige sincronizar por delta (`Date ge` la última corrida) y guardar en Postgres — nunca re-barrer todo el detalle.
|
||||||
|
2. **Trazabilidad cotización→factura la lleva la plataforma.** BIND no expone el vínculo; al orquestar la conversión (Etapa 2) la plataforma debe registrar el par `QuoteID→InvoiceID` en su propia BD (y/o convención en `Comments`, como hoy hacen con el ticket Jira).
|
||||||
|
3. **"Fecha de pago" = fecha de detección.** Sin pagos individuales, la plataforma registra cuándo *observó* el cambio a Pagada — suficiente para cobranza operativa; insuficiente para conciliación contable fina (que de todos modos es fase posterior).
|
||||||
|
4. **Catálogos internos a mapear:** `CFDIUse` (int→clave SAT) y códigos de `Status` no observados (3+). Confirmar con Pedro.
|
||||||
|
5. **Cliente HTTP:** paginar siempre (`$top=100`), retry en 500 transitorio, no usar `$select`, IDs estilo REST, contador local de cuota (sin headers de rate limit).
|
||||||
|
6. **Los complementos de pago (REP) no son visibles** — riesgo para el flujo PPD completo; escalar a Pedro (§10).
|
||||||
|
|
||||||
|
### 9.3 Presupuesto de peticiones (viabilidad del sync)
|
||||||
|
|
||||||
|
Escenario conservador: lista de facturas delta (1–2 req) + detalle solo de facturas nuevas/cambiadas (~3/día) + cotizaciones delta (1 req) + clientes delta (1 req) ⇒ **< 10 req por ciclo**. Con polling cada 15 min ≈ **~1,000 req/día**, 5 % del límite de 20K. Holgado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10 · Lo que quedó SIN validar y por qué
|
||||||
|
|
||||||
|
| Pendiente | Por qué no se validó | Cómo cerrarlo |
|
||||||
|
|---|---|---|
|
||||||
|
| **Escritura** (crear cotización/prefactura, convertir, emitir CFDI, cancelar) | **Prohibido en esta actividad**: cuenta de producción, sin sandbox, efecto fiscal. Regla dura de solo-GET. | Doc detallada tras login (developers.bind.com.mx) con Pedro; luego dry-run + confirmación humana en Etapa 2, empezando por un documento de prueba interno coordinado con Arturo. |
|
||||||
|
| Si la factura creada desde una cotización hereda alguna referencia a ésta | Requiere ejecutar la conversión (= escritura). | Mismo camino que el punto anterior; o preguntar a Arturo si la UI muestra el vínculo. |
|
||||||
|
| Shape real del `/{id}/xml` (¿XML embebido? ¿URL?) | El body se descartó por política de no persistir datos reales en esta corrida. | 1 GET dirigido leyendo solo las **claves** del JSON (sin valores), en la próxima sesión técnica. |
|
||||||
|
| Mapa completo `CFDIUse` interno → clave SAT | No hay catálogo expuesto; solo se observaron códigos `3` y `23`. | Pedir tabla a Pedro o doc tras login. |
|
||||||
|
| Códigos de `Status` > 2 (¿parciales, vencidas?) | Los filtros 3–5 no devolvieron filas: o no existen o no hay ejemplares en la cuenta. | Doc tras login; observar en operación. |
|
||||||
|
| **Complementos de pago (REP)** para PPD | Ningún recurso visible con los nombres probados. | **Crítico** — preguntar a Pedro/soporte BIND; el flujo PPD del MVP lo necesita al menos en lectura. |
|
||||||
|
| Rate limit real (20K/día) y comportamiento al agotarlo | No hay headers de cuota y agotar el límite adrede sería irresponsable en producción. | Aceptar el dato de Noe (20K) y llevar contador local. |
|
||||||
|
| Shape de `Users`, `Orders`, `Providers`, `Banks`, `BankAccounts`, `PriceLists` | Fuera del flujo del MVP; `Users` además contiene datos personales. | Cuando conciliación (Anexo B) lo requiera. |
|
||||||
|
| Webhooks / eventos push | No documentados públicamente; no sondeables por GET. | Preguntar a Pedro; mientras, polling incremental. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anexo · Reproducir la validación
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd bind-api-sandbox
|
||||||
|
# .env debe tener BIND_API_TOKEN (nunca se versiona; .gitignore lo cubre)
|
||||||
|
npm run validate:real # ronda 1: auth + inventario + shapes + OData + byId
|
||||||
|
npx tsx src/validate-real-api.ts --round2 # pagos, estatus, prefactura, detalles, paginación
|
||||||
|
npx tsx src/validate-real-api.ts --round3 # tope $top, literales CFDI, pdf/xml
|
||||||
|
npx tsx src/validate-real-api.ts --round4 # aritmética del saldo + xml
|
||||||
|
```
|
||||||
|
|
||||||
|
- Presupuesto acumulado entre rondas (`VALIDATION_BUDGET`, default 120). El script aborta al agotarlo.
|
||||||
|
- El reporte `validation-output/report.json` está sanitizado (solo estructura) y además gitignoreado por defensa en profundidad.
|
||||||
|
- El script es GET-only por construcción: no contiene ningún código capaz de emitir escrituras.
|
||||||
|
|
||||||
|
[REGISTRO #22]: ../bitacora/REGISTRO.md
|
||||||
|
[REGISTRO #29]: ../bitacora/REGISTRO.md
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"mock": "tsx src/mock-server/server.ts",
|
"mock": "tsx src/mock-server/server.ts",
|
||||||
"demo": "tsx src/demo.ts",
|
"demo": "tsx src/demo.ts",
|
||||||
"demo:prod": "BIND_BASE_URL=https://api.bind.com.mx 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"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,9 +6,22 @@
|
|||||||
* - dry-run: loguea el request que se haría sin enviarlo (útil para revisar
|
* - dry-run: loguea el request que se haría sin enviarlo (útil para revisar
|
||||||
* un payload antes de aprobarlo manualmente).
|
* un payload antes de aprobarlo manualmente).
|
||||||
* - Retries con backoff exponencial en 429 y 5xx (no en 4xx fuera de 429).
|
* - 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
|
* - 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+.
|
* - 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";
|
import { buildQueryString, type ODataQuery } from "./odata.js";
|
||||||
@@ -20,14 +33,35 @@ import type {
|
|||||||
Payment,
|
Payment,
|
||||||
Product,
|
Product,
|
||||||
} from "./types.js";
|
} 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";
|
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 {
|
export interface BindClientConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
subscriptionKey?: string;
|
subscriptionKey?: string;
|
||||||
mode?: ClientMode;
|
mode?: ClientMode;
|
||||||
|
/** Default "rest" (API real). Usa "odata" contra el mock local. */
|
||||||
|
idStyle?: IdStyle;
|
||||||
/** Máximo de reintentos para 429/5xx. */
|
/** Máximo de reintentos para 429/5xx. */
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
/** Logger opcional. Default: console. */
|
/** Logger opcional. Default: console. */
|
||||||
@@ -60,6 +94,7 @@ export class BindClient {
|
|||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
private readonly subscriptionKey?: string;
|
private readonly subscriptionKey?: string;
|
||||||
private readonly mode: ClientMode;
|
private readonly mode: ClientMode;
|
||||||
|
private readonly idStyle: IdStyle;
|
||||||
private readonly maxRetries: number;
|
private readonly maxRetries: number;
|
||||||
private readonly logger: Pick<Console, "info" | "warn" | "error">;
|
private readonly logger: Pick<Console, "info" | "warn" | "error">;
|
||||||
private readonly fetchImpl: typeof fetch;
|
private readonly fetchImpl: typeof fetch;
|
||||||
@@ -72,19 +107,78 @@ export class BindClient {
|
|||||||
this.apiKey = cfg.apiKey;
|
this.apiKey = cfg.apiKey;
|
||||||
this.subscriptionKey = cfg.subscriptionKey;
|
this.subscriptionKey = cfg.subscriptionKey;
|
||||||
this.mode = cfg.mode ?? "read-only";
|
this.mode = cfg.mode ?? "read-only";
|
||||||
|
this.idStyle = cfg.idStyle ?? "rest";
|
||||||
this.maxRetries = cfg.maxRetries ?? 3;
|
this.maxRetries = cfg.maxRetries ?? 3;
|
||||||
this.logger = cfg.logger ?? console;
|
this.logger = cfg.logger ?? console;
|
||||||
this.fetchImpl = cfg.fetchImpl ?? globalThis.fetch;
|
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>> {
|
customers(query: ODataQuery = {}): Promise<ODataCollection<Customer>> {
|
||||||
return this.get<ODataCollection<Customer>>(`/api/Customers${buildQueryString(query)}`);
|
return this.get<ODataCollection<Customer>>(`/api/Customers${buildQueryString(query)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
customer(id: string): Promise<Customer> {
|
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>> {
|
invoices(query: ODataQuery = {}): Promise<ODataCollection<Invoice>> {
|
||||||
@@ -92,7 +186,7 @@ export class BindClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
invoice(id: string): Promise<Invoice> {
|
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>> {
|
payments(query: ODataQuery = {}): Promise<ODataCollection<Payment>> {
|
||||||
@@ -133,6 +227,18 @@ export class BindClient {
|
|||||||
return this.request<T>("GET", path);
|
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> {
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
if (MUTATING.has(method) && this.mode === "read-only") {
|
if (MUTATING.has(method) && this.mode === "read-only") {
|
||||||
throw new BindReadOnlyViolation(method, path);
|
throw new BindReadOnlyViolation(method, path);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -20,9 +20,14 @@ import { and, eq, ge, guid, lt } from "./client/odata.js";
|
|||||||
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
baseUrl: process.env.BIND_BASE_URL ?? "http://localhost:4010",
|
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,
|
subscriptionKey: process.env.BIND_SUBSCRIPTION_KEY,
|
||||||
mode: (process.env.BIND_MODE as "read-only" | "dry-run" | "write") ?? "read-only",
|
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);
|
const client = new BindClient(cfg);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user