No todos los comercios electrónicos operan sobre plataformas cerradas o plugins prefabricados. Miles de empresas gestionan sus ventas mediante plataformas B2B y B2C a medida desarrolladas en PHP nativo, Laravel, Symfony, Node.js, Python o arquitecturas desacopladas (Angular, React, Vue, Next.js) alojadas en servidores Plesk, cPanel o VPS dedicados. El Endpoint Universal de Bentian ERP Bridge define el estándar formal de comunicación HTTP/JSON para integrar cualquier aplicación web personalizada con Factusol con garantías transaccionales corporativas.
1. Propósito y Arquitectura de Integración Enterprise
A diferencia de las integraciones basadas en webhooks entrantes que exigen abrir puertos en el router del almacén o contratar costosas IPs fijas con configuraciones de DMZ peligrosas, la arquitectura de Bentian opera exclusivamente mediante tráfico HTTPS saliente desde el puesto de trabajo hacia la tienda online:
El servidor web de la tienda solo necesita exponer un script HTTPS (por ejemplo, erp-bridge-endpoint.php o una ruta de API en su framework) capaz de responder a consultas de lectura de pedidos y recibir actualizaciones de inventario y confirmaciones de estado.
2. Protocolo Bidireccional de Comunicación (Polling & Flujo ACK)
La sincronización de pedidos entre la tienda web y Factusol se rige por un protocolo bidireccional en dos fases con cierre explícito (Two-Phase Commit / ACK):
- Fase 1 (Sondeo / Pull): El agente solicita pedidos en espera enviando una petición
GET /api/bentian-orders.php?limit=50&status=PENDING(o?action=get_orders). - Fase 2 (Custodia Local): El agente almacena el bloque de pedidos en su base de datos local SQLite (
queue.db) en modo WAL. - Fase 3 (Inyección Atómica): El conector Factusol abre una transacción OLEDB (
BeginTrans), busca o crea el cliente enF_CLI, inserta la cabecera enF_PCLy las líneas enF_LPC, y consolida la operación conCommitTrans. - Fase 4 (Confirmación / ACK): El agente emite una petición
POST /api/bentian-ack.php(o?action=ack_orders) notificando a la tienda online que el pedido ha sido registrado con éxito en Factusol y facilitando el número de pedido oficial del ERP. - Fase 5 (Transición en Web): La tienda web cambia el estado del pedido de
PENDINGaSYNCED, almacenando la referencia y fecha para auditoría.
3. Confirmación de Recepción (Flujo ACK) y Cierre de Transacción
Para cerrar formalmente el ciclo de vida del pedido, el agente de Bentian invoca el endpoint de confirmación enviando el siguiente contrato JSON:
POST /api/bentian-ack.php HTTP/1.1
Host: www.tutienda.com
Content-Type: application/json; charset=utf-8
X-Bentian-Token: <TOKEN_SECRETO>
X-Bentian-Timestamp: 1791189000
{
"orderId": "1042",
"status": "synced",
"factusolOrderNumber": "2026/0014",
"factusolSeries": "1",
"syncedAt": "2026-10-05T08:30:00Z"
}
POST /api/erp-bridge-endpoint.php?action=ack_orders HTTP/1.1
Host: www.tutienda.com
Content-Type: application/json; charset=utf-8
X-Bentian-Token: <TOKEN_SECRETO>
X-Bentian-Timestamp: 1791189000
{
"confirmations": [
{
"orderId": "1042",
"status": "synced",
"factusolOrderNumber": "2026/0014",
"factusolSeries": "1",
"syncedAt": "2026-10-05T08:30:00Z"
},
{
"orderId": "1043",
"status": "synced",
"factusolOrderNumber": "2026/0015",
"factusolSeries": "1",
"syncedAt": "2026-10-05T08:30:02Z"
}
]
}
Respuesta esperada por el agente (HTTP 200 OK):
{
"success": true,
"count": 2,
"message": "Pedidos marcados como sincronizados correctamente"
}
4. Resiliencia ante Fallos de Red e Idempotencia en SQLite
En cualquier sistema distribuido sobre Internet, las confirmaciones de red pueden fallar: un corte de fibra de 2 segundos, una saturación temporal en el hosting o un error 502 Bad Gateway de un proxy inverso pueden impedir que el agente entregue el ACK a la tienda online tras haber insertado el pedido en Factusol.
PENDING. En el siguiente ciclo de sondeo, la tienda volverá a ofrecer el pedido al agente. ¿Cómo se evita que Factusol registre el pedido por segunda vez?
Bentian resuelve este dilema mediante idempotencia estricta en su cola local SQLite (queue.db):
- Clave Primaria de Idempotencia: La tabla local de SQLite indexa unívocamente el identificador del pedido web (
web_order_id). - Detección de Pedido ya Inyectado: Cuando el agente descarga el lote de pedidos y recibe de nuevo el pedido
1042, consulta SQLite antes de tocar Factusol. Al detectar que el pedido ya figura con estadoINJECTEDy cuenta con el número correlativo2026/0014asignado, el agente aborta inmediatamente cualquier inserción enF_PCLyF_LPC. - Reintento Exclusivo del ACK: El agente detecta que la única tarea pendiente es la confirmación remota. Por tanto, emite de nuevo la llamada
POST /api/bentian-ack.phphacia la tienda web. En cuanto el servidor responde con HTTP 200, el agente marca el pedido comoACK_CONFIRMED. - Resultado Garantizado: Cero duplicaciones en Factusol, cero descuadres de existencias y entrega garantizada del estado final en la tienda web (*At-Least-Once Delivery con Efecto Exactly-Once en Factusol*).
5. Diccionario de Datos: Especificación Rigurosa de Campos JSON
A continuación se detalla la especificación de todos los campos del payload devuelto en la descarga de pedidos:
5.1 Cabecera del Pedido
| Campo JSON | Tipo | Obligatorio | Destino Factusol | Descripción & Ejemplo |
|---|---|---|---|---|
| id | String | Int | OBLIGATORIO | queue.db / SUFPCL | Identificador unívoco del pedido en la tienda web. Clave de idempotencia (ej: "1042"). |
| orderNumber | String | Opcional | F_PCL.SUFPCL | Código visual del pedido presentado al cliente (ej: "PED-2026-0891"). Si se omite, se usa id. |
| status | String | OBLIGATORIO | Filtro de descarga | Estado del pedido en la web. Debe ser "PENDING" o "processing" para su importación. |
| total | Float | OBLIGATORIO | F_PCL.TOTPCL | Importe total del pedido con IVA, recargos y portes incluidos (ej: 215.38). |
| subtotal | Float | Opcional | F_PCL.NETPCL | Base imponible neta total sin impuestos (ej: 178.00). |
| taxTotal | Float | Opcional | F_PCL.IVAPCL | Total acumulado de cuota de IVA (ej: 37.38). |
| shippingCost | Float | Opcional | F_PCL.PORPCL / Línea | Gastos de envío cobrados al cliente (ej: 6.50). |
| paymentMethod | String | Opcional | F_PCL.FPOPCL | Forma de pago empleada (ej: "redsys", "stripe", "bizum", "transfer"). |
| paymentStatus | String | Opcional | F_PCL.ESTPCL | Estado del pago (ej: "COMPLETED", "PAID", "PENDING"). |
| paymentReference | String | Opcional | F_PCL.OBSPCL | Identificador de transacción o autorización bancaria (ej: "ch_3NpA4..."). |
| notes | String | Opcional | F_PCL.OBSPCL | Instrucciones de entrega u observaciones del comprador (ej: "Entregar por la mañana"). |
| createdAt | String ISO-8601 | Opcional | F_PCL.FECPCL | Fecha y hora UTC de la orden (ej: "2026-10-05T08:14:22Z"). Si falta, se usa la fecha actual. |
5.2 Objeto del Cliente (customer)
| Campo JSON | Tipo | Obligatorio | Destino Factusol | Descripción & Mapeo |
|---|---|---|---|---|
| name / fullName | String | OBLIGATORIO | F_CLI.NOFCLI / CNOFCL | Nombre fiscal o comercial del cliente (ej: "Ferretería Industrial del Norte S.L."). |
| taxId / cif / nif | String | CRÍTICO | F_CLI.NIFCLI / CNIPCL | NIF, CIF o NIE español/europeo. Clave unívoca de búsqueda en F_CLI para no duplicar fichas. |
| String | Recomendado | F_CLI.EMACLI / CEMPCL | Email de facturación y seguimiento (ej: "[email protected]"). |
|
| phone | String | Opcional | F_CLI.TELCLI / TELPCL | Teléfono móvil o fijo para el albarán de transporte (ej: "612345678"). |
| address / street | String | Opcional | F_CLI.DOMCLI / CDOPCL | Dirección física de entrega y facturación (ej: "Polígono Industrial Nave 12"). |
| postalCode / cp | String | Opcional | F_CLI.CPOCLI / CCPPCL | Código postal de 5 dígitos (ej: "28001"). |
| city | String | Opcional | F_CLI.POBCLI / CPOPCL | Municipio o localidad (ej: "Madrid"). |
| province / state | String | Opcional | F_CLI.PROCLI / CPRPCL | Provincia (ej: "Madrid"). |
| country | String (ISO 3166-1) | Opcional | F_CLI.PAICLI / CPAPCL | Código de país de 2 letras en mayúsculas (por defecto "ES"). |
5.3 Array de Líneas de Pedido (lines)
| Campo JSON | Tipo | Obligatorio | Destino Factusol | Regla de Negocio |
|---|---|---|---|---|
| sku / artlpc | String | OBLIGATORIO | F_LPC.ARTLPC | Código del artículo en el catálogo de Factusol (F_ART.CODART). Clave de cruce. |
| name / deslpc | String | Recomendado | F_LPC.DESLPC | Descripción textual de la línea (máx. 50 caracteres para caber en campos legacy de Access). |
| quantity / canlpc | Float | Int | OBLIGATORIO | F_LPC.CANLPC | Número de unidades solicitadas (debe ser mayor estricto que 0). |
| priceWithoutVat / prelpc | Float | OBLIGATORIO | F_LPC.PRELPC | Precio neto unitario antes de impuestos (ej: 89.00). Evita la trampa del doble IVA. |
| vatRate / ivalpc | Float | OBLIGATORIO | F_LPC.IVALPC | Porcentaje de IVA aplicable a la línea (valores estándar en España: 21.0, 10.0, 4.0, 0.0). |
| equivalenceSurcharge / reclpc | Float | Opcional | F_LPC.RECLPC | Porcentaje de Recargo de Equivalencia si el cliente está acogido (ej: 5.2, 1.4, 0.5). |
| discountPercent / dtolpc | Float | Opcional | F_LPC.DTOLPC | Porcentaje de descuento comercial aplicado a la línea (ej: 10.0 para un 10%). |
6. Paginación de Alto Rendimiento, Límites y Versionado
Para garantizar que el conector pueda operar en tiendas con cientos de pedidos diarios sin provocar caídas de memoria (memory_limit) en servidores compartidos, el Endpoint Universal incorpora paginación basada en cursor y límites estrictos:
- Límite por Defecto: Si no se especifica, el servidor debe retornar hasta 50 pedidos por bloque.
- Límite Máximo Estricto: El parámetro
limitqueda acotado a un máximo de 200 pedidos (max(1, min(200, intval($_GET['limit'])))). Cualquier solicitud superior es truncada a 200. - Paginación por Cursor: El parámetro
cursorosince_idpermite solicitar los pedidos posteriores al último ID procesado (ej:GET ?action=get_orders&limit=50&cursor=1042). - Versionado Semántico del Esquema: Cada respuesta incluye el campo
"version": "1.1.0". El agente utiliza una política de compatibilidad hacia adelante (*forward-compatibility*): los campos JSON nuevos o no reconocidos son ignorados silenciosamente sin romper el deserializador.
7. Seguridad: Headers, Criptografía HMAC-SHA256 y Anti-Replay
Todas las peticiones HTTP emitidas por el agente hacia el endpoint incluyen un conjunto estándar de cabeceras de seguridad criptográfica:
| Cabecera HTTP | Formato | Obligatoriedad | Propósito de Seguridad |
|---|---|---|---|
| X-Bentian-Token | String alfanumérico | OBLIGATORIO | Token secreto pre-compartido entre el agente local y el servidor web. |
| X-Bentian-Timestamp | Época UNIX en segundos | OBLIGATORIO | Protección Anti-Replay: el servidor rechaza peticiones con más de 300s de desfase. |
| X-Bentian-Signature | Hexadecimal 64 caracteres | Recomendado (HMAC) | Firma criptográfica HMAC-SHA256 calculada sobre timestamp + '.' + body. |
| X-Bentian-Agent-Version | String semver (ej: v0.3.8) | Informativo | Permite al servidor web auditar qué versión del agente está conectándose. |
abs(time() - $requestTimestamp). Si la diferencia es superior a 300 segundos (5 minutos), la petición se descarta inmediatamente con código HTTP 403 Forbidden. Esto impide que un atacante que intercepte un paquete previo pueda replicarlo para descargar pedidos o alterar stock.
8. Rotación de Secretos y Variables de Entorno
Para cumplir con las normas de seguridad ISO 27001 y RGPD:
- Prohibición de Secretos en el Código: El token de Bentian NUNCA debe quedar grabado en texto plano dentro de scripts PHP accesibles públicamente o subidos a GitHub/GitLab.
- Almacenamiento Canónico en Variables de Entorno: Debe configurarse en el entorno del sistema operativo o en el panel de hosting (Plesk → Configuración de PHP → Variables de entorno, o fichero
.envubicado por encima de la carpeta públicahttpdocs):BENTIAN_ENDPOINT_TOKEN="eb_sec_9f83b2a1c4e7d6f5..." - Rotación sin Caída de Servicio (Zero-Downtime Rotation): Cuando se desee rotar un secreto empresarial, el servidor web puede aceptar temporalmente dos claves durante un período de solapamiento de 24 horas:
$validTokens = [ getenv('BENTIAN_ENDPOINT_TOKEN'), getenv('BENTIAN_PREVIOUS_TOKEN') // Token en retirada ]; $isAuthenticated = false; foreach ($validTokens as $expected) { if (!empty($expected) && hash_equals($expected, $receivedToken)) { $isAuthenticated = true; break; } }
9. Matriz de Códigos de Respuesta HTTP y Rate Limiting
El endpoint debe comunicar el resultado de cada operación utilizando códigos de estado HTTP semánticos RFC 9110:
| Código HTTP | Significado Técnico | Comportamiento del Agente Bentian |
|---|---|---|
| 200 OK | Petición procesada con éxito. Payload JSON íntegro. | Procesa pedidos o confirma el ciclo ACK satisfactoriamente. |
| 400 Bad Request | JSON malformado o campos requeridos ausentes. | Registra advertencia en el log de diagnóstico y omite el registro corrupto. |
| 401 Unauthorized | Cabecera X-Bentian-Token ausente o incorrecta. |
Marca error de credenciales en el GUI del agente y detiene el polling. |
| 403 Forbidden | Firma HMAC inválida o timestamp expirado (>300s). | Registra alerta de seguridad y sincroniza el reloj local del sistema con NTP. |
| 429 Too Many Requests | Límite de tasa excedido en el hosting (WAF o Cloudflare). | Respeta la cabecera Retry-After: 60 y activa backoff exponencial suave. |
| 500 Internal Error | Error de base de datos MySQL/MariaDB en el servidor. | Aplica reintentos programados sin perder ningún pedido en la cola local. |
Recomendación de Rate Limiting: Configurar un umbral de 60 peticiones/minuto por IP para el agente de Bentian. Esto garantiza margen de sobra para consultas de pedidos y descargas periódicas sin disparar bloqueos de seguridad del WAF.
10. Implementación de Referencia en PHP (Plesk / cPanel)
A continuación se proporciona una plantilla de integración completa en PHP, lista para subir a httpdocs/erp-bridge-endpoint.php en Plesk o cPanel. Incluye lectura segura de token por entorno, validación en tiempo constante (hash_equals), verificación anti-replay, soporte para descarga de pedidos, flujo ACK transaccional y actualización de stock:
<?php
/**
* ============================================================================
* Bentian ERP Bridge — Endpoint Universal Enterprise v1.1.0
* ============================================================================
* Ubicación en Plesk: httpdocs/erp-bridge-endpoint.php
* Requisitos: PHP 7.4+ con extensiones PDO MySQL / OpenSSL activas
* ============================================================================
*/
error_reporting(0);
ini_set('display_errors', '0');
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
// 1. Obtención de Secretos y Configuración desde Variables de Entorno
$secretToken = getenv('BENTIAN_ENDPOINT_TOKEN') ?: 'CAMBIA_ESTE_TOKEN_SECRETO_AQUI';
$dbHost = getenv('BENTIAN_DB_HOST') ?: '127.0.0.1';
$dbName = getenv('BENTIAN_DB_NAME') ?: 'mi_tienda_db';
$dbUser = getenv('BENTIAN_DB_USER') ?: 'mi_usuario_db';
$dbPass = getenv('BENTIAN_DB_PASS') ?: 'mi_password_db';
// 2. Extracción Compatible de Cabeceras HTTP
$headers = function_exists('getallheaders') ? getallheaders() : [];
$receivedToken = $headers['X-Bentian-Token']
?? $headers['x-bentian-token']
?? ($_SERVER['HTTP_X_BENTIAN_TOKEN'] ?? '');
$receivedTimestamp = (int)($headers['X-Bentian-Timestamp']
?? $headers['x-bentian-timestamp']
?? ($_SERVER['HTTP_X_BENTIAN_TIMESTAMP'] ?? 0));
$receivedSignature = $headers['X-Bentian-Signature']
?? $headers['x-bentian-signature']
?? ($_SERVER['HTTP_X_BENTIAN_SIGNATURE'] ?? '');
// 3. Autenticación en Tiempo Constante (Prevención de Timing Attacks)
if (empty($receivedToken) || !hash_equals($secretToken, $receivedToken)) {
http_response_code(401);
echo json_encode(['error' => 'No autorizado: Token de Bentian no coincide o ausente']);
exit;
}
// 4. Validación de Marca de Tiempo Anti-Replay (Ventana de 300 segundos)
if ($receivedTimestamp > 0 && abs(time() - $receivedTimestamp) > 300) {
http_response_code(403);
echo json_encode(['error' => 'Petición expirada: Timestamp fuera de la ventana permitida (300s)']);
exit;
}
// 5. Conexión PDO a Base de Datos
try {
$pdo = new PDO("mysql:host={$dbHost};dbname={$dbName};charset=utf8mb4", $dbUser, $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Error de conexión con la base de datos local']);
exit;
}
// 6. Enrutador de Acciones
$action = $_GET['action'] ?? 'get_orders';
$rawBody = file_get_contents('php://input');
$body = json_decode($rawBody, true) ?: [];
// Acción A: Verificación de Estado (Ping)
if ($action === 'ping') {
echo json_encode([
'status' => 'ok',
'success' => true,
'service' => 'Bentian Universal Bridge',
'version' => '1.1.0',
'databaseConnected' => true,
'timestamp' => time()
]);
exit;
}
// Acción B: Descarga de Pedidos Pendientes con Paginación
if ($action === 'get_orders' || $action === 'pull_orders') {
$limit = isset($_GET['limit']) ? max(1, min(200, intval($_GET['limit']))) : 50;
$cursor = isset($_GET['cursor']) ? intval($_GET['cursor']) : 0;
$stmt = $pdo->prepare("
SELECT id, order_number, status, total, subtotal, tax_total, shipping_cost,
customer_data, order_lines, payment_method, created_at
FROM eb_orders
WHERE status = 'PENDING' AND id > :cursor
ORDER BY id ASC
LIMIT :lim
");
$stmt->bindValue(':cursor', $cursor, PDO::PARAM_INT);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
$orders = [];
$lastId = $cursor;
foreach ($rows as $r) {
$lastId = intval($r['id']);
$orders[] = [
'id' => $lastId,
'orderNumber' => $r['order_number'] ?: ('PED-' . $lastId),
'status' => $r['status'],
'total' => floatval($r['total']),
'subtotal' => floatval($r['subtotal'] ?? $r['total']),
'taxTotal' => floatval($r['tax_total'] ?? 0),
'shippingCost' => floatval($r['shipping_cost'] ?? 0),
'paymentMethod' => $r['payment_method'] ?? 'web',
'createdAt' => $r['created_at'],
'customer' => json_decode($r['customer_data'], true) ?: [],
'lines' => json_decode($r['order_lines'], true) ?: []
];
}
echo json_encode([
'version' => '1.1.0',
'success' => true,
'pagination' => [
'limit' => $limit,
'count' => count($orders),
'hasMore' => count($orders) === $limit,
'nextCursor' => count($orders) === $limit ? $lastId : null
],
'orders' => $orders
]);
exit;
}
// Acción C: Confirmación de Pedidos Sincronizados (Flujo ACK Individual o en Bloque)
if ($action === 'ack_orders' || $action === 'ack') {
$confirmations = [];
if (isset($body['confirmations']) && is_array($body['confirmations'])) {
$confirmations = $body['confirmations'];
} elseif (isset($body['orderId'])) {
$confirmations = [$body];
}
if (empty($confirmations)) {
http_response_code(400);
echo json_encode(['error' => 'Estructura inválida: se esperaba "confirmations" o "orderId"']);
exit;
}
$stmt = $pdo->prepare("
UPDATE eb_orders
SET status = 'SYNCED',
factusol_order_number = :factNum,
factusol_series = :factSer,
synced_at = NOW()
WHERE id = :id AND status = 'PENDING'
");
$updated = 0;
$pdo->beginTransaction();
try {
foreach ($confirmations as $c) {
$stmt->execute([
':id' => intval($c['orderId'] ?? 0),
':factNum' => intval($c['factusolOrderNumber'] ?? 0) ?: null,
':factSer' => substr(strval($c['factusolSeries'] ?? '1'), 0, 5)
]);
$updated += $stmt->rowCount();
}
$pdo->commit();
echo json_encode(['success' => true, 'updated' => $updated]);
} catch (Exception $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Error al persistir confirmación ACK: ' . $e->getMessage()]);
}
exit;
}
// Acción D: Actualización de Stock en Tiempo Real desde Factusol
if ($action === 'push_stock') {
$updates = $body['stockUpdates'] ?? [];
if (empty($updates)) {
http_response_code(400);
echo json_encode(['error' => 'Parámetro stockUpdates vacío o ausente']);
exit;
}
$stmt = $pdo->prepare("
INSERT INTO eb_stock (code, stock, updated_at)
VALUES (:code, :stock, NOW())
ON DUPLICATE KEY UPDATE stock = :stock_up, updated_at = NOW()
");
$count = 0;
$pdo->beginTransaction();
try {
foreach ($updates as $u) {
$code = trim($u['sku'] ?? $u['code'] ?? '');
$stock = floatval($u['stock'] ?? 0);
if (!empty($code)) {
$stmt->execute([
':code' => $code,
':stock' => $stock,
':stock_up' => $stock
]);
$count++;
}
}
$pdo->commit();
echo json_encode(['success' => true, 'count' => $count]);
} catch (Exception $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Error al actualizar existencias: ' . $e->getMessage()]);
}
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Acción no reconocida: ' . htmlspecialchars($action)]);