Reorganiza repo como fuente de la verdad + propuesta v1.0 con emisión de facturas

- Estructura nueva: README maestro, bitacora/ (REGISTRO, PENDIENTES, plantillas),
  propuesta/, fuentes/; material superado a _archivado/
- Propuesta v1.0: MVP BIND-first con emisión asistida MXN/USD (dry-run +
  confirmación, timbra PAC de BIND), 112-136 h / $67,200-$81,600 + IVA,
  stack .NET 10 + EF Core + Angular 21 + PostgreSQL 17 sobre Azure
- Bitácora: historial de correos + 2 llamadas (incl. revisión 4-jun) y pendientes
- Prototipo y diagrama actualizados a v1.0; precios de Azure verificados
- Archivo ajeno (proyecto EOS) retirado del repo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
JohannVelazquez
2026-06-04 10:57:27 -06:00
parent 404e6f3b89
commit 633d05e330
47 changed files with 32977 additions and 472 deletions
+22
View File
@@ -0,0 +1,22 @@
# --- Configuración del sandbox de BIND ---
#
# Por default el cliente apunta al mock local (puerto 4010).
# Cuando Pedro entregue el API key real, copia este archivo a .env y
# cambia BIND_BASE_URL a https://api.bind.com.mx + llena las credenciales.
#
# Recuerda: BIND solo tiene PRODUCCIÓN. Cualquier llamada con base URL real
# afecta datos reales de Balam. Mantén MODE=read-only mientras no haya
# autorización explícita para escribir.
BIND_BASE_URL=http://localhost:4010
BIND_API_KEY=mock-bearer-token
BIND_SUBSCRIPTION_KEY=mock-subscription-key
# read-only | dry-run | write
# - read-only: solo GET. Bloquea POST/PUT/PATCH/DELETE en el cliente.
# - dry-run: loguea el request que se haría pero no lo envía.
# - write: emite escrituras reales. Solo en mock o con autorización.
BIND_MODE=read-only
# Puerto del mock server
MOCK_PORT=4010
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+159
View File
@@ -0,0 +1,159 @@
# BIND ERP API · sandbox local
Sandbox para validar la integración del MVP **BIND-first** de Balam **sin tocar producción**.
Está pensado para que tú (Johann), Pedro (Balam) o un futuro dev puedan:
1. Entender la forma real del API de BIND ERP antes de tener el API key.
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.
---
## TL;DR del API de BIND (lo que descubrí del discovery)
| Tema | Hallazgo |
|---|---|
| **Base URL** | `https://api.bind.com.mx` |
| **Estilo** | REST con sintaxis **OData v3** (filtros `$filter`, `$top`, `$skip`, `$orderby`, `$count`, IDs como `guid'...'`) |
| **Auth** | Dos headers: `Authorization: Bearer <API_KEY>` + `Ocp-Apim-Subscription-Key: <SUBSCRIPTION_KEY>` (este último cuando aplica) |
| **Origen del API key** | Cuenta BIND → Perfil de usuario → pestaña *Integraciones* |
| **Rate limit** | **20,000 peticiones / día** (confirmado por Noe, 25-may-2026) |
| **Sandbox oficial** | **No existe.** BIND recomienda Postman contra producción → razón #1 de este sandbox |
| **Portal dev** | [developers.bind.com.mx](https://developers.bind.com.mx) (login requerido para ver schemas detallados) |
| **PAC para CFDI** | Integrado dentro del propio BIND — la plataforma de Balam **no toca el SAT**, solo orquesta |
| **Recursos confirmados** | `Activities`, `Customers`, `Products` (y según el discovery doc: `Invoices`, `Payments`, `Quotes` muy probables) |
### Por qué un sandbox propio y no Postman
- BIND solo tiene producción → cualquier `POST` real toca facturas reales con consecuencias fiscales.
- El contrato exacto está detrás de login → necesitamos un lugar donde ir **acumulando lo que aprendemos** del API real conforme Pedro nos dé acceso.
- Tener el contrato en código (TypeScript + tipos) hace que **el motor de cobranza del MVP se pueda probar con CI** sin depender de la red.
- Cuando llegue Belvo / BUK en Fase 2 esta misma estructura sirve como plantilla.
---
## Cómo correrlo
Requiere Node 20+.
```powershell
# 1) Instalar deps
cd bind-api-sandbox
npm install
# 2) Levantar el mock en una terminal
npm run mock
# -> [bind-mock] escuchando en http://localhost:4010
# 3) Correr la demo end-to-end en otra terminal
npm run demo
```
La demo ejecuta 5 escenarios alineados al MVP:
| # | Escenario | Qué demuestra |
|---|---|---|
| 1 | Listar clientes activos | Cómo se construye la query OData base del dashboard |
| 2 | Facturas vencidas (`Status eq 'overdue' and DueDate lt ...`) | El motor de cobranza |
| 3 | CxC agregada por cliente (MXN) | KPI directivo del dashboard |
| 4 | Factura USD a cliente extranjero | Regla sin-IVA + TC fijado al emitir |
| 5 | Intento de `POST /Activities` en modo read-only | El guardrail que evita escribir a BIND prod por accidente |
Output esperado (verificado):
```
── 2. Facturas vencidas — motor de cobranza
┌─────────┬──────────┬────────────┬──────────────┬──────────┬─────────┐
│ Folio │ Cliente │ DueDate │ Currency │ Balance │
│ 'A-0003' │ '22222222' │ '2026-04-01' │ 'MXN' │ 20880 │
│ 'A-0005' │ '11111111' │ '2026-05-20' │ 'MXN' │ 62640 │
── 5. Intento de escritura en read-only (debe BLOQUEARSE)
✅ Guardrail OK: Read-only mode bloqueó POST /api/Activities. Cambia BIND_MODE=write...
Stats del cliente
{ requestsToday: 6, quota: 20000, remaining: 19994, mode: 'read-only' }
```
### Apuntar a producción (cuando llegue el API key)
Solo cambiar variables de entorno — el código no se modifica:
```powershell
$env:BIND_BASE_URL = "https://api.bind.com.mx"
$env:BIND_API_KEY = "<key del perfil de usuario de Balam>"
$env:BIND_SUBSCRIPTION_KEY = "<si aplica>"
$env:BIND_MODE = "read-only" # mantenlo así hasta tener autorización para escribir
npm run demo
```
---
## Mapeo a la arquitectura de Balam
Este sandbox es el prototipo de lo que en el repo principal vivirá en `packages/integrations/bind/`:
```
balam/
└── packages/
└── integrations/
└── bind/
├── BindClient.ts ← este sandbox lo prototipa
├── types.ts ← este sandbox lo prototipa
├── odata.ts ← este sandbox lo prototipa
└── README.md
apps/
└── worker/
└── src/
└── modules/
└── bind-sync/ ← consume BindClient, escribe a Postgres,
respeta tenant_id + outbox pattern
```
El cliente está pensado para encajar con los principios del proyecto (ver `01 - ARQUITECTURA-TECNICA.md`):
- **Modo seguro por default** (`read-only`): bloquea `POST/PUT/PATCH/DELETE` en código. Cumple §1 "La plataforma no escribe a BIND en MVP".
- **Dry-run** opcional: imprime el request sin enviarlo (cumple §1 punto 2 "Modo dry-run disponible en cualquier acción con efecto externo").
- **Idempotencia preparada**: el método `addActivity` está aislado para que cuando se autorice escritura, sea fácil envolverlo con `idempotency_key`.
- **Quota awareness**: el cliente lleva un contador local de requests del día y avisa cuando se acerca al límite de 20K.
- **Retries con backoff** en 429 y 5xx, respetando `Retry-After`.
---
## Decisiones de discovery que este sandbox **acelera**
Estos son los puntos del `03_Anexo_Tecnico_Integraciones_Discovery_Balam.docx` y del `04_Checklist_Accesos_Datos_Dependencias_Balam.docx` que dejan de estar "pendientes de validar" en cuanto se ejecuta esta prueba con un API key real:
| Item del discovery | Cómo lo cierra este sandbox |
|---|---|
| Tipo de autenticación, headers requeridos | Ya implementado en `BindClient`: dos headers, listos para producción |
| Estructura de URLs por recurso | Confirmada (`/api/{Recurso}` + OData) y probada en mock |
| Operaciones de lectura: clientes, facturas, pagos | Demo las ejerce todas. Una vez con API key, basta correr `BIND_BASE_URL=https://api.bind.com.mx npm run demo` |
| Filtros y paginación (`$filter`, `$top`, `$skip`) | Validados contra mock con la misma sintaxis que documenta BIND |
| Estrategia de pruebas sin sandbox | **Esta es la respuesta**: mock local + cliente tipado + modos read-only / dry-run / write |
| Rate limits | El cliente cuenta requests; el mock simula `?simulate=throttle` para probar el backoff |
---
## Pendientes para cerrar con Pedro (sugerencia de mail)
> Pedro, para destrabar la integración con BIND necesitamos:
>
> 1. **API key** generado desde *Perfil → Integraciones* en la cuenta de Balam, idealmente con permisos **solo lectura** primero.
> 2. **Subscription Key** si el plan de Balam la requiere (algunos planes en Azure API Management la piden además del Bearer).
> 3. Confirmación del **plan contratado** — para saber si 20K req/día aplica o si está reducido.
> 4. **Schema exacto** del recurso `Invoices` (especialmente nombres de campos `UUID`, `Folio`, `Status`, `Balance`). Una llamada de ejemplo con una factura real anonimizada serviría: `GET /api/Invoices?$top=1`.
> 5. ¿Existe endpoint para descargar **XML/PDF del CFDI**? Por la doc parece que sí, pero falta confirmar la ruta exacta.
>
> Con (1)(2) podemos correr el sandbox apuntando a `https://api.bind.com.mx` y validar lo demás de un jalón sin necesidad de otra llamada.
---
## Limitaciones honestas de este sandbox
- **El schema de `Invoice`, `Customer`, etc. es una aproximación** — está modelado a partir de la doc pública y del flujo que necesita Balam, no del SDK oficial. Cuando salga el primer `GET` real contra producción, hay que reconciliar nombres de campos (especialmente capitalización y campos opcionales).
- **El mock acepta cualquier Bearer**, solo valida que exista. No es un servidor de auth real, es un placeholder.
- **El parser de OData del mock solo cubre lo que el cliente genera** (`eq, ne, gt, lt, ge, le, and, or`, paréntesis). No soporta `contains`, `startswith`, funciones, lambdas. Suficiente para el MVP.
- **No reproduce la lógica de `$expand`** — si BIND lo soporta para traer `Lines` o `Customer` embebidos, hay que extender.
Cuando alguno de estos límites se vuelva una piedra en el zapato, se extiende. Hoy es deliberadamente mínimo.
+569
View File
@@ -0,0 +1,569 @@
{
"name": "bind-api-sandbox",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bind-api-sandbox",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "bind-api-sandbox",
"private": true,
"version": "0.1.0",
"description": "Sandbox local del API de BIND ERP para validar la integración del MVP de Balam sin tocar producción.",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"mock": "tsx src/mock-server/server.ts",
"demo": "tsx src/demo.ts",
"demo:prod": "BIND_BASE_URL=https://api.bind.com.mx tsx src/demo.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.0"
}
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Cliente del API de BIND ERP.
*
* Decisiones:
* - Modo seguro por default (read-only): bloquea cualquier método mutante.
* - dry-run: loguea el request que se haría sin enviarlo (útil para revisar
* un payload antes de aprobarlo manualmente).
* - Retries con backoff exponencial en 429 y 5xx (no en 4xx fuera de 429).
* - Lleva contador local de requests para acercarse al límite de 20K/día
* con visibilidad temprana (cuota real la valida el servidor).
* - Sin dependencias externas — usa fetch nativo de Node 20+.
*/
import { buildQueryString, type ODataQuery } from "./odata.js";
import type {
Activity,
Customer,
Invoice,
ODataCollection,
Payment,
Product,
} from "./types.js";
export type ClientMode = "read-only" | "dry-run" | "write";
export interface BindClientConfig {
baseUrl: string;
apiKey: string;
subscriptionKey?: string;
mode?: ClientMode;
/** Máximo de reintentos para 429/5xx. */
maxRetries?: number;
/** Logger opcional. Default: console. */
logger?: Pick<Console, "info" | "warn" | "error">;
/** Inyectable para tests. Default: globalThis.fetch. */
fetchImpl?: typeof fetch;
}
export class BindApiError extends Error {
constructor(
public readonly status: number,
public readonly url: string,
public readonly body: unknown,
) {
super(`BIND API ${status} on ${url}`);
}
}
export class BindReadOnlyViolation extends Error {
constructor(method: string, path: string) {
super(`Read-only mode bloqueó ${method} ${path}. Cambia BIND_MODE=write si tienes autorización.`);
}
}
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const DAILY_QUOTA = 20_000;
export class BindClient {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly subscriptionKey?: string;
private readonly mode: ClientMode;
private readonly maxRetries: number;
private readonly logger: Pick<Console, "info" | "warn" | "error">;
private readonly fetchImpl: typeof fetch;
private requestCount = 0;
private dayBucket = currentDayBucket();
constructor(cfg: BindClientConfig) {
this.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
this.apiKey = cfg.apiKey;
this.subscriptionKey = cfg.subscriptionKey;
this.mode = cfg.mode ?? "read-only";
this.maxRetries = cfg.maxRetries ?? 3;
this.logger = cfg.logger ?? console;
this.fetchImpl = cfg.fetchImpl ?? globalThis.fetch;
}
// --- Recursos del MVP --------------------------------------------------
customers(query: ODataQuery = {}): Promise<ODataCollection<Customer>> {
return this.get<ODataCollection<Customer>>(`/api/Customers${buildQueryString(query)}`);
}
customer(id: string): Promise<Customer> {
return this.get<Customer>(`/api/Customers(guid'${id}')`);
}
invoices(query: ODataQuery = {}): Promise<ODataCollection<Invoice>> {
return this.get<ODataCollection<Invoice>>(`/api/Invoices${buildQueryString(query)}`);
}
invoice(id: string): Promise<Invoice> {
return this.get<Invoice>(`/api/Invoices(guid'${id}')`);
}
payments(query: ODataQuery = {}): Promise<ODataCollection<Payment>> {
return this.get<ODataCollection<Payment>>(`/api/Payments${buildQueryString(query)}`);
}
products(query: ODataQuery = {}): Promise<ODataCollection<Product>> {
return this.get<ODataCollection<Product>>(`/api/Products${buildQueryString(query)}`);
}
activities(query: ODataQuery = {}): Promise<ODataCollection<Activity>> {
return this.get<ODataCollection<Activity>>(`/api/Activities${buildQueryString(query)}`);
}
/**
* Escritura controlada: dejado disponible para cuando el discovery
* confirme que es seguro. Por default el mode bloquea el método.
*/
addActivity(activity: Omit<Activity, "ID" | "CreatedAt">): Promise<Activity> {
return this.request<Activity>("POST", "/api/Activities", activity);
}
// --- Estado / observabilidad ------------------------------------------
stats(): { requestsToday: number; quota: number; remaining: number; mode: ClientMode } {
this.rolloverIfNewDay();
return {
requestsToday: this.requestCount,
quota: DAILY_QUOTA,
remaining: Math.max(0, DAILY_QUOTA - this.requestCount),
mode: this.mode,
};
}
// --- Implementación HTTP ----------------------------------------------
private get<T>(path: string): Promise<T> {
return this.request<T>("GET", path);
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
if (MUTATING.has(method) && this.mode === "read-only") {
throw new BindReadOnlyViolation(method, path);
}
this.rolloverIfNewDay();
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
Accept: "application/json",
};
if (this.subscriptionKey) headers["Ocp-Apim-Subscription-Key"] = this.subscriptionKey;
if (body !== undefined) headers["Content-Type"] = "application/json";
if (this.mode === "dry-run" && MUTATING.has(method)) {
this.logger.info("[bind][dry-run]", method, url, body ?? "");
return undefined as T;
}
let lastErr: unknown;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
this.requestCount++;
const res = await this.fetchImpl(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.ok) {
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
const errBody = await safeJson(res);
if (res.status === 429 || res.status >= 500) {
if (attempt < this.maxRetries) {
const waitMs = backoffMs(attempt, res.headers.get("Retry-After"));
this.logger.warn(
`[bind] ${res.status} en ${path}, retry ${attempt + 1}/${this.maxRetries} en ${waitMs}ms`,
);
await sleep(waitMs);
continue;
}
}
throw new BindApiError(res.status, url, errBody);
} catch (err) {
lastErr = err;
if (err instanceof BindApiError) throw err;
if (attempt >= this.maxRetries) break;
await sleep(backoffMs(attempt, null));
}
}
throw lastErr ?? new Error("request failed");
}
private rolloverIfNewDay() {
const now = currentDayBucket();
if (now !== this.dayBucket) {
this.dayBucket = now;
this.requestCount = 0;
}
}
}
function backoffMs(attempt: number, retryAfter: string | null): number {
if (retryAfter) {
const secs = Number(retryAfter);
if (Number.isFinite(secs)) return secs * 1000;
}
return Math.min(1000 * 2 ** attempt, 8000) + Math.floor(Math.random() * 250);
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function safeJson(res: Response): Promise<unknown> {
try {
return await res.json();
} catch {
return null;
}
}
function currentDayBucket(): string {
return new Date().toISOString().slice(0, 10);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Helpers para construir queries OData que entiende el API de BIND.
*
* Sintaxis observable en la doc oficial:
* /api/Products?$filter=ID eq guid'bbe2cc0c-...'&$skip=0&$top=50&$orderby=Name asc
*
* Mantengo el builder muy chico — sólo lo que el MVP necesita.
*/
export type ODataFilter = string;
export interface ODataQuery {
filter?: ODataFilter;
top?: number;
skip?: number;
orderby?: string;
select?: string[];
count?: boolean;
}
export function buildQueryString(q: ODataQuery): string {
const parts: string[] = [];
if (q.filter) parts.push(`$filter=${encodeURIComponent(q.filter)}`);
if (typeof q.top === "number") parts.push(`$top=${q.top}`);
if (typeof q.skip === "number") parts.push(`$skip=${q.skip}`);
if (q.orderby) parts.push(`$orderby=${encodeURIComponent(q.orderby)}`);
if (q.select?.length) parts.push(`$select=${encodeURIComponent(q.select.join(","))}`);
if (q.count) parts.push(`$count=true`);
return parts.length ? `?${parts.join("&")}` : "";
}
/**
* Pequeño helper para escribir filtros legibles. No es un parser OData;
* solo escapa comillas simples y envuelve guids.
*
* Ejemplos:
* eq("ID", guid("bbe2...")) -> "ID eq guid'bbe2...'"
* eq("Status", "issued") -> "Status eq 'issued'"
* and(eq("Status","issued"), gt("Total", 1000))
*/
export const guid = (id: string): string => `guid'${id.replaceAll("'", "''")}'`;
export const str = (s: string): string => `'${s.replaceAll("'", "''")}'`;
export const eq = (field: string, value: string | number | boolean): string =>
`${field} eq ${formatValue(value)}`;
export const ne = (field: string, value: string | number | boolean): string =>
`${field} ne ${formatValue(value)}`;
export const gt = (field: string, value: string | number): string =>
`${field} gt ${formatValue(value)}`;
export const lt = (field: string, value: string | number): string =>
`${field} lt ${formatValue(value)}`;
export const ge = (field: string, value: string | number): string =>
`${field} ge ${formatValue(value)}`;
export const le = (field: string, value: string | number): string =>
`${field} le ${formatValue(value)}`;
export const and = (...parts: string[]): string => parts.join(" and ");
export const or = (...parts: string[]): string => `(${parts.join(" or ")})`;
function formatValue(v: string | number | boolean): string {
if (typeof v === "number" || typeof v === "boolean") return String(v);
// Si ya viene formateado como guid'...' o '...' (string OData), respétalo.
if (/^(guid'.*'|'.*')$/.test(v)) return v;
return str(v);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Tipos del dominio de BIND ERP, modelados a partir de la documentación pública
* y de la convención observable del API (OData-like sobre Azure API Management).
*
* Estos tipos son una aproximación: la documentación detallada vive detrás de
* login en developers.bind.com.mx. Cuando se obtenga el API key se deben
* reconciliar contra el schema real (especialmente nombres exactos de campos).
*/
export type Guid = string; // BIND identifica recursos como guid'...' en filtros OData.
export type IsoDate = string; // ISO 8601, ej. "2026-05-28T10:00:00Z"
export type Decimal = number; // En producción debe envolverse a decimal(18,4) en Balam.
export type Currency = "MXN" | "USD" | "EUR";
export interface Customer {
ID: Guid;
Code: string;
Name: string;
TaxId: string; // RFC en MX, TaxID/EIN en US.
Country: string;
Email: string | null;
Currency: Currency;
PaymentTerms: number | null; // Días de crédito.
IsActive: boolean;
CreatedAt: IsoDate;
UpdatedAt: IsoDate;
}
export interface Product {
ID: Guid;
Code: string;
Name: string;
UnitPrice: Decimal;
Currency: Currency;
SatCode: string | null; // Catálogo SAT (ClaveProdServ).
IsActive: boolean;
}
export type InvoiceStatus = "draft" | "issued" | "paid" | "partial" | "overdue" | "cancelled";
export interface InvoiceLine {
ProductID: Guid;
Description: string;
Quantity: Decimal;
UnitPrice: Decimal;
TaxRate: Decimal; // ej. 0.16 = IVA 16 %
Subtotal: Decimal;
Total: Decimal;
}
export interface Invoice {
ID: Guid;
Folio: string;
Serie: string;
UUID: string | null; // UUID del CFDI cuando ya fue timbrada por el PAC integrado de BIND.
CustomerID: Guid;
IssueDate: IsoDate;
DueDate: IsoDate;
Currency: Currency;
ExchangeRate: Decimal | null; // TC al momento de emisión (relevante para USD/EUR).
Subtotal: Decimal;
Taxes: Decimal;
Total: Decimal;
Balance: Decimal; // Saldo pendiente.
Status: InvoiceStatus;
Lines: InvoiceLine[];
XmlUrl: string | null;
PdfUrl: string | null;
}
export interface Payment {
ID: Guid;
InvoiceID: Guid;
PaymentDate: IsoDate;
Amount: Decimal;
Currency: Currency;
Method: "cash" | "transfer" | "card" | "check" | "other";
Reference: string | null;
}
export interface Activity {
ID: Guid;
CustomerID: Guid | null;
InvoiceID: Guid | null;
Type: string;
Subject: string;
Notes: string | null;
CreatedAt: IsoDate;
CreatedBy: string;
}
/**
* Respuesta paginada estilo OData v3/v4: la API responde con
* { value: [...], "odata.count"?: number, "odata.nextLink"?: string }.
* Modelamos solo lo que necesita el cliente.
*/
export interface ODataCollection<T> {
value: T[];
count?: number;
nextLink?: string;
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Demo end-to-end: cinco escenarios que tocan los casos críticos del MVP
* de Balam (BIND-first) tal como salieron en el discovery.
*
* 1. Listar clientes activos -> base del dashboard
* 2. Filtrar facturas vencidas (cobranza) -> motor de recordatorios
* 3. Calcular CxC por cliente -> KPI directivo
* 4. Detectar factura USD a cliente extranjero -> regla sin IVA + TC
* 5. Intentar una escritura en modo read-only -> demuestra el guardrail
*
* Por default apunta al mock local. Para correrlo contra producción:
* BIND_BASE_URL=https://api.bind.com.mx \
* BIND_API_KEY=<perfil-usuario/integraciones> \
* BIND_SUBSCRIPTION_KEY=<si-aplica> \
* pnpm demo
*/
import { BindClient, BindReadOnlyViolation } from "./client/BindClient.js";
import { and, eq, ge, guid, lt } from "./client/odata.js";
const cfg = {
baseUrl: process.env.BIND_BASE_URL ?? "http://localhost:4010",
apiKey: process.env.BIND_API_KEY ?? "mock-bearer-token",
subscriptionKey: process.env.BIND_SUBSCRIPTION_KEY,
mode: (process.env.BIND_MODE as "read-only" | "dry-run" | "write") ?? "read-only",
};
const client = new BindClient(cfg);
async function main() {
banner(`BIND API sandbox — modo: ${cfg.mode} · base: ${cfg.baseUrl}`);
// ── 1. Clientes activos ───────────────────────────────────────────────
step("1. Listar clientes activos (sería el seed del dashboard)");
const active = await client.customers({
filter: eq("IsActive", true),
orderby: "Name asc",
top: 50,
count: true,
});
console.table(
active.value.map((c) => ({
Code: c.Code,
Name: c.Name,
Currency: c.Currency,
Country: c.Country,
Terms: c.PaymentTerms,
})),
);
// ── 2. Facturas vencidas ──────────────────────────────────────────────
step("2. Facturas vencidas — motor de cobranza");
const today = new Date().toISOString();
const overdue = await client.invoices({
filter: and(eq("Status", "overdue"), lt("DueDate", `'${today}'`)),
orderby: "DueDate asc",
});
console.table(
overdue.value.map((i) => ({
Folio: `${i.Serie}-${i.Folio}`,
Cliente: shortId(i.CustomerID),
DueDate: i.DueDate.slice(0, 10),
Currency: i.Currency,
Balance: i.Balance,
})),
);
// ── 3. Aging por cliente (cuentas por cobrar) ─────────────────────────
step("3. CxC por cliente (sólo MXN para simplificar el demo)");
const open = await client.invoices({
filter: and(eq("Currency", "MXN"), ge("Balance", 0.01)),
});
const byCustomer = new Map<string, number>();
for (const inv of open.value) {
byCustomer.set(inv.CustomerID, (byCustomer.get(inv.CustomerID) ?? 0) + inv.Balance);
}
const customersIndex = new Map(
(await client.customers({ top: 200 })).value.map((c) => [c.ID, c.Name]),
);
console.table(
[...byCustomer.entries()].map(([id, total]) => ({
Cliente: customersIndex.get(id) ?? id,
CxC_MXN: total.toFixed(2),
})),
);
// ── 4. Facturas USD a cliente extranjero ──────────────────────────────
step("4. Facturas USD — validar regla sin-IVA + tipo de cambio fijado");
const usd = await client.invoices({ filter: eq("Currency", "USD") });
for (const inv of usd.value) {
const customer = await client.customer(inv.CustomerID);
console.log(
` ${inv.Serie}-${inv.Folio} cliente=${customer.Name} (${customer.Country}) total=$${inv.Total} USD TC=${inv.ExchangeRate ?? "—"} IVA=${inv.Taxes}`,
);
}
// ── 5. Guardrail de escritura ─────────────────────────────────────────
step("5. Intento de escritura en read-only (debe BLOQUEARSE)");
try {
await client.addActivity({
CustomerID: active.value[0]!.ID,
InvoiceID: null,
Type: "note",
Subject: "Prueba desde sandbox",
Notes: null,
CreatedBy: "demo",
});
console.log(" ⚠️ La escritura PASÓ — revisar BIND_MODE.");
} catch (err) {
if (err instanceof BindReadOnlyViolation) {
console.log(` ✅ Guardrail OK: ${err.message}`);
} else {
throw err;
}
}
// ── Resumen ───────────────────────────────────────────────────────────
banner("Stats del cliente");
console.log(client.stats());
// Caso opcional: si se pasa --invoice <guid> bajamos el documento.
const wantInvoice = process.argv.find((a) => a.startsWith("--invoice="));
if (wantInvoice) {
const id = wantInvoice.split("=")[1]!;
step(`Lookup directo: /api/Invoices(${guid(id)})`);
console.log(await client.invoice(id));
}
}
function banner(s: string) {
console.log(`\n${"═".repeat(72)}\n${s}\n${"═".repeat(72)}`);
}
function step(s: string) {
console.log(`\n── ${s}`);
}
function shortId(id: string): string {
return id.slice(0, 8);
}
main().catch((err) => {
console.error("Demo falló:", err);
process.exit(1);
});
@@ -0,0 +1,261 @@
/**
* Datos sintéticos que imitan lo que BIND devolvería para una cuenta
* del tamaño de Balam (45 colaboradores + 5 freelancers, ~50 facturas/mes).
*
* Nada de esto es información real de Balam. Solo cumple con la *forma*
* del payload para que el cliente y los handlers se validen.
*/
import type {
Activity,
Customer,
Invoice,
InvoiceLine,
Payment,
Product,
} from "../../client/types.js";
export const customers: Customer[] = [
{
ID: "11111111-1111-1111-1111-111111111111",
Code: "ACU-001",
Name: "Acuntia México SA de CV",
TaxId: "ACU010203AB1",
Country: "MX",
Email: "facturacion@acuntia.example",
Currency: "MXN",
PaymentTerms: 30,
IsActive: true,
CreatedAt: "2024-01-15T10:00:00Z",
UpdatedAt: "2026-04-01T10:00:00Z",
},
{
ID: "22222222-2222-2222-2222-222222222222",
Code: "CLI-002",
Name: "TechMex Innovaciones SAPI",
TaxId: "TMI150301CD2",
Country: "MX",
Email: "ap@techmex.example",
Currency: "MXN",
PaymentTerms: 45,
IsActive: true,
CreatedAt: "2024-03-20T10:00:00Z",
UpdatedAt: "2026-04-15T10:00:00Z",
},
{
ID: "33333333-3333-3333-3333-333333333333",
Code: "CLI-003",
Name: "Norteamericana Logistics LLC",
TaxId: "98-7654321",
Country: "US",
Email: "billing@norteam.example",
Currency: "USD",
PaymentTerms: 60,
IsActive: true,
CreatedAt: "2025-02-10T10:00:00Z",
UpdatedAt: "2026-05-01T10:00:00Z",
},
{
ID: "44444444-4444-4444-4444-444444444444",
Code: "CLI-004",
Name: "Distribuidora del Golfo SA",
TaxId: "DGO180815EF3",
Country: "MX",
Email: "pagos@dgolfo.example",
Currency: "MXN",
PaymentTerms: 30,
IsActive: true,
CreatedAt: "2025-06-01T10:00:00Z",
UpdatedAt: "2026-05-10T10:00:00Z",
},
{
ID: "55555555-5555-5555-5555-555555555555",
Code: "CLI-005",
Name: "Servicios Estratégicos del Norte",
TaxId: "SEN200401GH4",
Country: "MX",
Email: "tesoreria@sen.example",
Currency: "MXN",
PaymentTerms: 15,
IsActive: false,
CreatedAt: "2025-08-12T10:00:00Z",
UpdatedAt: "2026-03-22T10:00:00Z",
},
];
export const products: Product[] = [
{
ID: "a1111111-1111-1111-1111-111111111111",
Code: "SVC-CONSULT",
Name: "Consultoría estratégica · hora",
UnitPrice: 1800,
Currency: "MXN",
SatCode: "80101504",
IsActive: true,
},
{
ID: "a2222222-2222-2222-2222-222222222222",
Code: "SVC-RECRUIT",
Name: "Búsqueda de talento ejecutivo",
UnitPrice: 45000,
Currency: "MXN",
SatCode: "80111501",
IsActive: true,
},
];
function line(product: Product, qty: number, taxRate: number): InvoiceLine {
const subtotal = round2(qty * product.UnitPrice);
const total = round2(subtotal * (1 + taxRate));
return {
ProductID: product.ID,
Description: product.Name,
Quantity: qty,
UnitPrice: product.UnitPrice,
TaxRate: taxRate,
Subtotal: subtotal,
Total: total,
};
}
export const invoices: Invoice[] = [
// 1) Pagada
{
ID: "f0000001-0000-0000-0000-000000000001",
Folio: "0001",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0001",
CustomerID: customers[1]!.ID,
IssueDate: "2026-03-01T10:00:00Z",
DueDate: "2026-04-15T10:00:00Z",
Currency: "MXN",
ExchangeRate: null,
Subtotal: 90000,
Taxes: 14400,
Total: 104400,
Balance: 0,
Status: "paid",
Lines: [line(products[1]!, 2, 0.16)],
XmlUrl: "https://api.bind.com.mx/api/Invoices/f0000001/xml",
PdfUrl: "https://api.bind.com.mx/api/Invoices/f0000001/pdf",
},
// 2) Vigente
{
ID: "f0000002-0000-0000-0000-000000000002",
Folio: "0002",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0002",
CustomerID: customers[3]!.ID,
IssueDate: "2026-05-10T10:00:00Z",
DueDate: "2026-06-09T10:00:00Z",
Currency: "MXN",
ExchangeRate: null,
Subtotal: 36000,
Taxes: 5760,
Total: 41760,
Balance: 41760,
Status: "issued",
Lines: [line(products[0]!, 20, 0.16)],
XmlUrl: null,
PdfUrl: null,
},
// 3) Vencida — caso cobranza
{
ID: "f0000003-0000-0000-0000-000000000003",
Folio: "0003",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0003",
CustomerID: customers[1]!.ID,
IssueDate: "2026-02-15T10:00:00Z",
DueDate: "2026-04-01T10:00:00Z",
Currency: "MXN",
ExchangeRate: null,
Subtotal: 18000,
Taxes: 2880,
Total: 20880,
Balance: 20880,
Status: "overdue",
Lines: [line(products[0]!, 10, 0.16)],
XmlUrl: null,
PdfUrl: null,
},
// 4) USD a cliente Texas — sin IVA (exportación)
{
ID: "f0000004-0000-0000-0000-000000000004",
Folio: "0004",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0004",
CustomerID: customers[2]!.ID,
IssueDate: "2026-05-20T10:00:00Z",
DueDate: "2026-07-19T10:00:00Z",
Currency: "USD",
ExchangeRate: 17.85,
Subtotal: 12500,
Taxes: 0,
Total: 12500,
Balance: 12500,
Status: "issued",
Lines: [
{
ProductID: products[1]!.ID,
Description: products[1]!.Name,
Quantity: 1,
UnitPrice: 12500,
TaxRate: 0,
Subtotal: 12500,
Total: 12500,
},
],
XmlUrl: null,
PdfUrl: null,
},
// 5) Cliente estratégico — ACUNTIA (no debe recibir recordatorio auto)
{
ID: "f0000005-0000-0000-0000-000000000005",
Folio: "0005",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0005",
CustomerID: customers[0]!.ID,
IssueDate: "2026-04-20T10:00:00Z",
DueDate: "2026-05-20T10:00:00Z",
Currency: "MXN",
ExchangeRate: null,
Subtotal: 54000,
Taxes: 8640,
Total: 62640,
Balance: 62640,
Status: "overdue",
Lines: [line(products[0]!, 30, 0.16)],
XmlUrl: null,
PdfUrl: null,
},
];
export const payments: Payment[] = [
{
ID: "p0000001-0000-0000-0000-000000000001",
InvoiceID: invoices[0]!.ID,
PaymentDate: "2026-04-10T10:00:00Z",
Amount: 104400,
Currency: "MXN",
Method: "transfer",
Reference: "SPEI 7XX9-2026-04-10",
},
];
export const activities: Activity[] = [
{
ID: "ac000001-0000-0000-0000-000000000001",
CustomerID: customers[1]!.ID,
InvoiceID: invoices[2]!.ID,
Type: "reminder",
Subject: "Recordatorio enviado a TechMex (vencida 30 días)",
Notes: "Plantilla cobranza-vencida-30d",
CreatedAt: "2026-04-05T16:00:00Z",
CreatedBy: "balam-collections-bot",
},
];
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
@@ -0,0 +1,187 @@
/**
* Mini-evaluador de filtros OData para el mock.
*
* NO es un parser completo de OData — soporta sólo lo que el cliente del MVP
* genera con los helpers de src/client/odata.ts:
*
* - `Field eq 'value'` / `Field eq guid'...'` / `Field eq 123`
* - `Field ne | gt | lt | ge | le ...`
* - cadenas con AND/OR y paréntesis simples
*
* Suficiente para validar end-to-end que el cliente arma URLs correctas.
*/
type Op = "eq" | "ne" | "gt" | "lt" | "ge" | "le";
type Value = string | number | boolean | null;
interface Comparison {
kind: "cmp";
field: string;
op: Op;
value: Value;
}
interface And {
kind: "and";
left: Node;
right: Node;
}
interface Or {
kind: "or";
left: Node;
right: Node;
}
type Node = Comparison | And | Or;
export function evalFilter<T extends Record<string, unknown>>(
filter: string | undefined,
row: T,
): boolean {
if (!filter) return true;
const node = parse(tokenize(filter));
return run(node, row);
}
// --- tokenizer ---------------------------------------------------------
type Token = { type: "ident" | "op" | "value" | "lparen" | "rparen" | "and" | "or"; v: string };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < input.length) {
const c = input[i]!;
if (c === " ") {
i++;
continue;
}
if (c === "(") {
tokens.push({ type: "lparen", v: "(" });
i++;
continue;
}
if (c === ")") {
tokens.push({ type: "rparen", v: ")" });
i++;
continue;
}
if (c === "'") {
let j = i + 1;
let s = "";
while (j < input.length) {
if (input[j] === "'" && input[j + 1] === "'") {
s += "'";
j += 2;
} else if (input[j] === "'") {
break;
} else {
s += input[j];
j++;
}
}
tokens.push({ type: "value", v: s });
i = j + 1;
continue;
}
// guid'...'
if (input.startsWith("guid'", i)) {
const end = input.indexOf("'", i + 5);
tokens.push({ type: "value", v: input.slice(i + 5, end) });
i = end + 1;
continue;
}
// identifier / op / and / or / number / bool
const m = /^[A-Za-z_][A-Za-z0-9_]*|^-?\d+(\.\d+)?/.exec(input.slice(i));
if (!m) throw new Error(`No puedo tokenizar en pos ${i}: ${input.slice(i)}`);
const raw = m[0];
i += raw.length;
if (/^-?\d/.test(raw)) {
tokens.push({ type: "value", v: raw });
continue;
}
const lower = raw.toLowerCase();
if (lower === "and") tokens.push({ type: "and", v: "and" });
else if (lower === "or") tokens.push({ type: "or", v: "or" });
else if (lower === "true" || lower === "false") tokens.push({ type: "value", v: lower });
else if (["eq", "ne", "gt", "lt", "ge", "le"].includes(lower))
tokens.push({ type: "op", v: lower });
else tokens.push({ type: "ident", v: raw });
}
return tokens;
}
// --- parser (precedencia: paréntesis > and > or) -----------------------
function parse(tokens: Token[]): Node {
let pos = 0;
const peek = () => tokens[pos];
const eat = () => tokens[pos++]!;
function parseOr(): Node {
let left = parseAnd();
while (peek()?.type === "or") {
eat();
left = { kind: "or", left, right: parseAnd() };
}
return left;
}
function parseAnd(): Node {
let left = parseAtom();
while (peek()?.type === "and") {
eat();
left = { kind: "and", left, right: parseAtom() };
}
return left;
}
function parseAtom(): Node {
const t = eat();
if (t.type === "lparen") {
const inner = parseOr();
if (eat().type !== "rparen") throw new Error("Falta )");
return inner;
}
if (t.type !== "ident") throw new Error(`Esperaba identificador, recibí ${t.type}`);
const opTok = eat();
if (opTok.type !== "op") throw new Error(`Esperaba operador después de ${t.v}`);
const valTok = eat();
if (valTok.type !== "value") throw new Error(`Esperaba valor después de ${opTok.v}`);
return {
kind: "cmp",
field: t.v,
op: opTok.v as Op,
value: coerce(valTok.v),
};
}
return parseOr();
}
function coerce(raw: string): Value {
if (raw === "true") return true;
if (raw === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
return raw;
}
function run(node: Node, row: Record<string, unknown>): boolean {
if (node.kind === "and") return run(node.left, row) && run(node.right, row);
if (node.kind === "or") return run(node.left, row) || run(node.right, row);
const left = row[node.field];
const right = node.value;
switch (node.op) {
case "eq":
return left == right;
case "ne":
return left != right;
case "gt":
return (left as number) > (right as number);
case "lt":
return (left as number) < (right as number);
case "ge":
return (left as number) >= (right as number);
case "le":
return (left as number) <= (right as number);
default: {
const _exhaustive: never = node.op;
return _exhaustive;
}
}
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Mock server que imita api.bind.com.mx para desarrollo y pruebas.
*
* - Valida los headers que documenta BIND (Authorization: Bearer + opcionalmente
* Ocp-Apim-Subscription-Key).
* - Soporta $filter, $top, $skip, $orderby, $count sobre las colecciones de seed.
* - Devuelve respuestas en el formato OData v3-ish:
* { value: [...], "odata.count": N }
* - Inyecta latencia y 429 ocasional vía query (?simulate=throttle|slow) para
* ejercitar el cliente.
*
* Por qué un mock y no Postman/Wiremock: el contrato de BIND no es público en
* detalle, así que necesitamos un sandbox que evolucione con lo que vayamos
* descubriendo del API real sin pagar por una herramienta extra.
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { URL } from "node:url";
import { activities, customers, invoices, payments, products } from "./data/seed.js";
import { evalFilter } from "./odata-filter.js";
const PORT = Number(process.env.MOCK_PORT ?? 4010);
interface Collection<T> {
rows: T[];
byIdField?: keyof T;
}
const COLLECTIONS: Record<string, Collection<any>> = {
Customers: { rows: customers, byIdField: "ID" },
Invoices: { rows: invoices, byIdField: "ID" },
Payments: { rows: payments, byIdField: "ID" },
Products: { rows: products, byIdField: "ID" },
Activities: { rows: activities, byIdField: "ID" },
};
const server = createServer(async (req, res) => {
try {
await handle(req, res);
} catch (err) {
sendJson(res, 500, { error: String((err as Error).message) });
}
});
async function handle(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
if (url.pathname === "/health") return sendJson(res, 200, { ok: true });
// Validación de headers tipo BIND
const auth = req.headers["authorization"];
if (!auth || !String(auth).toLowerCase().startsWith("bearer ")) {
return sendJson(res, 401, {
error: { code: "Unauthorized", message: "Missing Authorization: Bearer <api-key>" },
});
}
// Subscription key es opcional según la doc, pero si la mandan validamos shape.
const sub = req.headers["ocp-apim-subscription-key"];
if (sub !== undefined && String(sub).length < 5) {
return sendJson(res, 401, {
error: { code: "Unauthorized", message: "Subscription key inválida" },
});
}
// Simulación de fallas para ejercitar el cliente
const simulate = url.searchParams.get("simulate");
if (simulate === "throttle") {
res.setHeader("Retry-After", "1");
return sendJson(res, 429, {
error: { code: "TooManyRequests", message: "Cuota diaria excedida (simulado)" },
});
}
if (simulate === "slow") {
await new Promise((r) => setTimeout(r, 1500));
}
// Rutas: /api/{Recurso} y /api/{Recurso}(guid'...')
const m = /^\/api\/([A-Za-z]+)(?:\(guid'([^']+)'\))?\/?$/.exec(url.pathname);
if (!m) return sendJson(res, 404, { error: { code: "NotFound", path: url.pathname } });
const [, resource, id] = m;
const col = COLLECTIONS[resource!];
if (!col) return sendJson(res, 404, { error: { code: "ResourceNotFound", resource } });
if (req.method === "GET" && id) {
const row = col.rows.find((r) => r[col.byIdField!] === id);
if (!row) return sendJson(res, 404, { error: { code: "NotFound", id } });
return sendJson(res, 200, row);
}
if (req.method === "GET") {
const $filter = url.searchParams.get("$filter") ?? undefined;
const $top = parseIntOr(url.searchParams.get("$top"), col.rows.length);
const $skip = parseIntOr(url.searchParams.get("$skip"), 0);
const $orderby = url.searchParams.get("$orderby") ?? undefined;
const $count = url.searchParams.get("$count") === "true";
let rows = col.rows.filter((r) => evalFilter($filter, r));
if ($orderby) rows = applyOrderBy(rows, $orderby);
const totalCount = rows.length;
rows = rows.slice($skip, $skip + $top);
const body: { value: unknown[]; "odata.count"?: number } = { value: rows };
if ($count) body["odata.count"] = totalCount;
return sendJson(res, 200, body);
}
if (req.method === "POST" && resource === "Activities" && !id) {
const payload = await readJson(req);
const created = {
ID: cryptoRandomGuid(),
CreatedAt: new Date().toISOString(),
...payload,
};
activities.push(created as any);
return sendJson(res, 201, created);
}
return sendJson(res, 405, { error: { code: "MethodNotAllowed", method: req.method } });
}
function parseIntOr(raw: string | null, fallback: number): number {
if (raw === null) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
function applyOrderBy<T extends Record<string, unknown>>(rows: T[], orderby: string): T[] {
const [field, dir] = orderby.trim().split(/\s+/);
const sign = dir?.toLowerCase() === "desc" ? -1 : 1;
return [...rows].sort((a, b) => {
const av = a[field!] as any;
const bv = b[field!] as any;
if (av < bv) return -1 * sign;
if (av > bv) return 1 * sign;
return 0;
});
}
async function readJson(req: IncomingMessage): Promise<any> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const raw = Buffer.concat(chunks).toString("utf8");
return raw ? JSON.parse(raw) : {};
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function cryptoRandomGuid(): string {
// Suficiente para mock. En prod BIND emite sus propios IDs.
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
server.listen(PORT, () => {
console.log(`[bind-mock] escuchando en http://localhost:${PORT}`);
console.log("[bind-mock] recursos: /api/Customers /api/Invoices /api/Payments /api/Products /api/Activities");
console.log("[bind-mock] auth requerida: Authorization: Bearer <cualquier-cosa>");
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"types": ["node"],
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}