Initial commit: sap-legacy-webhook-emulator

Reconstruye el Webhook Messenger nativo de SAP B1 (FP2605) para
clientes que aun no estan en esa version: captura eventos via
SBO_SP_PostTransactionNotice, provisiona UserTables OEVC/EVC1/OEVN/EVN1
y despacha CloudEvents al mismo endpoint que el messenger real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-09-16 08:30:45 -05:00
commit e1c8ee9015
42 files changed
+4042

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import { generateCode } from '../lib/codes.ts';
import { hhmmss } from '../lib/timestamps.ts';
import { userTableEntity } from '../lib/service-layer-paths.ts';
const ENTITY = userTableEntity('EVN1');
export interface Evn1Row {
code: string;
eventId: string;
webhookId: string;
status: string;
retry: number;
}
function toRow(raw: Record<string, unknown>): Evn1Row {
return {
code: raw.Code as string,
eventId: raw.U_EventID as string,
webhookId: raw.U_WebhookID as string,
status: raw.U_Status as string,
retry: (raw.U_Retry as number) ?? 0,
};
}
export async function findExisting(client: ServiceLayerClient, eventId: string, webhookId: string): Promise<Evn1Row | null> {
const filter = encodeURIComponent(`U_EventID eq '${eventId}' and U_WebhookID eq '${webhookId}'`);
const res = await client.get(`/${ENTITY}?$filter=${filter}`);
const rows = (res.data?.value as Record<string, unknown>[]) ?? [];
return rows.length > 0 ? toRow(rows[0]) : null;
}
export async function create(client: ServiceLayerClient, eventId: string, webhookId: string): Promise<void> {
await client.post(`/${ENTITY}`, {
Code: generateCode(),
Name: `${eventId}/${webhookId}`,
U_EventID: eventId,
U_WebhookID: webhookId,
U_Status: 'F',
U_Retry: 0,
});
}
export async function listPending(client: ServiceLayerClient): Promise<Evn1Row[]> {
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent("U_Status eq 'F'")}`);
return rows.map(toRow);
}
export async function listForEvent(client: ServiceLayerClient, eventId: string): Promise<Evn1Row[]> {
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent(`U_EventID eq '${eventId}'`)}`);
return rows.map(toRow);
}
export async function markDelivered(client: ServiceLayerClient, code: string, returnCode: number): Promise<void> {
const now = new Date();
await client.patch(`/${ENTITY}('${code}')`, {
U_Status: 'D',
U_ReturnCode: returnCode,
U_LastSentDT: now.toISOString(),
U_LastSentTM: hhmmss(now),
});
}
export async function markFailedAttempt(client: ServiceLayerClient, row: Evn1Row, returnCode: number): Promise<void> {
const now = new Date();
await client.patch(`/${ENTITY}('${row.code}')`, {
U_Retry: row.retry + 1,
U_ReturnCode: returnCode,
U_LastSentDT: now.toISOString(),
U_LastSentTM: hhmmss(now),
});
}
+44
View File
@@ -0,0 +1,44 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import { userTableEntity } from '../lib/service-layer-paths.ts';
const ENTITY = userTableEntity('OEVN');
export interface OevnRow {
code: string;
sourceDb: string;
status: string;
objectType: string;
transType: string;
fieldNames: string;
fieldValue: string;
}
function toRow(raw: Record<string, unknown>): OevnRow {
return {
code: raw.Code as string,
sourceDb: raw.U_SourceDB as string,
status: raw.U_Status as string,
objectType: raw.U_ObjectType as string,
transType: raw.U_TransType as string,
fieldNames: raw.U_FieldNames as string,
fieldValue: raw.U_FieldValue as string,
};
}
export async function listNewEvents(client: ServiceLayerClient): Promise<OevnRow[]> {
const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent("U_Status eq 'N'")}`);
return rows.map(toRow);
}
export async function getEvent(client: ServiceLayerClient, code: string): Promise<OevnRow | null> {
const res = await client.get(`/${ENTITY}('${code}')`);
return res.status === 200 ? toRow(res.data) : null;
}
export async function deleteEvent(client: ServiceLayerClient, code: string): Promise<void> {
await client.delete(`/${ENTITY}('${code}')`);
}
export async function setEventStatus(client: ServiceLayerClient, code: string, status: string): Promise<void> {
await client.patch(`/${ENTITY}('${code}')`, { U_Status: status });
}
+18
View File
@@ -0,0 +1,18 @@
// SAP emite un `object_type` especial para operaciones sobre cualquier
// UserTable simple (no registrada como UDO completo): un código negativo
// seguido de un tab y el nombre físico de la tabla (con "@"), en vez de un
// código positivo de BoObjectTypes. Confirmado contra Service Layer real:
// un DELETE sobre "@OEVN" hecho por el propio dispatcher generó
// object_type = "-3\t@OEVN" — es decir, nuestras propias escrituras
// (DELETE/PATCH/POST sobre @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer)
// producen un evento nuevo describiéndose a sí mismas. Si tratáramos ese
// evento como cualquier otro (sin match → borrar), el borrado generaría
// otro eco, y así indefinidamente. Por eso estos eventos nunca se tocan:
// ni se resuelven, ni se borran — quedan con Status='N' para siempre,
// cortando la cascada en el primer nivel. Ver docs/DISENO.md.
const OWN_TABLES = new Set(['@OEVC', '@EVC1', '@OEVN', '@EVN1']);
export function isEchoOfOwnTable(objectType: string): boolean {
const [code, tableName] = objectType.split('\t');
return code !== undefined && code.startsWith('-') && OWN_TABLES.has(tableName ?? '');
}
+144
View File
@@ -0,0 +1,144 @@
import type { ServiceLayerClient } from '../service-layer-client.ts';
import { config } from '../config.ts';
import { bizObjectFor } from '../mapping/object-types.ts';
import { buildCloudEvent } from '../lib/cloud-event.ts';
import * as oevn from './oevn-repository.ts';
import * as evn1 from './evn1-repository.ts';
import * as subscriptions from '../subscriptions/subscriptions-repository.ts';
import { sendBatch, type SendResult } from './send-webhook.ts';
import { isEchoOfOwnTable } from './own-table-guard.ts';
import { logger } from '../lib/logger.ts';
// Un tick del dispatcher: registrar eventos nuevos capturados por el SP
// (asignarles las suscripciones que les corresponden) y despachar todo lo
// que siga pendiente de entrega — nuevo o reintento. Ver docs/DISENO.md,
// sección "Dispatcher — lenguaje, arquitectura y flujo".
export async function processTick(client: ServiceLayerClient): Promise<void> {
const newCount = await registerNewEvents(client);
const pendingCount = await sendPendingDeliveries(client);
// Siempre deja un rastro, aunque no haya nada para hacer — así un
// visualizador de logs muestra que el proceso sigue vivo entre tanda y
// tanda de actividad real.
logger.info(`[dispatcher] Tick: ${newCount} evento(s) nuevo(s), ${pendingCount} pendiente(s) de entrega.`);
}
async function registerNewEvents(client: ServiceLayerClient): Promise<number> {
const newEvents = await oevn.listNewEvents(client);
if (newEvents.length === 0) return 0;
const filters = await subscriptions.listFilters(client);
for (const event of newEvents) {
// Eco de una escritura nuestra sobre @OEVC/@EVC1/@OEVN/@EVN1 (ver
// own-table-guard.ts) — nunca se toca, ni se resuelve ni se borra,
// para no perpetuar la cascada.
if (isEchoOfOwnTable(event.objectType)) continue;
let bizObject: string;
try {
bizObject = bizObjectFor(event.objectType);
} catch {
logger.warn(`[dispatcher] ObjectType desconocido '${event.objectType}' (evento ${event.code}) — se descarta.`);
await oevn.deleteEvent(client, event.code);
continue;
}
const matches = filters.filter((f) => f.bizObject === bizObject && f.transType === event.transType);
if (matches.length === 0) {
logger.info(`[dispatcher] Sin suscripciones para ${bizObject}/${event.transType} (evento ${event.code}) — se descarta.`);
await oevn.deleteEvent(client, event.code);
continue;
}
for (const match of matches) {
const existing = await evn1.findExisting(client, event.code, match.webhookId);
if (!existing) await evn1.create(client, event.code, match.webhookId);
}
await oevn.setEventStatus(client, event.code, 'P');
}
return newEvents.length;
}
async function sendPendingDeliveries(client: ServiceLayerClient): Promise<number> {
const pending = await evn1.listPending(client);
if (pending.length === 0) return 0;
const activeSubscriptions = await subscriptions.listActiveSubscriptions(client);
const byWebhookId = new Map<string, evn1.Evn1Row[]>();
for (const row of pending) {
if (row.retry >= config.retryLimit) {
if (activeSubscriptions.has(row.webhookId)) {
logger.warn(`[dispatcher] Suscripción ${row.webhookId} superó ${config.retryLimit} reintentos — pasa a State='X'.`);
await subscriptions.markExceptional(client, row.webhookId);
activeSubscriptions.delete(row.webhookId);
}
continue;
}
if (!activeSubscriptions.has(row.webhookId)) continue; // pausada / exceptional: no reintentar
const group = byWebhookId.get(row.webhookId) ?? [];
group.push(row);
byWebhookId.set(row.webhookId, group);
}
for (const [webhookId, rows] of byWebhookId) {
await sendGroup(client, activeSubscriptions.get(webhookId)!, rows);
}
await recomputeEventStatuses(client, [...new Set(pending.map((r) => r.eventId))]);
return pending.length;
}
async function sendGroup(
client: ServiceLayerClient,
subscription: subscriptions.Subscription,
rows: evn1.Evn1Row[],
): Promise<void> {
const events = [];
const deliverableRows: evn1.Evn1Row[] = [];
for (const row of rows) {
const event = await oevn.getEvent(client, row.eventId);
if (!event) continue; // evento borrado/inconsistente, se ignora
events.push(buildCloudEvent(event));
deliverableRows.push(row);
}
if (events.length === 0) return;
let result: SendResult;
try {
result = await sendBatch(subscription, events);
} catch (err) {
logger.warn(`[dispatcher] Error de red mandando a ${subscription.url}: ${err instanceof Error ? err.message : String(err)}`);
result = { ok: false, status: 0, body: '', finalUrl: subscription.url };
}
// "Entregado" aquí significa solo "el receptor contestó 2xx". Se registran
// status y respuesta porque un destino asíncrono puede aceptar con 202 y
// devolver un id de ejecución: sin ese dato no hay cómo distinguir una
// entrega procesada de una encolada que después se descartó.
const detail = `HTTP ${result.status}${result.body ? ` ${result.body}` : ''}`;
const redirect = result.finalUrl !== subscription.url ? ` [redirigido a ${result.finalUrl}]` : '';
if (result.ok) {
logger.info(
`[dispatcher] Entregados ${events.length} evento(s) a ${subscription.webhookId} (${subscription.url}): ${detail}${redirect}.`,
);
} else {
logger.warn(`[dispatcher] Falló la entrega a ${subscription.webhookId} (${subscription.url}): ${detail}${redirect}.`);
}
for (const row of deliverableRows) {
if (result.ok) await evn1.markDelivered(client, row.code, result.status);
else await evn1.markFailedAttempt(client, row, result.status);
}
}
async function recomputeEventStatuses(client: ServiceLayerClient, eventIds: string[]): Promise<void> {
for (const eventId of eventIds) {
const rows = await evn1.listForEvent(client, eventId);
if (rows.length === 0) continue;
const allDelivered = rows.every((r) => r.status === 'D');
await oevn.setEventStatus(client, eventId, allDelivered ? 'D' : 'P');
}
}
+51
View File
@@ -0,0 +1,51 @@
import axios from 'axios';
import https from 'node:https';
import { createHmac } from 'node:crypto';
import type { CloudEvent } from '../lib/cloud-event.ts';
import type { Subscription } from '../subscriptions/subscriptions-repository.ts';
const http = axios.create({
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
validateStatus: () => true,
});
export interface SendResult {
ok: boolean;
status: number;
// Respuesta cruda recortada. Un 2xx no prueba que el receptor haya
// procesado el evento: hay destinos que encolan y devuelven 202 con un id
// de ejecución, y sin este dato no hay forma de rastrear qué pasó después.
body: string;
// URL efectiva tras seguir redirects. Si difiere de subscription.url, el
// POST pudo haberse degradado a GET y perdido el body por el camino.
finalUrl: string;
}
const MAX_BODY_LOG = 300;
function summarizeBody(data: unknown): string {
const text = typeof data === 'string' ? data : JSON.stringify(data) ?? '';
return text.length > MAX_BODY_LOG ? `${text.slice(0, MAX_BODY_LOG)}…` : text;
}
// Mismo esquema de firma que ya implementa sap-webhook-api
// (X-B1-Webhook-Signature, HMAC-SHA256 en base64 sobre los bytes crudos del
// body) — el AuthCred de una suscripción HMAC es el JSON embebido que usa
// SAP internamente: {"Key": "<secreto>", "Algorithm": "hmacsha256"}.
export async function sendBatch(subscription: Subscription, events: CloudEvent[]): Promise<SendResult> {
const body = JSON.stringify(events);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (subscription.authType === 'H' && subscription.authCred) {
const { Key } = JSON.parse(subscription.authCred) as { Key: string };
headers['X-B1-Webhook-Signature'] = createHmac('sha256', Key).update(body).digest('base64');
}
const res = await http.post(subscription.url, body, { headers });
return {
ok: res.status >= 200 && res.status < 300,
status: res.status,
body: summarizeBody(res.data),
finalUrl: (res.request as { res?: { responseUrl?: string } })?.res?.responseUrl ?? subscription.url,
};
}