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>
292 lines
9.6 KiB
Markdown
292 lines
9.6 KiB
Markdown
# 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.
|