Initial commit: sap-webhook-api

API NestJS que recibe el Webhook Messenger de SAP Business One
(CloudEvents), valida HMAC opcional y guarda cada notificacion
local (data/events/) e inserta en Postgres (webhook_events).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-09-16 07:41:25 -05:00
commit 5e0df73477
28 files changed
+11773

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
# Del alta de una Orden al archivo JSON
Cómo viaja un evento de SAP Business One hasta quedar guardado en disco por
`sap-webhook-api`: los dos servidores involucrados, sus componentes,
puertos e IPs de ejemplo, y dónde entra en juego el certificado
autofirmado.
## Diagrama
```mermaid
flowchart LR
subgraph SAP["SAP Business One — servidor Windows · 192.168.1.205"]
direction TB
SL["Service Layer\nsap10:50000\nLogin · EventSubscriptions\ncert propio autofirmado"]
SQL["SQL Server (MSSQL)\nDB: SBODemoCL\nTablas: OEVC · OEVN · EVN1\nlogin: sa"]
WM["Webhook Messenger\npuerto 40008\nWebhookMessenger.jar\nDEFAULT_LANGUAGE=us_english (workaround)"]
SL <-->|"Login / EventSubscriptions"| SQL
SQL <-->|"SELECT/UPDATE OEVN, EVN1 (login sa)"| WM
end
subgraph MAC["Mac de desarrollo · 192.168.1.166"]
direction TB
CFG["Configuración\ncurl / sl.http"]
API["sap-webhook-api (NestJS)\n192.168.1.166:3000\nPOST /webhooks/sap\ncert CN/SAN=192.168.1.166"]
FS["data/events/*.json\n1 archivo por notificación"]
API -->|guarda| FS
end
CFG -- "HTTPS :50000\nLogin, EventSubscriptions" --> SL
WM -- "HTTPS :3000\nCloudEvents batch\nPOST /webhooks/sap" --> API
classDef warn fill:#faf0dd,stroke:#a8690b,color:#7a4c08;
class WM warn
```
Dos servidores en la misma LAN. La Mac llama al Service Layer para
configurar la suscripción (flecha superior); el Webhook Messenger — que
corre dentro del servidor SAP y lee la cola de eventos en SQL Server
autenticado como `sa` — es quien llama de vuelta a la Mac para entregar
cada evento (flecha inferior). El recuadro en ámbar marca el workaround de
`DATEFORMAT` aplicado para que esa segunda flecha funcione.
## Componentes e IPs de ejemplo
| Componente | Host : puerto | Protocolo | Notas |
|---|---|---|---|
| **Service Layer** (SAP Business One) | `sap10 : 50000` | HTTPS | Certificado propio autofirmado; nuestras llamadas lo ignoran con `curl -k`. |
| **Webhook Messenger** (servicio Windows) | `192.168.1.205 : 40008` | TCP (health) + JDBC a SQL Server | Se conecta a SQL Server como `sa`; despacha los eventos por HTTPS hacia el `WebhookURL` suscripto. |
| **SQL Server** (MSSQL) | — (interno) | TDS | DB `SBODemoCL`. Tablas `OEVC` / `OEVN` / `EVN1`. |
| **sap-webhook-api** (NestJS) | `192.168.1.166 : 3000` | HTTPS | Certificado autofirmado propio, `CN/SAN = 192.168.1.166`. |
| **data/events/** (filesystem local) | — (disco) | — | Un archivo `.json` por notificación recibida. |
## 🔐 Certificado autofirmado
Generado en la Mac para `CN/SAN = 192.168.1.166` — la IP donde corre
`sap-webhook-api`, no la del servidor SAP.
Ese nombre **tiene que coincidir** con el host que se declara como
`WebhookURL` al crear la suscripción
(`https://192.168.1.166:3000/webhooks/sap`). Como la suscripción usa
`VerifyCertificate: tNO`, SAP nunca valida este certificado — si más
adelante se activa la verificación, hay que importarlo además en el
keystore del propio Webhook Messenger.
## 🟠 Estado del bug de fechas
El Webhook Messenger construye el SQL contra `OEVN` con la fecha como
texto plano. Con el login `sa` en idioma `Español` (`DATEFORMAT dmy`),
cualquier día del mes mayor a 12 rompía la consulta.
Workaround confirmado en este ambiente de desarrollo: cambiar `sa` a
`DEFAULT_LANGUAGE = us_english` y reiniciar el servicio. Sigue pendiente
confirmar si `FP2605 HFX1` lo corrige oficialmente.
---
Detalle completo del diagnóstico, la evidencia y los pasos de instalación
en `CLAUDE.md` y `PASOS-REALIZADOS.md` de este repositorio.
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
# Cómo ejecutar la API (con y sin HMAC)
Guía rápida para levantar `sap-webhook-api` en los dos modos soportados:
sin autenticación (`AuthenticationType: None`, el modo actual de la
suscripción real en SAP) y con firma HMAC.
## 0. Una sola vez: preparar el entorno
```bash
cd sap-webhook-api
npm install
cp .env.example .env # si todavía no existe .env
```
Revisá que `.env` tenga los datos correctos de tu Service Layer
(`SERVICE_LAYER_URL`, `COMPANY_DB`, `SL_USER`, `SL_PASS`, `WEBHOOK_ID`). El
certificado HTTPS (`certs/key.pem` / `certs/cert.pem`) ya debería existir —
si no, ver `PASOS-REALIZADOS.md`, sección 9.
**Importante**: el modo con el que arranca la API (`.env` →
`HMAC_SECRET_KEY`) tiene que coincidir con el `AuthenticationType`
configurado en la suscripción real de SAP. Si no coinciden, SAP manda
notificaciones que la API rechaza (o al revés, la API acepta sin validar
nada aunque SAP crea que está firmando). Los scripts de la sección 2 se
encargan de mantenerlos sincronizados.
## 1. Modo sin autenticación (`AuthenticationType: None`)
Es el modo por defecto — no requiere ningún paso extra.
```bash
# confirmá que HMAC_SECRET_KEY esté vacío en .env
npm run start
```
La API queda escuchando en `https://<tu-ip>:3000/webhooks/sap`, acepta
cualquier notificación sin validar firma. Prueba local:
```bash
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" \
-d '[{"id":"test-1","type":"sap.b1.Orders.Created.v1","subject":"1","data":{"DocEntry":"1"}}]'
# -> 200 OK
```
## 2. Modo con HMAC (`AuthenticationType: HMAC`)
### 2.1 Activar
```bash
npm run auth:hmac:on
```
Esto:
1. Genera un `HMAC_SECRET_KEY` (si `.env` no tenía uno) y lo guarda en `.env`.
2. Hace `Login` en el Service Layer, lee la configuración actual de la
suscripción (`WEBHOOK_ID` de `.env`), la **pausa**, la **elimina** y la
**vuelve a crear** con `AuthenticationType: "HMAC"` — el Service Layer
no permite cambiar `AuthenticationType` con `PATCH` sobre una
suscripción existente, así que este es el único camino soportado
(se conserva `WebhookURL`, `Handshake` y `EventCollection` originales).
`AuthenticationCred` no es el secreto pelado: va como un string con
JSON embebido, `{"Key": "<secreto>", "Algorithm": "hmacsha256"}`.
Después, **reiniciá la API** para que tome el `HMAC_SECRET_KEY` nuevo:
```bash
npm run start
```
A partir de este momento, todo `POST /webhooks/sap` sin la firma correcta
en el header `X-B1-Webhook-Signature` (HMAC-SHA256 del body, en base64)
devuelve `401 Unauthorized`.
### 2.2 Probar manualmente (simulando lo que hace SAP)
```bash
SECRET=$(grep '^HMAC_SECRET_KEY=' .env | cut -d= -f2)
BODY='[{"id":"test-2","type":"sap.b1.Orders.Created.v1","subject":"2","data":{"DocEntry":"2"}}]'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64)
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" \
-H "X-B1-Webhook-Signature: $SIG" \
-d "$BODY"
# -> 200 OK
# sin firma o con una firma incorrecta -> 401
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" -d "$BODY"
```
### 2.3 Desactivar (volver a `None`)
```bash
npm run auth:hmac:off
npm run start # reiniciar para que la API deje de exigir la firma
```
Esto revierte la suscripción en SAP a `AuthenticationType: "None"` y vacía
`HMAC_SECRET_KEY` en `.env`, en un solo paso.
## Referencia rápida
| Acción | Comando |
|---|---|
| Instalar dependencias | `npm install` |
| Levantar la API | `npm run start` |
| Activar HMAC (API + SAP) | `npm run auth:hmac:on` |
| Desactivar HMAC (API + SAP) | `npm run auth:hmac:off` |
| Ver estado actual de la suscripción | `curl -sk -b cookies.txt "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"` (después de un `Login`) |
Detalle de la implementación (cómo se valida la firma, por qué se usa el
body crudo, etc.) en `CLAUDE.md`.
+291
View File
@@ -0,0 +1,291 @@
# Pasos realizados — API de Webhooks SAP Business One
Documento de los pasos ejecutados para levantar la API NestJS que escucha el
Event Notification del Webhook de SAP Business One Service Layer, desde el
proyecto vacío hasta tener la suscripción activa y lista para recibir
notificaciones reales. No incluye las pruebas contra SAP real (creación de
órdenes de venta) ni la investigación de errores posteriores — eso queda
documentado por separado.
## Contexto / objetivo
- API sencilla en NestJS que escucha el Webhook Messenger de SAP Business One
(Service Layer) y guarda localmente cada Event Notification recibido.
- SAP Business One corre en `sap10` (Service Layer, puerto `50000`) y en
`192.168.1.205` (Webhook Messenger, puerto `40008`), en la misma LAN que la
máquina de desarrollo (Mac, IP `192.168.1.166`).
- Compañía de pruebas: `SBODemoCL`, usuario `manager`.
## 1. Crear el proyecto NestJS
```bash
npx --yes @nestjs/cli new sap-webhook-api --package-manager npm --skip-git
```
Genera el scaffold estándar de Nest (TypeScript, npm) en `sap-webhook-api/`.
## 2. Crear el módulo de webhooks
```bash
cd sap-webhook-api
npx nest generate module webhooks --no-spec
npx nest generate controller webhooks --no-spec
```
Esto registra automáticamente `WebhooksModule` en `AppModule` y crea
`src/webhooks/webhooks.controller.ts`.
## 3. Endpoint mínimo que recibe el webhook
`src/webhooks/webhooks.controller.ts`:
```ts
import { Body, Controller, HttpCode, Logger, Post } from '@nestjs/common';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
const EVENTS_DIR = join(process.cwd(), 'data', 'events');
interface SapEventNotification {
EventID?: string;
SourceDB?: string;
BusinessObject?: string;
Operation?: string;
Status?: string;
ReplayState?: string;
FieldNames?: string;
FieldValues?: string;
CreateDate?: string;
CreateTime?: string;
}
@Controller('webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
@Post('sap')
@HttpCode(200)
receiveSapEvent(@Body() body: SapEventNotification | SapEventNotification[]) {
const notifications = Array.isArray(body) ? body : [body];
this.logger.log(`Recibidas ${notifications.length} notificación(es) de SAP`);
mkdirSync(EVENTS_DIR, { recursive: true });
notifications.forEach((notification, index) => {
const id = notification?.EventID ?? `${Date.now()}-${index}`;
const fileName = `${id}.json`;
writeFileSync(join(EVENTS_DIR, fileName), JSON.stringify(notification, null, 2));
this.logger.log(`Evento guardado: ${fileName}`);
});
return { received: true, count: notifications.length };
}
}
```
SAP puede enviar varias notificaciones "bateadas" en un solo `POST` (según
`MessageBatchLimit`), como confirma el sample oficial de SAP
(`webhook-service-sample-nodejs`). Por eso el body se normaliza siempre a un
array, y se guarda **un archivo por notificación**, nombrado por su
`EventID` cuando está disponible (o timestamp + índice como respaldo).
> Nota: esta versión reemplaza a una anterior más simple que guardaba el
> `body` completo en un solo archivo por request — ver historial de cambios
> del proyecto si se necesita esa versión.
## 4. Primera prueba local (HTTP)
```bash
npm run start
curl -s -i -X POST http://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" -d '{"test":"hola"}'
```
Resultado: `200 OK`, `{"received":true}`, y log
`Evento recibido de SAP: {"test":"hola"}`.
## 5. Verificar conectividad de red en la LAN
- IP de la Mac en la LAN: `192.168.1.166` (`ipconfig getifaddr en0`).
- Confirmar que Node escucha en todas las interfaces, no solo localhost:
```bash
lsof -nP -iTCP:3000 -sTCP:LISTEN
# node ... TCP *:3000 (LISTEN)
```
- Desde el servidor SAP (Windows), verificar que puede llegar a la Mac en el
puerto `3000` (macOS no trae `telnet`, así que se usó `Test-NetConnection`):
```powershell
Test-NetConnection -ComputerName 192.168.1.166 -Port 3000
# TcpTestSucceeded : True
```
- Desde la Mac, verificar conectividad hacia el Webhook Messenger de SAP
(puerto `40008`) y hacia el Service Layer (puerto `50000`), con `nc`
(netcat, viene instalado en macOS):
```bash
nc -zv 192.168.1.205 40008 # Connection succeeded
nc -zv sap10 50000 # Connection succeeded
```
Con esto queda confirmada la conectividad en ambos sentidos.
## 6. Login en el Service Layer y estado del webhook
```bash
curl -sk -i -c cookies.txt -X POST https://sap10:50000/b1s/v2/Login \
-H "Content-Type: application/json" \
-d '{"CompanyDB":"SBODemoCL","UserName":"manager","Password":"mngr"}'
```
```bash
curl -sk -b cookies.txt https://sap10:50000/b1s/v2/CompanyService_GetAdminInfo
```
El campo `EnableWebhook` ya estaba en `"tYES"` en esta compañía, así que no
hizo falta llamar a `CompanyService_UpdateAdminInfo`.
## 7. Confirmar el evento en el catálogo
```bash
curl -sk -b cookies.txt \
"https://sap10:50000/b1s/v2/EventSubscriptionsService_GetEventCatalog"
```
Se confirmó la entrada:
```json
{"BusinessObject": "Orders", "ObjectType": "17", "TransactionTypes": "Created,Updated,Deleted,Closed,Cancelled,Reopened"}
```
## 8. Primer intento de suscripción (falló — HTTP no permitido)
```bash
curl -sk -i -b cookies.txt -X POST https://sap10:50000/b1s/v2/EventSubscriptions \
-H "Content-Type: application/json" -d '{
"WebhookID": "SapWebhookApiTest",
"WebhookURL": "http://192.168.1.166:3000/webhooks/sap",
"AuthenticationType": "None",
"Handshake": "tNO",
"EventCollection": [{"BusinessObject": "Orders", "TransactionType": "Created"}]
}'
```
Resultado: `400 Bad Request`, `-21100 Invalid webhook URL` — SAP exige que
`WebhookURL` sea HTTPS.
## 9. Certificado autofirmado y API en HTTPS
```bash
cd sap-webhook-api
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem \
-days 825 -nodes -subj "/CN=192.168.1.166" \
-addext "subjectAltName=IP:192.168.1.166"
```
`src/main.ts`:
```ts
import { readFileSync } from 'fs';
import { join } from 'path';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const httpsOptions = {
key: readFileSync(join(__dirname, '..', 'certs', 'key.pem')),
cert: readFileSync(join(__dirname, '..', 'certs', 'cert.pem')),
};
const app = await NestFactory.create(AppModule, { httpsOptions });
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
```
Prueba local tras el cambio:
```bash
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" -d '{"test":"https-ok"}'
ls -la data/events/
```
Resultado: `200 OK` y el archivo del evento guardado correctamente en
`data/events/`.
## 10. Crear la suscripción con HTTPS (éxito)
```bash
curl -sk -i -b cookies.txt -X POST https://sap10:50000/b1s/v2/EventSubscriptions \
-H "Content-Type: application/json" -d '{
"WebhookID": "SapWebhookApiTest",
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
"AuthenticationType": "None",
"Handshake": "tNO",
"EventCollection": [{"BusinessObject": "Orders", "TransactionType": "Created"}]
}'
```
Resultado: `201 Created`, `State: "Active"` — pero con `VerifyCertificate:
"tYES"` (SAP intentaría validar nuestro certificado autofirmado y lo
rechazaría).
## 11. Desactivar la verificación de certificado
```bash
curl -sk -i -b cookies.txt -X PATCH \
"https://sap10:50000/b1s/v2/EventSubscriptions('SapWebhookApiTest')" \
-H "Content-Type: application/json" -d '{"VerifyCertificate": "tNO"}'
```
Resultado: `204 No Content`. Verificado con un `GET` posterior:
`VerifyCertificate: "tNO"`, `State: "Active"`.
## Estado al cierre de esta etapa
| Ítem | Estado |
|---|---|
| Proyecto NestJS (`sap-webhook-api`) | ✅ Creado |
| Endpoint `POST /webhooks/sap` | ✅ Responde 200 y guarda cada evento en `data/events/` |
| Conectividad LAN (Mac ↔ SAP, ambos sentidos) | ✅ Confirmada |
| Webhook habilitado en la compañía SAP | ✅ (ya estaba `tYES`) |
| Certificado HTTPS autofirmado | ✅ Generado y en uso |
| Suscripción `SapWebhookApiTest` (`Orders`/`Created`) | ✅ `Active`, `VerifyCertificate: tNO` |
A partir de aquí comienzan las pruebas con órdenes de venta reales en SAP, que
llevaron a identificar un bug en el Webhook Messenger de SAP (no cubierto en
este documento).
## Anexo: tablas internas de SAP para diagnóstico
Para diagnosticar problemas de entrega del webhook (suscripción activa pero
sin eventos llegando, errores de envío, etc.), conviene revisar directamente
estas tablas en la base de la compañía (ej. `SBODemoCL`), vía SSMS, en modo
**solo lectura**:
```sql
-- Suscripciones de webhook registradas (una fila por WebhookID: URL, estado,
-- fecha/hora de creación, último envío, último error)
SELECT * FROM OEVC
-- Cola de eventos de negocio generados (una fila por transacción que matchea
-- algún BusinessObject/TransactionType suscripto: EventID, CreateDate,
-- CreateTime, Status, BizObject, TransType). Status = 'N' significa
-- pendiente de enviar.
SELECT * FROM OEVN
-- Estado de envío de cada evento hacia cada webhook suscripto (relaciona
-- EventID + WebhookID con su resultado: Status, Retry, ReturnCode,
-- LastSentDT, LastSentTM)
SELECT * FROM EVN1
```
Estas tablas son internas del motor de SAP Business One — **no están
pensadas para escritura directa vía SQL** (cualquier cambio debería hacerse
a través de Service Layer / DI API). Sirven únicamente para inspeccionar el
estado real de la cola de eventos y de las suscripciones cuando el
comportamiento reportado por el Service Layer (`GET /EventSubscriptions`) no
alcanza para explicar por qué no llegan las notificaciones.
Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB