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:
commit
5e0df73477
28 files changed
+11773
No files matched your search
@@ -0,0 +1,17 @@
|
|||||||
|
# Puerto donde escucha la API (default 3000)
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Postgres compartida con sap-intercompany-worker (ver docker-compose.yml en la raíz)
|
||||||
|
DATABASE_URL=postgresql://intercompany:intercompany@localhost:5432/sap_intercompany?schema=public
|
||||||
|
|
||||||
|
# Si se define, la API exige la firma X-B1-Webhook-Signature (HMAC-SHA256)
|
||||||
|
# en /webhooks/sap. Si se deja vacío/comentado, acepta todo sin firma
|
||||||
|
# (equivalente a AuthenticationType: None en la suscripción de SAP).
|
||||||
|
HMAC_SECRET_KEY=
|
||||||
|
|
||||||
|
# Datos usados solo por los scripts en scripts/ (Login + PATCH a Service Layer)
|
||||||
|
SERVICE_LAYER_URL=https://127.0.0.1:50000/b1s/v2
|
||||||
|
COMPANY_DB=SBODemoCL
|
||||||
|
SL_USER=manager
|
||||||
|
SL_PASS=manager
|
||||||
|
WEBHOOK_ID=SapWebhookApiTest
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
certs/*.pem
|
||||||
|
data/events/*.json
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# sap-webhook-api
|
||||||
|
|
||||||
|
API en NestJS que escucha el Webhook Messenger de SAP Business One
|
||||||
|
(Service Layer) y captura cada Event Notification que llega.
|
||||||
|
|
||||||
|
## Qué hace
|
||||||
|
|
||||||
|
- Expone `POST /webhooks/sap` por **HTTPS** (certificado autofirmado en
|
||||||
|
`certs/`), pensado para responder rápido y no arriesgar el timeout de
|
||||||
|
10s que impone el Webhook Messenger de SAP.
|
||||||
|
- Soporta el payload real de SAP: un array de eventos en formato
|
||||||
|
[CloudEvents](https://cloudevents.io/).
|
||||||
|
- Guarda cada notificación como:
|
||||||
|
- un archivo `.json` en `data/events/` (uno por evento, nombrado por su
|
||||||
|
`id`), y
|
||||||
|
- una fila en Postgres (tabla `webhook_events`), para que
|
||||||
|
[`sap-intercompany-worker`](../sap-intercompany-worker) la pueda
|
||||||
|
procesar de forma asíncrona.
|
||||||
|
- Soporta autenticación `None` (default) o `HMAC` (`X-B1-Webhook-Signature`,
|
||||||
|
HMAC-SHA256), activable/desactivable con un script que sincroniza SAP y
|
||||||
|
la propia API — ver `docs/EJECUCION.md`.
|
||||||
|
- Expone también `GET /webhooks/sap` (chequeo de vida / handshake) porque
|
||||||
|
SAP lo usa para validar el endpoint antes de despachar eventos.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # completar con los datos reales de tu Service Layer
|
||||||
|
|
||||||
|
# Postgres compartida con sap-intercompany-worker (una sola vez, desde la raíz del repo)
|
||||||
|
cd .. && docker compose up -d && cd sap-webhook-api
|
||||||
|
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
La API queda escuchando en `https://<tu-ip>:3000/webhooks/sap`.
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.ts # bootstrap, HTTPS
|
||||||
|
webhooks/
|
||||||
|
webhooks.controller.ts # GET/POST /webhooks/sap
|
||||||
|
webhook-events.repository.ts # insert a Postgres (webhook_events)
|
||||||
|
certs/ # certificado autofirmado (key.pem / cert.pem)
|
||||||
|
data/events/ # cada notificación, como .json
|
||||||
|
scripts/ # enable/disable-hmac-auth.sh
|
||||||
|
tools/sl.http # requests de referencia contra el Service Layer
|
||||||
|
docs/ # bitácora, arquitectura, guía de ejecución
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
- `docs/PASOS-REALIZADOS.md` — bitácora paso a paso de cómo se armó el
|
||||||
|
proyecto y se configuró la suscripción en SAP.
|
||||||
|
- `docs/EJECUCION.md` — cómo correr la API en modo `None` y en modo `HMAC`.
|
||||||
|
- `docs/ARQUITECTURA.md` / `docs/ARQUITECTURA.pdf` — diagrama de
|
||||||
|
arquitectura (servidores, puertos, certificados).
|
||||||
|
- `tools/sl.http` — requests de ejemplo (Login, EventSubscriptions, HMAC)
|
||||||
|
para usar con la extensión REST Client / httpyac.
|
||||||
|
|
||||||
|
Para el diagnóstico del bug de SAP que bloqueaba la entrega de eventos
|
||||||
|
(`DATEFORMAT`), y el resto del contexto de la sesión, ver `CLAUDE.md` en la
|
||||||
|
raíz del repo.
|
||||||
@@ -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.
@@ -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`.
|
||||||
@@ -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 |
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10354
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "sap-webhook-api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"auth:hmac:on": "bash scripts/enable-hmac-auth.sh",
|
||||||
|
"auth:hmac:off": "bash scripts/disable-hmac-auth.sh"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"pg": "^8.23.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
|
"@types/supertest": "^7.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Revierte la suscripción de SAP a AuthenticationType: None, y vacía
|
||||||
|
# HMAC_SECRET_KEY en .env para que la API deje de exigir la firma.
|
||||||
|
#
|
||||||
|
# El Service Layer no permite cambiar AuthenticationType con PATCH sobre
|
||||||
|
# una suscripción existente -> hay que Pause + Delete + volver a crearla
|
||||||
|
# (POST), reusando su WebhookURL/Handshake/EventCollection actuales.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
ENV_FILE=".env"
|
||||||
|
[ -f "$ENV_FILE" ] || { echo "No existe $ENV_FILE (copiá .env.example primero)"; exit 1; }
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${SERVICE_LAYER_URL:?Falta SERVICE_LAYER_URL en .env}"
|
||||||
|
: "${COMPANY_DB:?Falta COMPANY_DB en .env}"
|
||||||
|
: "${SL_USER:?Falta SL_USER en .env}"
|
||||||
|
: "${SL_PASS:?Falta SL_PASS en .env}"
|
||||||
|
: "${WEBHOOK_ID:?Falta WEBHOOK_ID en .env}"
|
||||||
|
|
||||||
|
COOKIE_JAR="$(mktemp)"
|
||||||
|
trap 'rm -f "$COOKIE_JAR"' EXIT
|
||||||
|
|
||||||
|
echo "Login en Service Layer ($SERVICE_LAYER_URL, compañía $COMPANY_DB)..."
|
||||||
|
curl -sk -c "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X POST "$SERVICE_LAYER_URL/Login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"CompanyDB\":\"$COMPANY_DB\",\"UserName\":\"$SL_USER\",\"Password\":\"$SL_PASS\"}"
|
||||||
|
|
||||||
|
echo "Leyendo configuración actual de '$WEBHOOK_ID'..."
|
||||||
|
CURRENT=$(curl -sk -b "$COOKIE_JAR" "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')")
|
||||||
|
|
||||||
|
NEW_BODY=$(echo "$CURRENT" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
current = json.load(sys.stdin)
|
||||||
|
body = {
|
||||||
|
'WebhookID': current['WebhookID'],
|
||||||
|
'WebhookURL': current['WebhookURL'],
|
||||||
|
'AuthenticationType': 'None',
|
||||||
|
'Handshake': current.get('Handshake', 'tNO'),
|
||||||
|
'VerifyCertificate': current.get('VerifyCertificate', 'tNO'),
|
||||||
|
'EventCollection': [
|
||||||
|
{'BusinessObject': e['BusinessObject'], 'TransactionType': e['TransactionType']}
|
||||||
|
for e in current['EventCollection']
|
||||||
|
],
|
||||||
|
}
|
||||||
|
print(json.dumps(body))
|
||||||
|
")
|
||||||
|
|
||||||
|
echo "Pausando suscripción '$WEBHOOK_ID'..."
|
||||||
|
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X POST "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')/Pause"
|
||||||
|
|
||||||
|
echo "Eliminando suscripción '$WEBHOOK_ID'..."
|
||||||
|
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X DELETE "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"
|
||||||
|
|
||||||
|
echo "Recreando '$WEBHOOK_ID' con AuthenticationType: None..."
|
||||||
|
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X POST "$SERVICE_LAYER_URL/EventSubscriptions" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$NEW_BODY"
|
||||||
|
|
||||||
|
# El POST de creación ignora el VerifyCertificate que mandamos y siempre
|
||||||
|
# arranca en tYES -> hace falta un PATCH aparte para volver a ponerlo en tNO.
|
||||||
|
echo "Ajustando VerifyCertificate: tNO..."
|
||||||
|
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X PATCH "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"VerifyCertificate": "tNO"}'
|
||||||
|
|
||||||
|
if grep -q '^HMAC_SECRET_KEY=' "$ENV_FILE"; then
|
||||||
|
sed -i.bak "s|^HMAC_SECRET_KEY=.*|HMAC_SECRET_KEY=|" "$ENV_FILE"
|
||||||
|
rm -f "$ENV_FILE.bak"
|
||||||
|
echo
|
||||||
|
echo "HMAC_SECRET_KEY vaciado en $ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Listo. Reiniciá la API (npm run start) para que deje de exigir la firma."
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Activa AuthenticationType: HMAC en la suscripción de SAP, sincronizado
|
||||||
|
# con HMAC_SECRET_KEY de .env (lo genera si todavía no existe).
|
||||||
|
#
|
||||||
|
# El Service Layer no permite cambiar AuthenticationType con PATCH sobre
|
||||||
|
# una suscripción existente -> hay que Pause + Delete + volver a crearla
|
||||||
|
# (POST), reusando su WebhookURL/Handshake/EventCollection actuales.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
ENV_FILE=".env"
|
||||||
|
[ -f "$ENV_FILE" ] || { echo "No existe $ENV_FILE (copiá .env.example primero)"; exit 1; }
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
if [ -z "${HMAC_SECRET_KEY:-}" ]; then
|
||||||
|
HMAC_SECRET_KEY="$(openssl rand -base64 32)"
|
||||||
|
if grep -q '^HMAC_SECRET_KEY=' "$ENV_FILE"; then
|
||||||
|
sed -i.bak "s|^HMAC_SECRET_KEY=.*|HMAC_SECRET_KEY=$HMAC_SECRET_KEY|" "$ENV_FILE"
|
||||||
|
rm -f "$ENV_FILE.bak"
|
||||||
|
else
|
||||||
|
echo "HMAC_SECRET_KEY=$HMAC_SECRET_KEY" >> "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
echo "Se generó un HMAC_SECRET_KEY nuevo y se guardó en $ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${SERVICE_LAYER_URL:?Falta SERVICE_LAYER_URL en .env}"
|
||||||
|
: "${COMPANY_DB:?Falta COMPANY_DB en .env}"
|
||||||
|
: "${SL_USER:?Falta SL_USER en .env}"
|
||||||
|
: "${SL_PASS:?Falta SL_PASS en .env}"
|
||||||
|
: "${WEBHOOK_ID:?Falta WEBHOOK_ID en .env}"
|
||||||
|
|
||||||
|
COOKIE_JAR="$(mktemp)"
|
||||||
|
trap 'rm -f "$COOKIE_JAR"' EXIT
|
||||||
|
|
||||||
|
echo "Login en Service Layer ($SERVICE_LAYER_URL, compañía $COMPANY_DB)..."
|
||||||
|
curl -sk -c "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X POST "$SERVICE_LAYER_URL/Login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"CompanyDB\":\"$COMPANY_DB\",\"UserName\":\"$SL_USER\",\"Password\":\"$SL_PASS\"}"
|
||||||
|
|
||||||
|
echo "Leyendo configuración actual de '$WEBHOOK_ID'..."
|
||||||
|
CURRENT=$(curl -sk -b "$COOKIE_JAR" "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')")
|
||||||
|
|
||||||
|
NEW_BODY=$(echo "$CURRENT" | HMAC_KEY="$HMAC_SECRET_KEY" python3 -c "
|
||||||
|
import json, os, sys
|
||||||
|
current = json.load(sys.stdin)
|
||||||
|
cred = json.dumps({'Key': os.environ['HMAC_KEY'], 'Algorithm': 'hmacsha256'})
|
||||||
|
body = {
|
||||||
|
'WebhookID': current['WebhookID'],
|
||||||
|
'WebhookURL': current['WebhookURL'],
|
||||||
|
'AuthenticationType': 'HMAC',
|
||||||
|
'AuthenticationCred': cred,
|
||||||
|
'Handshake': current.get('Handshake', 'tNO'),
|
||||||
|
'VerifyCertificate': current.get('VerifyCertificate', 'tNO'),
|
||||||
|
'EventCollection': [
|
||||||
|
{'BusinessObject': e['BusinessObject'], 'TransactionType': e['TransactionType']}
|
||||||
|
for e in current['EventCollection']
|
||||||
|
],
|
||||||
|
}
|
||||||
|
print(json.dumps(body))
|
||||||
|
")
|
||||||
|
|
||||||
|
echo "Pausando suscripción '$WEBHOOK_ID'..."
|
||||||
|
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X POST "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')/Pause"
|
||||||
|
|
||||||
|
echo "Eliminando suscripción '$WEBHOOK_ID'..."
|
||||||
|
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X DELETE "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"
|
||||||
|
|
||||||
|
echo "Recreando '$WEBHOOK_ID' con AuthenticationType: HMAC..."
|
||||||
|
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X POST "$SERVICE_LAYER_URL/EventSubscriptions" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$NEW_BODY"
|
||||||
|
|
||||||
|
# El POST de creación ignora el VerifyCertificate que mandamos y siempre
|
||||||
|
# arranca en tYES -> hace falta un PATCH aparte para volver a ponerlo en
|
||||||
|
# tNO (si no, SAP intenta validar nuestro cert autofirmado y falla con
|
||||||
|
# "SSL Certificate Untrusted", dejando la suscripción Inactive).
|
||||||
|
echo "Ajustando VerifyCertificate: tNO..."
|
||||||
|
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||||
|
-X PATCH "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"VerifyCertificate": "tNO"}'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Listo. Reiniciá la API (npm run start) para que tome el HMAC_SECRET_KEY de .env."
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [WebhooksModule],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { checkDatabaseConnection } from './webhooks/webhook-events.repository';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new Logger('Diagnostics');
|
||||||
|
await checkDatabaseConnection();
|
||||||
|
const httpsOptions = {
|
||||||
|
key: readFileSync(join(__dirname, '..', 'certs', 'key.pem')),
|
||||||
|
cert: readFileSync(join(__dirname, '..', 'certs', 'cert.pem')),
|
||||||
|
};
|
||||||
|
const app = await NestFactory.create(AppModule, { httpsOptions, rawBody: true });
|
||||||
|
|
||||||
|
const httpServer = app.getHttpServer();
|
||||||
|
httpServer.on('connection', (socket) => {
|
||||||
|
logger.log(`TCP connection recibida desde ${socket.remoteAddress}:${socket.remotePort}`);
|
||||||
|
});
|
||||||
|
httpServer.on('tlsClientError', (err, socket) => {
|
||||||
|
logger.error(`TLS handshake fallido desde ${socket.remoteAddress}: ${err.message}`);
|
||||||
|
});
|
||||||
|
httpServer.on('secureConnection', (tlsSocket) => {
|
||||||
|
logger.log(`TLS handshake OK desde ${tlsSocket.remoteAddress}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
const logger = new Logger('WebhookEventsRepository');
|
||||||
|
|
||||||
|
const pool = process.env.DATABASE_URL
|
||||||
|
? new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Extrae BusinessObject y Operation del "type" del CloudEvent, ej.
|
||||||
|
// "sap.b1.Orders.Created.v1" -> { businessObject: "Orders", operation: "Created" }
|
||||||
|
function parseType(type: string | undefined): { businessObject: string; operation: string } {
|
||||||
|
const match = type?.match(/^sap\.b1\.([^.]+)\.([^.]+)\.v\d+$/);
|
||||||
|
return {
|
||||||
|
businessObject: match?.[1] ?? 'Unknown',
|
||||||
|
operation: match?.[2] ?? 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida al arrancar si Postgres responde, para avisar temprano en el log en
|
||||||
|
// vez de que el primer indicio sea un ECONNREFUSED al llegar el primer evento.
|
||||||
|
// No bloquea el arranque: la API sigue funcionando sin Postgres (ver abajo).
|
||||||
|
export async function checkDatabaseConnection(): Promise<void> {
|
||||||
|
if (!pool) {
|
||||||
|
logger.warn('DATABASE_URL no configurada: los eventos no se guardarán en Postgres');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
logger.log('Conexión a Postgres OK');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`No se pudo conectar a Postgres al arrancar (¿está levantada?): ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserta el evento en la bandeja de entrada compartida (webhook_events) para
|
||||||
|
// que sap-intercompany-worker lo pueda procesar. Si no hay DATABASE_URL
|
||||||
|
// configurada, no hace nada (la API sigue funcionando igual sin Postgres).
|
||||||
|
export async function saveWebhookEventToInbox(notification: {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
subject?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!pool || !notification.id) return;
|
||||||
|
|
||||||
|
const { businessObject, operation } = parseType(notification.type);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO webhook_events (id, business_object, operation, subject, payload, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||||
|
ON CONFLICT (id) DO NOTHING`,
|
||||||
|
[notification.id, businessObject, operation, notification.subject ?? '', JSON.stringify(notification)],
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`No se pudo guardar el evento ${notification.id} en Postgres`, err as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Head,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { saveWebhookEventToInbox } from './webhook-events.repository';
|
||||||
|
|
||||||
|
const EVENTS_DIR = join(process.cwd(), 'data', 'events');
|
||||||
|
const HMAC_SECRET_KEY = process.env.HMAC_SECRET_KEY;
|
||||||
|
|
||||||
|
interface SapEventNotification {
|
||||||
|
id?: string;
|
||||||
|
specversion?: string;
|
||||||
|
source?: string;
|
||||||
|
type?: string;
|
||||||
|
subject?: string;
|
||||||
|
time?: string;
|
||||||
|
datacontenttype?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('webhooks')
|
||||||
|
export class WebhooksController {
|
||||||
|
private readonly logger = new Logger(WebhooksController.name);
|
||||||
|
|
||||||
|
@Get('sap')
|
||||||
|
@HttpCode(200)
|
||||||
|
validateSapWebhook(@Headers('x-b1-webhook-token') token?: string) {
|
||||||
|
// SAP valida que el endpoint esté vivo con un GET antes de despachar
|
||||||
|
// eventos (y lo usa también para el handshake, si Handshake: tYES).
|
||||||
|
this.logger.log(`GET de validación/handshake recibido de SAP${token ? ` (token=${token})` : ''}`);
|
||||||
|
return token ? { Challenge: token } : { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Head('sap')
|
||||||
|
@HttpCode(200)
|
||||||
|
checkSapWebhookAlive() {
|
||||||
|
// SAP también puede mandar HEAD solo para chequear que el endpoint esté vivo.
|
||||||
|
this.logger.log('HEAD de chequeo de vida recibido de SAP');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sap/events')
|
||||||
|
listStoredEvents() {
|
||||||
|
if (!existsSync(EVENTS_DIR)) return [];
|
||||||
|
|
||||||
|
return readdirSync(EVENTS_DIR)
|
||||||
|
.filter((file) => file.endsWith('.json'))
|
||||||
|
.map((file) => {
|
||||||
|
const notification = JSON.parse(
|
||||||
|
readFileSync(join(EVENTS_DIR, file), 'utf-8'),
|
||||||
|
) as SapEventNotification;
|
||||||
|
return {
|
||||||
|
id: notification.id ?? file.replace(/\.json$/, ''),
|
||||||
|
type: notification.type,
|
||||||
|
subject: notification.subject,
|
||||||
|
time: notification.time,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.time ?? '').localeCompare(b.time ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sap/events/:id')
|
||||||
|
getStoredEvent(@Param('id') id: string) {
|
||||||
|
const filePath = join(EVENTS_DIR, `${id}.json`);
|
||||||
|
if (!filePath.startsWith(EVENTS_DIR) || !existsSync(filePath)) {
|
||||||
|
throw new NotFoundException(`Evento ${id} no encontrado`);
|
||||||
|
}
|
||||||
|
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sap')
|
||||||
|
@HttpCode(200)
|
||||||
|
async receiveSapEvent(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Body() body: SapEventNotification | SapEventNotification[],
|
||||||
|
@Headers('x-b1-webhook-signature') signature?: string,
|
||||||
|
) {
|
||||||
|
if (HMAC_SECRET_KEY) {
|
||||||
|
this.verifyHmacSignature(req, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = Array.isArray(body) ? body : [body];
|
||||||
|
this.logger.log(`Recibidas ${notifications.length} notificación(es) de SAP`);
|
||||||
|
|
||||||
|
mkdirSync(EVENTS_DIR, { recursive: true });
|
||||||
|
for (const [index, notification] of notifications.entries()) {
|
||||||
|
const id = notification?.id ?? `${Date.now()}-${index}`;
|
||||||
|
const fileName = `${id}.json`;
|
||||||
|
writeFileSync(join(EVENTS_DIR, fileName), JSON.stringify(notification, null, 2));
|
||||||
|
await saveWebhookEventToInbox(notification);
|
||||||
|
this.logger.log(`Evento guardado: ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { received: true, count: notifications.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyHmacSignature(req: Request, signature?: string) {
|
||||||
|
if (!signature) {
|
||||||
|
throw new UnauthorizedException('Falta la firma HMAC (X-B1-Webhook-Signature)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = (req as Request & { rawBody?: Buffer }).rawBody;
|
||||||
|
if (!rawBody) {
|
||||||
|
throw new UnauthorizedException('No se pudo leer el body crudo para validar la firma');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedSignature = createHmac('sha256', HMAC_SECRET_KEY as string)
|
||||||
|
.update(rawBody)
|
||||||
|
.digest('base64');
|
||||||
|
|
||||||
|
const received = Buffer.from(signature);
|
||||||
|
const expected = Buffer.from(expectedSignature);
|
||||||
|
const isValid =
|
||||||
|
received.length === expected.length && timingSafeEqual(received, expected);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
this.logger.warn('Firma HMAC inválida recibida en /webhooks/sap');
|
||||||
|
throw new UnauthorizedException('Firma HMAC inválida');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WebhooksController } from './webhooks.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WebhooksController],
|
||||||
|
})
|
||||||
|
export class WebhooksModule {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
@url=https://sap10:50000/b1s/v2
|
||||||
|
|
||||||
|
###
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
POST {{url}}/Login
|
||||||
|
Content-Type : application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"CompanyDB":"SBODemoCL",
|
||||||
|
"UserName": "manager",
|
||||||
|
"Password":"mngr"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
GET {{url}}/CompanyService_GetAdminInfo
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
POST {{url}}/CompanyService_UpdateAdminInfo
|
||||||
|
Content-Type : application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"AdminInfo":{
|
||||||
|
"EnableWebhook": "tYES"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
GET {{url}}/EventSubscriptionsService_GetEventCatalog
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
POST {{url}}/EventSubscriptions
|
||||||
|
Content-Type : application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"WebhookID": "SapWebhookApiTest",
|
||||||
|
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
|
||||||
|
"AuthenticationType": "None",
|
||||||
|
"Handshake": "tNO",
|
||||||
|
"EventCollection": [
|
||||||
|
{
|
||||||
|
"BusinessObject": "Orders",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
GET {{url}}/EventSubscriptions
|
||||||
|
|
||||||
|
###
|
||||||
|
# ⚠ El Service Layer NO permite cambiar AuthenticationType con PATCH sobre
|
||||||
|
# una suscripción existente — hay que Pause + Delete (arriba/abajo) y
|
||||||
|
# volver a crearla con este POST. AuthenticationCred para HMAC no es el
|
||||||
|
# secreto pelado: es un string con JSON embebido {"Key": "...", "Algorithm": "hmacsha256"}.
|
||||||
|
# Con esta config, SAP firma cada POST con HMAC-SHA256 usando "Key" y lo
|
||||||
|
# manda en el header X-B1-Webhook-Signature.
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
POST {{url}}/EventSubscriptions
|
||||||
|
Content-Type : application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"WebhookID": "SapWebhookApiTest",
|
||||||
|
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
|
||||||
|
"VerifyCertificate": "tNO",
|
||||||
|
"AuthenticationType": "HMAC",
|
||||||
|
"AuthenticationCred": "{\"Key\": \"6Umq/cD6YQ4S8LKDOT33oDB6WifYXQFRQ4oRbIGzH0c=\", \"Algorithm\": \"hmacsha256\"}",
|
||||||
|
"Handshake": "tNO",
|
||||||
|
"EventCollection": [
|
||||||
|
{
|
||||||
|
"BusinessObject": "Orders",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
DELETE {{url}}/EventSubscriptions('SapWebhookApiTest')
|
||||||
|
|
||||||
|
###
|
||||||
|
# To remove an event (such as Invoices.Updated) from the subscription,
|
||||||
|
# send a PATCH request with the updated EventCollection that excludes
|
||||||
|
# the event you want to remove. Include the special header
|
||||||
|
# B1S-ReplaceCollectionsOnPatch to indicate a full replacement of the
|
||||||
|
# collection.
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
PATCH {{url}}/EventSubscriptions('SapWebhookApiTest')
|
||||||
|
B1S-ReplaceCollectionsOnPatch: true
|
||||||
|
Content-Type : application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"EventCollection": [
|
||||||
|
{
|
||||||
|
"BusinessObject": "BusinessPartners",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "BusinessPartners",
|
||||||
|
"TransactionType": "Updated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "BusinessPartners",
|
||||||
|
"TransactionType": "Deleted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "Orders",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "ProfitCenters",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "ProfitCenters",
|
||||||
|
"TransactionType": "Updated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "ProfitCenters",
|
||||||
|
"TransactionType": "Deleted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "SalesPersons",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "SalesPersons",
|
||||||
|
"TransactionType": "Updated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "SalesPersons",
|
||||||
|
"TransactionType": "Deleted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "ChartOfAccounts",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "ChartOfAccounts",
|
||||||
|
"TransactionType": "Updated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "ChartOfAccounts",
|
||||||
|
"TransactionType": "Deleted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "Items",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "Items",
|
||||||
|
"TransactionType": "Updated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "Items",
|
||||||
|
"TransactionType": "Deleted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "JournalEntries",
|
||||||
|
"TransactionType": "Created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "JournalEntries",
|
||||||
|
"TransactionType": "Updated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BusinessObject": "JournalEntries",
|
||||||
|
"TransactionType": "Cancelled"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# If a webhook subscription is deactivated due to errors, such as
|
||||||
|
# repeated delivery failures, you can resume it by sending a POST request
|
||||||
|
# to the EventSubscriptions('{WebhookID}')/Resume endpoint.
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Resume
|
||||||
|
|
||||||
|
###
|
||||||
|
# To pause an active event subscription, send a POST request to the EventSubscriptions('{WebhookID}')/Pause endpoint.
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Pause
|
||||||
|
|
||||||
|
###
|
||||||
|
# If a webhook subscription requires handshake verification, you can manually initiate the handshake process by sending a POST request to the EventSubscriptions('{WebhookID}')/Handshake endpoint.
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Handshake
|
||||||
|
|
||||||
|
###
|
||||||
|
# To replay missed event notifications for a specific webhook subscription, send a POST request to the EventSubscriptions('{WebhookID}')/Replay endpoint:
|
||||||
|
# @no-reject-unauthorized
|
||||||
|
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Replay
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user