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:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -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.
|
||||
Generated
+569
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>");
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user