Deploy: 2026-05-22 23:55:19

This commit is contained in:
Hamza-Ayed
2026-05-22 23:55:19 +03:00
parent 7bf0933efb
commit 4860519f39
15 changed files with 1280 additions and 102 deletions

View File

@@ -342,101 +342,141 @@ class WhatsAppController extends BaseController
$replyAudio = null;
$replyAudioMimeType = null;
if ($rule['trigger_type'] === 'keyword') {
if (empty($incomingText)) {
$companyId = $session['company_id'];
// Limit enforcement for non-admin companies (company 1 is admin/demo)
if ($companyId !== 1) {
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
error_log("[Chatbot Warning] Company {$companyId} has no active subscription.");
return;
}
$keywords = array_filter(array_map('trim', explode(',', $rule['keyword'])));
$matched = false;
foreach ($keywords as $kw) {
if (mb_stripos($incomingText, $kw) !== false) {
$matched = true;
break;
// Check general request limit
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
error_log("[Chatbot Warning] Company {$companyId} has exceeded its general request limit.");
return;
}
// Check voice limit if input is audio
if ($hasAudio && !\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice')) {
error_log("[Chatbot Warning] Company {$companyId} has exceeded its voice request limit.");
$replyText = "⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من الرسائل الصوتية لهذا الشهر. يرجى إرسال استفسارك نصياً لكي نتمكن من مساعدتك.";
}
// Check OCR limit if input is image
if ($hasImage && !\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'ocr')) {
error_log("[Chatbot Warning] Company {$companyId} has exceeded its OCR/image request limit.");
$replyText = "⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من تحليل الصور والوصولات لهذا الشهر. يرجى إرسال استفسارك نصياً.";
}
}
if ($replyText === null) {
if ($rule['trigger_type'] === 'keyword') {
if (empty($incomingText)) {
return;
}
$keywords = array_filter(array_map('trim', explode(',', $rule['keyword'])));
$matched = false;
foreach ($keywords as $kw) {
if (mb_stripos($incomingText, $kw) !== false) {
$matched = true;
break;
}
}
if ($matched) {
$replyText = $rule['ai_prompt']; // Under keyword rules, ai_prompt stores the predefined static reply
}
} elseif ($rule['trigger_type'] === 'gemini_ai') {
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
if (empty($apiKey)) {
error_log("[Chatbot Warning] Gemini API Key is not set globally or for company " . $session['company_id']);
return;
}
}
if ($matched) {
$replyText = $rule['ai_prompt']; // Under keyword rules, ai_prompt stores the predefined static reply
}
} elseif ($rule['trigger_type'] === 'gemini_ai') {
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
if (empty($apiKey)) {
error_log("[Chatbot Warning] Gemini API Key is not set globally or for company " . $session['company_id']);
return;
}
// Dynamically fetch customer/driver info from configured endpoints if set
$infoContext = "";
$infoEndpoint = \App\Models\CompanyEndpoint::findByAction($session['company_id'], 'fetch_user_info');
if ($infoEndpoint && !empty($msgData['phone'])) {
$infoContext = $this->fetchUserInfoFromEndpoint($infoEndpoint, $msgData['phone']);
}
// Dynamically fetch customer/driver info from configured endpoints if set
$infoContext = "";
$infoEndpoint = \App\Models\CompanyEndpoint::findByAction($session['company_id'], 'fetch_user_info');
if ($infoEndpoint && !empty($msgData['phone'])) {
$infoContext = $this->fetchUserInfoFromEndpoint($infoEndpoint, $msgData['phone']);
}
// Dynamically fetch Salla order context if connected
$sallaContext = "";
if (!empty($msgData['phone'])) {
$sallaContext = $this->fetchSallaOrderContext($session['company_id'], $msgData['phone'], $incomingText);
}
// Dynamically fetch Salla order context if connected
$sallaContext = "";
if (!empty($msgData['phone'])) {
$sallaContext = $this->fetchSallaOrderContext($session['company_id'], $msgData['phone'], $incomingText);
}
$systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.';
// Dynamically fetch WooCommerce order context if connected
$wooContext = "";
if (!empty($msgData['phone'])) {
$wooContext = $this->fetchWooCommerceOrderContext($session['company_id'], $msgData['phone'], $incomingText);
}
// Append real-time info context to Gemini system prompt
if (!empty($infoContext)) {
$systemPrompt .= "\n\n" . $infoContext;
}
if (!empty($sallaContext)) {
$systemPrompt .= "\n\n" . $sallaContext;
}
$systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.';
// Enforce language matching rule dynamically
$systemPrompt .= "\n\nIMPORTANT LANGUAGE RULE: Detect the language of the incoming message. If the incoming message is in English, you MUST reply in English. If the incoming message is in Arabic, you MUST reply in Arabic. Override any default language instruction to match the user's language.";
// Append real-time info context to Gemini system prompt
if (!empty($infoContext)) {
$systemPrompt .= "\n\n" . $infoContext;
}
if (!empty($sallaContext)) {
$systemPrompt .= "\n\n" . $sallaContext;
}
if (!empty($wooContext)) {
$systemPrompt .= "\n\n" . $wooContext;
}
if ($hasAudio) {
$duration = isset($msgData['duration']) ? intval($msgData['duration']) : null;
if ($duration !== null && $duration > 90) {
$replyText = "⚠️ عذراً، التسجيل الصوتي طويل جداً. يرجى إرسال تسجيل صوتي موجز (لا يتجاوز دقيقة واحدة) لتلخيص المشكلة لكي نتمكن من مساعدتك بشكل أفضل.";
} else {
$mimeType = $msgData['mimeType'];
// Enforce language matching rule dynamically
$systemPrompt .= "\n\nIMPORTANT LANGUAGE RULE: Detect the language of the incoming message. If the incoming message is in English, you MUST reply in English. If the incoming message is in Arabic, you MUST reply in Arabic. Override any default language instruction to match the user's language.";
if ($hasAudio) {
$duration = isset($msgData['duration']) ? intval($msgData['duration']) : null;
if ($duration !== null && $duration > 90) {
$replyText = "⚠️ عذراً، التسجيل الصوتي طويل جداً. يرجى إرسال تسجيل صوتي موجز (لا يتجاوز دقيقة واحدة) لتلخيص المشكلة لكي نتمكن من مساعدتك بشكل أفضل.";
} else {
$mimeType = $msgData['mimeType'];
if (strpos($mimeType, ';') !== false) {
$mimeType = trim(explode(';', $mimeType)[0]);
}
$configuredElKey = !empty($rule['elevenlabs_api_key']) ? $rule['elevenlabs_api_key'] : null;
$elApiKey = \App\Services\GeminiService::getElevenLabsApiKey($configuredElKey);
$configuredVoiceId = !empty($rule['elevenlabs_voice_id']) ? $rule['elevenlabs_voice_id'] : null;
$elVoiceId = \App\Services\GeminiService::getElevenLabsVoiceId($configuredVoiceId);
// Try generating native audio response first
$audioResponse = \App\Services\GeminiService::generateAudioResponseFromAudio(
$apiKey,
$systemPrompt,
$msgData['audio'],
$mimeType,
'Puck',
$elApiKey,
$elVoiceId
);
if ($audioResponse && !empty($audioResponse['audio'])) {
$replyAudio = $audioResponse['audio'];
$replyAudioMimeType = $audioResponse['mimeType'] ?? 'audio/mp4';
$replyText = '[صوت من الذكاء الاصطناعي]';
} else {
// Fallback to text output from audio
$replyText = \App\Services\GeminiService::generateResponseFromAudio($apiKey, $systemPrompt, $msgData['audio'], $mimeType);
}
}
} elseif ($hasImage) {
$mimeType = $msgData['imageMimeType'];
if (strpos($mimeType, ';') !== false) {
$mimeType = trim(explode(';', $mimeType)[0]);
}
$configuredElKey = !empty($rule['elevenlabs_api_key']) ? $rule['elevenlabs_api_key'] : null;
$elApiKey = \App\Services\GeminiService::getElevenLabsApiKey($configuredElKey);
$configuredVoiceId = !empty($rule['elevenlabs_voice_id']) ? $rule['elevenlabs_voice_id'] : null;
$elVoiceId = \App\Services\GeminiService::getElevenLabsVoiceId($configuredVoiceId);
// Instruct Gemini to identify payment slips and output a specific command format if found
$imageSystemPrompt = $systemPrompt . "\n\nإرشادات إضافية للصور والوصولات:\nإذا كانت الصورة المرفقة عبارة عن وصل دفع أو إيصال تحويل مالي (مثل زين كاش أو إيداع بنكي)، يرجى استخراج البيانات التالية بدقة بالغة وكتابتها في بداية ردك بصيغة JSON محاطة بـ [PAYMENT_RECEIPT: { ... }] كالتالي:\n[PAYMENT_RECEIPT: {\"transaction_id\": \"رقم المعاملة أو الحوالة هنا\", \"amount\": \"المبلغ المستخرج كأرقام فقط\", \"method\": \"طريقة الدفع مثل Zain Cash أو Bank\"}]\nثم أكمل ردك الطبيعي بالترحيب بالسائق/العميل وإخباره بأنه جاري التحقق من عملية الدفع الآن.";
// Try generating native audio response first
$audioResponse = \App\Services\GeminiService::generateAudioResponseFromAudio(
$apiKey,
$systemPrompt,
$msgData['audio'],
$mimeType,
'Puck',
$elApiKey,
$elVoiceId
);
if ($audioResponse && !empty($audioResponse['audio'])) {
$replyAudio = $audioResponse['audio'];
$replyAudioMimeType = $audioResponse['mimeType'] ?? 'audio/mp4';
$replyText = '[صوت من الذكاء الاصطناعي]';
} else {
// Fallback to text output from audio
$replyText = \App\Services\GeminiService::generateResponseFromAudio($apiKey, $systemPrompt, $msgData['audio'], $mimeType);
}
$replyText = \App\Services\GeminiService::generateResponseFromImage($apiKey, $imageSystemPrompt, $msgData['image'], $mimeType);
} else {
$replyText = \App\Services\GeminiService::generateResponse($apiKey, $systemPrompt, $incomingText);
}
} elseif ($hasImage) {
$mimeType = $msgData['imageMimeType'];
if (strpos($mimeType, ';') !== false) {
$mimeType = trim(explode(';', $mimeType)[0]);
}
// Instruct Gemini to identify payment slips and output a specific command format if found
$imageSystemPrompt = $systemPrompt . "\n\nإرشادات إضافية للصور والوصولات:\nإذا كانت الصورة المرفقة عبارة عن وصل دفع أو إيصال تحويل مالي (مثل زين كاش أو إيداع بنكي)، يرجى استخراج البيانات التالية بدقة بالغة وكتابتها في بداية ردك بصيغة JSON محاطة بـ [PAYMENT_RECEIPT: { ... }] كالتالي:\n[PAYMENT_RECEIPT: {\"transaction_id\": \"رقم المعاملة أو الحوالة هنا\", \"amount\": \"المبلغ المستخرج كأرقام فقط\", \"method\": \"طريقة الدفع مثل Zain Cash أو Bank\"}]\nثم أكمل ردك الطبيعي بالترحيب بالسائق/العميل وإخباره بأنه جاري التحقق من عملية الدفع الآن.";
$replyText = \App\Services\GeminiService::generateResponseFromImage($apiKey, $imageSystemPrompt, $msgData['image'], $mimeType);
} else {
$replyText = \App\Services\GeminiService::generateResponse($apiKey, $systemPrompt, $incomingText);
}
}
@@ -504,6 +544,17 @@ class WhatsAppController extends BaseController
error_log("[Chatbot Gateway Error] failed to send auto-reply: " . $errorMsg);
}
// Increment SaaS usage stats if successfully sent
if ($status === 'sent' && $companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
if ($hasAudio || !empty($replyAudio)) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
}
if ($hasImage) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'ocr');
}
}
// Log the outbound auto-reply message
\App\Models\MessageLog::logMessage([
'company_id' => $session['company_id'],
@@ -771,5 +822,100 @@ class WhatsAppController extends BaseController
}
return "";
}
/**
* Fetch order info context from WooCommerce store for the company
*/
private function fetchWooCommerceOrderContext(int $companyId, string $phone, string $incomingText): string
{
try {
$store = \App\Models\WooCommerceStore::findByCompany($companyId);
if (!$store) {
return ""; // WooCommerce is not integrated
}
// Standardize customer phone to compare trailing digits
$cleanPhone = preg_replace('/\D/', '', $phone);
if (empty($cleanPhone)) {
return "";
}
// 1. Check if user is asking about a specific order ID (e.g. sequence of 3-12 digits)
if (preg_match('/\b(\d{3,12})\b/', $incomingText, $matches)) {
$orderId = (int)$matches[1];
$order = \App\Services\WooCommerceService::fetchOrder($store, $orderId, $phone);
if ($order) {
if (isset($order['unauthorized']) && $order['unauthorized'] === true) {
return "\n\n[تنبيه أمني للذكاء الاصطناعي: العميل سأل عن الطلب رقم {$orderId} ولكن هذا الطلب مسجل برقم هاتف مختلف في WooCommerce. لحماية الخصوصية والأمان، يمنع منعاً باتاً عرض تفاصيل هذا الطلب له. أخبر العميل بلطف أن رقم الهاتف الحالي لا يتطابق مع رقم الهاتف المسجل في تفاصيل هذا الطلب ولا يمكنك كشف تفاصيله]";
}
$status = $order['status'] ?? 'غير معروف';
$translatedStatus = $this->translateStatus($status);
$total = $order['total'] ?? '';
$currency = $order['currency'] ?? 'SAR';
$itemsCount = count($order['line_items'] ?? []);
$context = "\n\n[تفاصيل طلب WooCommerce المستعلم عنه للعميل:\n";
$context .= "- رقم الطلب: {$orderId}\n";
$context .= "- حالة الطلب الحالية: {$translatedStatus}\n";
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
$context .= "- عدد المنتجات: {$itemsCount}\n";
$context .= "الرجاء صياغة رد ودود ومختصر باللغة العربية لإخبار العميل بحالة هذا الطلب بالتحديد]";
return $context;
}
}
// 2. Fetch list of recent orders for the merchant and search by customer phone number
$orders = \App\Services\WooCommerceService::fetchRecentOrders($store, 30);
if (is_array($orders)) {
foreach ($orders as $order) {
$orderPhone = $order['billing']['phone'] ?? $order['shipping']['phone'] ?? '';
if (\App\Services\WooCommerceService::comparePhones($phone, $orderPhone)) {
// Found the most recent order for this customer
$orderId = $order['id'] ?? '';
$status = $order['status'] ?? 'غير معروف';
$translatedStatus = $this->translateStatus($status);
$total = $order['total'] ?? '';
$currency = $order['currency'] ?? 'SAR';
$itemsCount = count($order['line_items'] ?? []);
$context = "\n\n[آخر طلب للعميل في متجر WooCommerce:\n";
$context .= "- رقم الطلب: {$orderId}\n";
$context .= "- حالة الطلب الحالية: {$translatedStatus}\n";
$context .= "- إجمالي الطلب: {$total} {$currency}\n";
$context .= "- عدد المنتجات: {$itemsCount}\n";
$context .= "الرجاء استخدام هذه التفاصيل للإجابة على استفساره حول حالة طلبه الأخير بدقة وود باللغة العربية]";
return $context;
}
}
}
return "\n\n[سياق المتجر: العميل متصل بمتجر WooCommerce ولكن لم يتم العثور على أي طلبات سابقة له برقم الهاتف هذا. أخبره بلطف أنه لا توجد طلبات سابقة مسجلة برقم هاتفه الحالي في المتجر، واطلب منه تزويدك برقم الطلب للبحث]";
} catch (\Exception $e) {
error_log("[Fetch WooCommerce Order Exception] " . $e->getMessage());
}
return "";
}
/**
* Translate WooCommerce order status to Arabic
*/
private function translateStatus(string $status): string
{
$translations = [
'pending' => 'بانتظار الدفع',
'processing' => 'قيد التجهيز',
'on-hold' => 'قيد الانتظار',
'completed' => 'مكتمل',
'cancelled' => 'ملغي',
'refunded' => 'مسترجع',
'failed' => 'فشل الدفع',
'checkout-draft' => 'مسودة السلة'
];
return $translations[$status] ?? $status;
}
}

View File

@@ -0,0 +1,271 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Models\WooCommerceStore;
use App\Models\WhatsAppSession;
class WooCommerceController extends BaseController
{
/**
* Connect WooCommerce store.
* Protected by AuthMiddleware.
* Accessible via POST /api/integrations/woocommerce/connect
*/
public function connect(Request $request, Response $response)
{
$body = $request->getBody();
$storeUrl = $body['store_url'] ?? '';
$consumerKey = $body['consumer_key'] ?? '';
$consumerSecret = $body['consumer_secret'] ?? '';
$webhookSecret = $body['webhook_secret'] ?? null;
if (empty($storeUrl) || empty($consumerKey) || empty($consumerSecret)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'Missing store_url, consumer_key, or consumer_secret'
]);
return;
}
try {
$storeId = WooCommerceStore::saveStore(
$request->company_id,
$storeUrl,
$consumerKey,
$consumerSecret,
$webhookSecret
);
// Generate delivery URL for webhooks to display to the user
$appUrl = rtrim(getenv('APP_URL') ?: 'https://nabeh.intaleqapp.com', '/');
$webhookUrl = $appUrl . '/api/webhooks/woocommerce?company_id=' . $request->company_id;
$response->json([
'status' => 'success',
'message' => 'WooCommerce store connected successfully',
'webhook_url' => $webhookUrl
]);
} catch (\Exception $e) {
$response->status(500)->json([
'status' => 'error',
'message' => 'Connection failed: ' . $e->getMessage()
]);
}
}
/**
* Get WooCommerce connection status.
* Protected by AuthMiddleware.
* Accessible via GET /api/integrations/woocommerce/status
*/
public function status(Request $request, Response $response)
{
$store = WooCommerceStore::findByCompany($request->company_id);
if ($store) {
$appUrl = rtrim(getenv('APP_URL') ?: 'https://nabeh.intaleqapp.com', '/');
$webhookUrl = $appUrl . '/api/webhooks/woocommerce?company_id=' . $request->company_id;
$response->json([
'status' => 'success',
'connected' => true,
'store_url' => $store['store_url'],
'webhook_url' => $webhookUrl,
'has_webhook_secret' => !empty($store['webhook_secret'])
]);
} else {
$response->json([
'status' => 'success',
'connected' => false
]);
}
}
/**
* Disconnect WooCommerce integration.
* Protected by AuthMiddleware.
* Accessible via POST /api/integrations/woocommerce/disconnect
*/
public function disconnect(Request $request, Response $response)
{
WooCommerceStore::deleteByCompany($request->company_id);
$response->json([
'status' => 'success',
'message' => 'WooCommerce integration disconnected successfully'
]);
}
/**
* Handle incoming webhook from WooCommerce.
* Accessible via POST /api/webhooks/woocommerce?company_id=XYZ
*/
public function webhook(Request $request, Response $response)
{
$companyId = (int)($request->get('company_id') ?? 0);
if (empty($companyId)) {
$response->status(400)->json(['error' => 'Missing company_id query parameter']);
return;
}
$store = WooCommerceStore::findByCompany($companyId);
if (!$store) {
$response->status(404)->json(['error' => 'Store connection details not found for this tenant']);
return;
}
$rawPayload = file_get_contents('php://input');
// 1. Verify signature if webhook_secret is configured
if (!empty($store['webhook_secret'])) {
$signatureHeader = $request->getHeader('x-wc-webhook-signature') ?: '';
if (empty($signatureHeader)) {
$response->status(401)->json(['error' => 'Missing x-wc-webhook-signature header']);
return;
}
$calculated = base64_encode(hash_hmac('sha256', $rawPayload, $store['webhook_secret'], true));
if (!hash_equals($calculated, $signatureHeader)) {
error_log("[WooCommerce Webhook Error] Signature mismatch for company {$companyId}");
$response->status(401)->json(['error' => 'Signature verification failed']);
return;
}
}
// 2. Parse payload
$payload = json_decode($rawPayload, true);
if (!$payload) {
$response->status(400)->json(['error' => 'Invalid JSON payload']);
return;
}
$topic = $request->getHeader('x-wc-webhook-topic') ?: '';
if (empty($topic)) {
$response->status(400)->json(['error' => 'Missing x-wc-webhook-topic header']);
return;
}
// 3. Process events
if ($topic === 'order.created' || $topic === 'order.updated') {
$this->handleOrderWebhook($companyId, $topic, $payload);
}
$response->json(['status' => 'success']);
}
/**
* Process order webhook events and send automated WhatsApp message
*/
private function handleOrderWebhook(int $companyId, string $topic, array $order)
{
$orderId = $order['id'] ?? '';
$customerName = trim(($order['billing']['first_name'] ?? '') . ' ' . ($order['billing']['last_name'] ?? ''));
if (empty($customerName)) {
$customerName = 'عميلنا العزيز';
}
$customerPhone = $order['billing']['phone'] ?? $order['shipping']['phone'] ?? '';
$status = $order['status'] ?? '';
$total = $order['total'] ?? '';
$currency = $order['currency'] ?? 'USD';
if (empty($customerPhone) || empty($orderId)) {
return;
}
// Normalize phone number
$customerPhone = preg_replace('/\D/', '', $customerPhone);
if (substr($customerPhone, 0, 2) === '00') {
$customerPhone = substr($customerPhone, 2);
}
// Normalize Saudi numbers starting with 05
if (strlen($customerPhone) === 9 && $customerPhone[0] === '5') {
$customerPhone = '966' . $customerPhone;
} elseif (strlen($customerPhone) === 10 && substr($customerPhone, 0, 2) === '05') {
$customerPhone = '966' . substr($customerPhone, 1);
}
// Formulate Arabic message based on topic
$message = '';
if ($topic === 'order.created') {
$message = "مرحباً {$customerName}،\nتم استلام طلبك رقم ({$orderId}) بنجاح! 🎉\nإجمالي الفاتورة: {$total} {$currency}\nحالة الطلب الحالية: *قيد المراجعة*.\nشكرًا لتسوقك معنا!";
} elseif ($topic === 'order.updated') {
$translatedStatus = $this->translateStatus($status);
$message = "مرحباً {$customerName}،\nتم تحديث حالة طلبك رقم ({$orderId}) إلى: *{$translatedStatus}*.\n";
// If completed, add standard courier notice
if ($status === 'completed') {
$message .= "🚚 طلبك الآن في طريقه إليك! نتمنى لك تجربة ممتعة.";
}
}
if (empty($message)) {
return;
}
// Send WhatsApp Message via active session
$session = WhatsAppSession::findByCompany($companyId);
if (!$session || $session['status'] !== 'connected') {
error_log("[WooCommerce Webhook Warning] Cannot send auto-notification: No active connected WhatsApp session for company: " . $companyId);
return;
}
$this->sendWhatsAppNotification($session['session_key'], $customerPhone, $message);
}
/**
* Translate WooCommerce order status to Arabic
*/
private function translateStatus(string $status): string
{
$translations = [
'pending' => 'بانتظار الدفع',
'processing' => 'قيد التجهيز',
'on-hold' => 'قيد الانتظار',
'completed' => 'مكتمل',
'cancelled' => 'ملغي',
'refunded' => 'مسترجع',
'failed' => 'فشل الدفع',
'checkout-draft' => 'مسودة السلة'
];
return $translations[$status] ?? $status;
}
/**
* Send a WhatsApp message through the gateway
*/
private function sendWhatsAppNotification(string $sessionKey, string $phone, string $message)
{
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
if (substr($gatewayUrl, -4) === '/api') {
$sendUrl = $gatewayUrl . '/messages/send';
} else {
$sendUrl = $gatewayUrl . '/api/messages/send';
}
$payload = json_encode([
'session_key' => $sessionKey,
'phone' => $phone,
'message' => $message
]);
$ch = curl_init($sendUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Webhook-Secret: ' . getenv('WEBHOOK_SECRET')
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[WooCommerce Webhook Gateway Error] Failed to send WhatsApp notification. Gateway response: " . $response);
}
}
}

View File

@@ -43,9 +43,25 @@ class ConversationFlowEngine
$companyId = $session['company_id'];
$text = isset($msgData['body']) ? trim($msgData['body']) : '';
// If incoming message is audio, transcribe it via Gemini
// If incoming message is audio, transcribe it via Gemini (if limits permit)
$isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
if ($isAudio) {
if ($companyId !== 1) {
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
error_log("[Flow Engine Warning] Company {$companyId} has no active subscription for audio transcription.");
return false;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its request limit for audio transcription.");
return false;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its voice limit for audio transcription.");
return false;
}
}
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
@@ -54,6 +70,11 @@ class ConversationFlowEngine
if ($transcription) {
$text = $transcription;
$msgData['body'] = $transcription;
// Increment usage stats for successful transcription
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
}
}
}
@@ -84,6 +105,21 @@ class ConversationFlowEngine
return false;
}
// Check subscription limits for active/starting flow
if ($companyId !== 1) {
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
error_log("[Flow Engine Warning] Company {$companyId} has no active subscription.");
self::sendReply($session, $phone, "⚠️ عذراً، لا يوجد اشتراك نشط لهذا المتجر حالياً.");
return true;
}
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its general request limit.");
self::sendReply($session, $phone, "⚠️ عذراً، لقد استهلك هذا المتجر كامل الحد المسموح له من الرسائل والطلبات لهذا الشهر.");
return true;
}
}
// 3. User cancel flow option
$normalizedCancel = strtolower(trim($text));
if (in_array($normalizedCancel, ['إلغاء', 'خروج', 'cancel', 'exit'])) {

View File

@@ -172,7 +172,7 @@ EOT
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
if (!empty($apiKey)) {
$postponeData = $this->detectPostponement($text, $apiKey);
$postponeData = $this->detectPostponement($text, $apiKey, $companyId);
if ($postponeData !== null) {
$hours = $postponeData['hours'];
$postponeCount = ($context['postpone_count'] ?? 0) + 1;
@@ -366,6 +366,15 @@ EOT
}
$companyId = $context['company_id'] ?? 1;
// Check subscription limit for OCR
if ($companyId !== 1) {
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'ocr')) {
error_log("[DriverRegistrationFlow] Company {$companyId} has exceeded its OCR limit.");
return new FlowResult("⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من تحليل الصور والوصولات لهذا الشهر. يرجى إرسال استفسارك نصياً.", $step);
}
}
$rule = ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
@@ -389,6 +398,12 @@ EOT
return new FlowResult($failMessage, $step);
}
// Increment stats on successful OCR processing
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'ocr');
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
// Save URL and OCR JSON string in the conversation context
$context[$step . '_url'] = $imageUrl;
$context[$step . '_ocr'] = $ocrData;
@@ -432,7 +447,7 @@ EOT
/**
* Detect if user wants to postpone, and return hours_delay if so.
*/
private function detectPostponement(string $text, string $apiKey): ?array
private function detectPostponement(string $text, string $apiKey, int $companyId): ?array
{
if (empty($text)) {
return null;
@@ -471,6 +486,11 @@ EOT;
return null;
}
// Increment request limit on successful postponement check API call
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
// Clean markdown block if present
$response = trim(preg_replace('/```json|```/', '', $response));
$data = json_decode($response, true);

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
use App\Models\CompanySubscription;
use App\Models\CompanySubscriptionUsage;
/**
* SubscriptionMiddleware
* Validates company subscription validity and request quotas before processing operations.
*/
class SubscriptionMiddleware
{
public function handle(Request $request, Response $response): void
{
// 1. Get company ID (populated by AuthMiddleware)
$companyId = $request->company_id ?? null;
if (!$companyId) {
$response->json(['error' => 'Unauthorized', 'message' => 'Company details not found in request Context'], 401);
exit;
}
// Allow Company 1 (Intaleq admin/demo) to bypass limits temporarily or have unlimited
if ($companyId === 1) {
return;
}
// 2. Fetch active subscription
$activeSub = CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
$response->json([
'error' => 'Payment Required',
'message' => 'This account does not have an active subscription or the current subscription has expired. Please subscribe to a plan to continue.'
], 402);
exit;
}
// 3. Verify total requests limit
$hasQuota = CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request');
if (!$hasQuota) {
$response->json([
'error' => 'Quota Exceeded',
'message' => 'You have exceeded the monthly request quota for your plan (' . $activeSub['max_requests'] . ' requests). Please upgrade your subscription.'
], 403);
exit;
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use App\Core\Database;
/**
* CompanySubscription Model
* Manages active tenancies linked to subscription plans.
*/
class CompanySubscription extends BaseModel
{
protected static string $table = 'company_subscriptions';
/**
* Get active subscription for a company
*/
public static function findActiveByCompany(int $companyId): ?array
{
$now = date('Y-m-d H:i:s');
return Database::selectOne(
"SELECT cs.*, sp.name as plan_name, sp.max_sessions, sp.max_requests,
sp.max_voice_requests, sp.max_ocr_requests, sp.features
FROM " . static::$table . " cs
JOIN subscription_plans sp ON cs.plan_id = sp.id
WHERE cs.company_id = ?
AND cs.status = 'active'
AND cs.starts_at <= ?
AND cs.ends_at >= ?
LIMIT 1",
[$companyId, $now, $now]
);
}
/**
* Create or update subscription for a company
*/
public static function subscribeCompany(int $companyId, int $planId, int $durationDays = 30, ?string $gateway = null, ?string $ref = null): string
{
$now = time();
$startsAt = date('Y-m-d H:i:s', $now);
$endsAt = date('Y-m-d H:i:s', $now + ($durationDays * 86400));
// Deactivate previous active subscriptions
Database::execute(
"UPDATE " . static::$table . " SET status = 'expired' WHERE company_id = ? AND status = 'active'",
[$companyId]
);
return self::create([
'company_id' => $companyId,
'plan_id' => $planId,
'status' => 'active',
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'payment_gateway' => $gateway,
'subscription_ref' => $ref
]);
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Models;
use App\Core\Database;
/**
* CompanySubscriptionUsage Model
* Tracks API usage stats per company dynamically linked to active billing cycles.
*/
class CompanySubscriptionUsage extends BaseModel
{
protected static string $table = 'company_subscription_usage';
/**
* Get or initialize usage record for the active billing cycle of a company
*/
public static function getOrCreateCurrentUsage(int $companyId, array $activeSubscription): array
{
$billingStart = date('Y-m-d', strtotime($activeSubscription['starts_at']));
$billingEnd = date('Y-m-d', strtotime($activeSubscription['ends_at']));
// Check if usage record already exists
$usage = Database::selectOne(
"SELECT * FROM " . static::$table . "
WHERE company_id = ? AND billing_start = ? AND billing_end = ?
LIMIT 1",
[$companyId, $billingStart, $billingEnd]
);
if (!$usage) {
// Initialize new record
try {
$id = self::create([
'company_id' => $companyId,
'billing_start' => $billingStart,
'billing_end' => $billingEnd,
'request_count' => 0,
'voice_count' => 0,
'ocr_count' => 0
]);
return [
'id' => $id,
'company_id' => $companyId,
'billing_start' => $billingStart,
'billing_end' => $billingEnd,
'request_count' => 0,
'voice_count' => 0,
'ocr_count' => 0
];
} catch (\Exception $e) {
// Handle concurrent insertions gracefully
$usage = Database::selectOne(
"SELECT * FROM " . static::$table . "
WHERE company_id = ? AND billing_start = ? AND billing_end = ?
LIMIT 1",
[$companyId, $billingStart, $billingEnd]
);
if (!$usage) {
throw $e;
}
}
}
return $usage;
}
/**
* Increment usage counts for the current billing cycle
*/
public static function incrementUsage(int $companyId, string $type = 'request', int $amount = 1): bool
{
$activeSub = CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
return false;
}
$currentUsage = self::getOrCreateCurrentUsage($companyId, $activeSub);
$column = 'request_count';
if ($type === 'voice') {
$column = 'voice_count';
} elseif ($type === 'ocr') {
$column = 'ocr_count';
}
return Database::execute(
"UPDATE " . static::$table . "
SET {$column} = {$column} + ?
WHERE id = ?",
[$amount, $currentUsage['id']]
) > 0;
}
/**
* Check if a company has exceeded its plan limits for a certain action
*/
public static function hasRemainingLimit(int $companyId, string $type = 'request'): bool
{
$activeSub = CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
return false; // No active subscription means no requests allowed
}
$usage = self::getOrCreateCurrentUsage($companyId, $activeSub);
if ($type === 'request') {
return $usage['request_count'] < $activeSub['max_requests'];
} elseif ($type === 'voice') {
return $usage['voice_count'] < $activeSub['max_voice_requests'];
} elseif ($type === 'ocr') {
return $usage['ocr_count'] < $activeSub['max_ocr_requests'];
}
return false;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use App\Core\Database;
/**
* SubscriptionPlan Model
* Represents SaaS pricing and request limit plans.
*/
class SubscriptionPlan extends BaseModel
{
protected static string $table = 'subscription_plans';
/**
* Get plan by name
*/
public static function findByName(string $name): ?array
{
return Database::selectOne(
"SELECT * FROM " . static::$table . " WHERE name = ? LIMIT 1",
[$name]
);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use App\Core\Database;
use App\Core\Security;
/**
* WooCommerceStore Model
* Represents WooCommerce connections per company. Keys are encrypted.
*/
class WooCommerceStore extends BaseModel
{
protected static string $table = 'woocommerce_stores';
/**
* Find store connection by company ID
*/
public static function findByCompany(int $companyId): ?array
{
return Database::selectOne(
"SELECT * FROM " . static::$table . " WHERE company_id = ? LIMIT 1",
[$companyId]
);
}
/**
* Get decrypted credentials for API calls
*/
public static function getDecryptedCredentials(array $store): array
{
return [
'store_url' => $store['store_url'],
'consumer_key' => Security::decrypt($store['consumer_key']),
'consumer_secret' => Security::decrypt($store['consumer_secret']),
'webhook_secret' => $store['webhook_secret'] ?? null
];
}
/**
* Save or update WooCommerce connection
*/
public static function saveStore(int $companyId, string $storeUrl, string $consumerKey, string $consumerSecret, ?string $webhookSecret = null): string
{
$encryptedKey = Security::encrypt($consumerKey);
$encryptedSecret = Security::encrypt($consumerSecret);
$existing = self::findByCompany($companyId);
$data = [
'company_id' => $companyId,
'store_url' => rtrim($storeUrl, '/'),
'consumer_key' => $encryptedKey,
'consumer_secret' => $encryptedSecret,
'webhook_secret' => $webhookSecret
];
if ($existing) {
self::update($existing['id'], $data);
return $existing['id'];
} else {
return self::create($data);
}
}
/**
* Delete WooCommerce connection
*/
public static function deleteByCompany(int $companyId): int
{
return Database::execute(
"DELETE FROM " . static::$table . " WHERE company_id = ?",
[$companyId]
);
}
}

View File

@@ -43,10 +43,10 @@ class GeminiService
*/
public static function getElevenLabsVoiceId(?string $configuredVoiceId = null): string
{
$voiceIdSource = !empty($configuredVoiceId) ? $configuredVoiceId : (getenv('ELEVENLABS_VOICE_ID') ?: 'pNInz6obpgDQGcFmaJgB');
$voiceIdSource = !empty($configuredVoiceId) ? $configuredVoiceId : (getenv('ELEVENLABS_VOICE_ID') ?: 'EXAVITQu4vr4xnSDxMaL');
$voiceIds = array_filter(array_map('trim', explode(',', $voiceIdSource)));
if (empty($voiceIds)) {
return 'pNInz6obpgDQGcFmaJgB';
return 'EXAVITQu4vr4xnSDxMaL';
}
return $voiceIds[array_rand($voiceIds)];
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Models\WooCommerceStore;
/**
* WooCommerceService
* Interacts with WooCommerce REST API of merchant stores securely.
*/
class WooCommerceService
{
/**
* Fetch order from WooCommerce by ID and verify customer phone
*/
public static function fetchOrder(array $store, int $orderId, ?string $customerPhone = null): ?array
{
$credentials = WooCommerceStore::getDecryptedCredentials($store);
$storeUrl = rtrim($credentials['store_url'], '/');
$url = $storeUrl . '/wp-json/wc/v3/orders/' . $orderId;
// Basic Authentication header
$auth = base64_encode($credentials['consumer_key'] . ':' . $credentials['consumer_secret']);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Basic ' . $auth,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Handle local dev self-signed certs gracefully
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[WooCommerce API Error] Failed to fetch order $orderId from $storeUrl. HTTP: $httpCode, Response: $res");
return null;
}
$order = json_decode($res, true);
if (!$order) {
return null;
}
// Security check: Match customer phone if provided
if ($customerPhone) {
$orderPhone = $order['billing']['phone'] ?? $order['shipping']['phone'] ?? '';
if (!self::comparePhones($customerPhone, $orderPhone)) {
error_log("[WooCommerce Security Warning] Order $orderId phone ($orderPhone) does not match customer phone ($customerPhone)");
return ['unauthorized' => true];
}
}
return $order;
}
/**
* Fetch recent orders from WooCommerce
*/
public static function fetchRecentOrders(array $store, int $perPage = 30): ?array
{
$credentials = WooCommerceStore::getDecryptedCredentials($store);
$storeUrl = rtrim($credentials['store_url'], '/');
$url = $storeUrl . '/wp-json/wc/v3/orders?per_page=' . $perPage;
// Basic Authentication header
$auth = base64_encode($credentials['consumer_key'] . ':' . $credentials['consumer_secret']);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Basic ' . $auth,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Handle local dev self-signed certs gracefully
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[WooCommerce API Error] Failed to fetch orders from $storeUrl. HTTP: $httpCode, Response: $res");
return null;
}
return json_decode($res, true);
}
/**
* Compare two phone numbers by matching their trailing digits (ignoring country codes/symbols)
*/
public static function comparePhones(string $phone1, string $phone2): bool
{
$clean1 = preg_replace('/\D/', '', $phone1);
$clean2 = preg_replace('/\D/', '', $phone2);
if (empty($clean1) || empty($clean2)) {
return false;
}
// Compare trailing 9 digits (common standard for Saudi and international numbers)
$len1 = strlen($clean1);
$len2 = strlen($clean2);
$matchLen = min(9, $len1, $len2);
if ($matchLen < 6) {
// If phone numbers are very short, require exact match
return $clean1 === $clean2;
}
return substr($clean1, -$matchLen) === substr($clean2, -$matchLen);
}
}

View File

@@ -0,0 +1,76 @@
-- ==============================================================================
-- 🗄️ Nabeh SaaS Subscriptions & WooCommerce Integration Schema Additions
-- ==============================================================================
-- 1. Subscription Plans Table
CREATE TABLE IF NOT EXISTS `subscription_plans` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
`price` DECIMAL(10, 2) NOT NULL,
`billing_cycle` ENUM('monthly', 'yearly') DEFAULT 'monthly',
`max_sessions` INT DEFAULT 1,
`max_requests` INT DEFAULT 1000,
`max_voice_requests` INT DEFAULT 0,
`max_ocr_requests` INT DEFAULT 0,
`features` JSON NULL COMMENT 'Enabled features flags and limits detail',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. Company Subscriptions Table
CREATE TABLE IF NOT EXISTS `company_subscriptions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`plan_id` INT NOT NULL,
`status` ENUM('active', 'trialing', 'canceled', 'expired', 'past_due') DEFAULT 'active',
`starts_at` TIMESTAMP NOT NULL,
`ends_at` TIMESTAMP NOT NULL,
`canceled_at` TIMESTAMP NULL DEFAULT NULL,
`payment_gateway` VARCHAR(50) NULL,
`subscription_ref` VARCHAR(255) NULL COMMENT 'External subscription ID reference',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`plan_id`) REFERENCES `subscription_plans`(`id`) ON DELETE RESTRICT,
INDEX `idx_sub_status` (`status`),
INDEX `idx_sub_dates` (`starts_at`, `ends_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. Company Subscription Usage Stats Table (Consolidated for fast checking)
CREATE TABLE IF NOT EXISTS `company_subscription_usage` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`billing_start` DATE NOT NULL,
`billing_end` DATE NOT NULL,
`request_count` INT DEFAULT 0,
`voice_count` INT DEFAULT 0,
`ocr_count` INT DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `unique_company_billing_period` (`company_id`, `billing_start`, `billing_end`),
FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. WooCommerce Stores Integration Table
CREATE TABLE IF NOT EXISTS `woocommerce_stores` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`store_url` VARCHAR(512) NOT NULL,
`consumer_key` TEXT NOT NULL COMMENT 'AES-256-GCM Encrypted WooCommerce API Consumer Key',
`consumer_secret` TEXT NOT NULL COMMENT 'AES-256-GCM Encrypted WooCommerce API Consumer Secret',
`webhook_secret` VARCHAR(255) NULL COMMENT 'Used to verify signature of incoming webhooks',
`is_active` TINYINT(1) DEFAULT 1,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON DELETE CASCADE,
INDEX `idx_wc_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==============================================================================
-- 🔌 Seed Default Subscription Plans & Setup Simulation Tenancies
-- ==============================================================================
INSERT INTO `subscription_plans` (`id`, `name`, `price`, `billing_cycle`, `max_sessions`, `max_requests`, `max_voice_requests`, `max_ocr_requests`, `features`) VALUES
(1, 'Starter', 19.00, 'monthly', 1, 1000, 0, 0, '{"voice": false, "ocr": false, "integrations": []}'),
(2, 'Growth', 49.00, 'monthly', 2, 5000, 500, 500, '{"voice": true, "ocr": true, "integrations": ["salla", "woocommerce"]}'),
(3, 'Professional', 99.00, 'monthly', 5, 15000, 2000, 2000, '{"voice": true, "ocr": true, "integrations": ["salla", "woocommerce", "custom_api"]}')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `price` = VALUES(`price`), `max_requests` = VALUES(`max_requests`), `max_voice_requests` = VALUES(`max_voice_requests`), `max_ocr_requests` = VALUES(`max_ocr_requests`), `features` = VALUES(`features`);

View File

@@ -45,41 +45,47 @@ $router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me
// WhatsApp Gateway Routes
$router->get('/api/whatsapp/status', [\App\Controllers\WhatsAppController::class, 'status'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/whatsapp/qr', [\App\Controllers\WhatsAppController::class, 'requestQr'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/whatsapp/qr', [\App\Controllers\WhatsAppController::class, 'requestQr'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/whatsapp/disconnect', [\App\Controllers\WhatsAppController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/whatsapp/webhook', [\App\Controllers\WhatsAppController::class, 'webhook']); // No AuthMiddleware (Protected by WEBHOOK_SECRET internally)
// Phase 4 & 5: CRM, Templates & Campaigns Routes
$router->get('/api/contacts', [\App\Controllers\ContactController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/contacts', [\App\Controllers\ContactController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/contacts', [\App\Controllers\ContactController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->get('/api/groups', [\App\Controllers\GroupController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/groups', [\App\Controllers\GroupController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/groups/add', [\App\Controllers\GroupController::class, 'addContact'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/groups/bulk-add', [\App\Controllers\GroupController::class, 'bulkAddContacts'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/groups', [\App\Controllers\GroupController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/groups/add', [\App\Controllers\GroupController::class, 'addContact'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/groups/bulk-add', [\App\Controllers\GroupController::class, 'bulkAddContacts'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->get('/api/templates', [\App\Controllers\TemplateController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/templates', [\App\Controllers\TemplateController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/templates', [\App\Controllers\TemplateController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->get('/api/campaigns', [\App\Controllers\CampaignController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/campaigns', [\App\Controllers\CampaignController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/campaigns', [\App\Controllers\CampaignController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->get('/api/chatbot/rules', [\App\Controllers\ChatbotController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/chatbot/rules',[\App\Controllers\ChatbotController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/chatbot/generate-prompt-from-audio', [\App\Controllers\ChatbotController::class, 'generatePromptFromAudio'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/chatbot/rules',[\App\Controllers\ChatbotController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/chatbot/generate-prompt-from-audio', [\App\Controllers\ChatbotController::class, 'generatePromptFromAudio'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
// Custom Integration Endpoints Routes (Phase 5)
$router->get('/api/endpoints', [\App\Controllers\EndpointController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/endpoints', [\App\Controllers\EndpointController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]);
$router->delete('/api/endpoints', [\App\Controllers\EndpointController::class, 'delete'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/endpoints', [\App\Controllers\EndpointController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->delete('/api/endpoints', [\App\Controllers\EndpointController::class, 'delete'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
// Salla Platform Integration Routes (Phase 6+)
$router->get('/api/integrations/salla/auth', [\App\Controllers\SallaController::class, 'auth']);
$router->get('/api/integrations/salla/callback', [\App\Controllers\SallaController::class, 'callback']);
$router->get('/api/integrations/salla/status', [\App\Controllers\SallaController::class, 'status'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/integrations/salla/disconnect',[\App\Controllers\SallaController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/integrations/salla/status', [\App\Controllers\SallaController::class, 'status'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/integrations/salla/disconnect',[\App\Controllers\SallaController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/webhooks/salla', [\App\Controllers\SallaController::class, 'webhook']);
// WooCommerce Store Integration Routes
$router->post('/api/integrations/woocommerce/connect', [\App\Controllers\WooCommerceController::class, 'connect'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->get('/api/integrations/woocommerce/status', [\App\Controllers\WooCommerceController::class, 'status'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/integrations/woocommerce/disconnect', [\App\Controllers\WooCommerceController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/webhooks/woocommerce', [\App\Controllers\WooCommerceController::class, 'webhook']);
// Mock External API for Entaleq Driver Info (Used to fetch real-time driver data)
$router->post('/api/external/driver-info', function ($request, $response) {

View File

@@ -0,0 +1,35 @@
<?php
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
header('Content-Type: text/plain; charset=utf-8');
try {
echo "Connecting to database...\n";
$pdo = Database::getConnection();
$sqlFile = dirname(__DIR__) . '/create_saas_and_woocommerce_tables.sql';
if (!file_exists($sqlFile)) {
throw new \Exception("SQL file not found at: " . $sqlFile);
}
echo "Reading SQL file...\n";
$sql = file_get_contents($sqlFile);
echo "Executing SQL statements...\n";
$pdo->exec($sql);
echo "Migration completed successfully!\n";
// Verify tables
$stmt = $pdo->query("SHOW TABLES");
$tables = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo "Current database tables:\n";
foreach ($tables as $t) {
echo "- $t\n";
}
} catch (\Exception $e) {
echo "Migration failed: " . $e->getMessage() . "\n";
}

View File

@@ -0,0 +1,141 @@
<?php
/**
* WooCommerce Integration & SaaS Limit Verification Simulation
* Run this on the server: php backend/public/test_woocommerce_limits.php
*/
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
use App\Models\CompanySubscription;
use App\Models\CompanySubscriptionUsage;
use App\Models\WooCommerceStore;
use App\Services\WooCommerceService;
echo "=== Starting SaaS & WooCommerce Integration Tests ===\n\n";
$companyId = 999; // Mock company for testing limits
$phone = "966555555555"; // Mock Saudi number
// Ensure clean database state for this mock company
Database::execute("DELETE FROM company_subscriptions WHERE company_id = ?", [$companyId]);
Database::execute("DELETE FROM company_subscription_usage WHERE company_id = ?", [$companyId]);
Database::execute("DELETE FROM woocommerce_stores WHERE company_id = ?", [$companyId]);
Database::execute("DELETE FROM companies WHERE id = ?", [$companyId]);
// 1. Create Mock Company
echo "1. Creating mock company...\n";
Database::execute("INSERT INTO companies (id, name, created_at) VALUES (?, 'Mock Merchant Co', NOW())", [$companyId]);
// 2. Add WooCommerce Mock Connection details
echo "2. Saving mock WooCommerce store credentials...\n";
$mockStoreUrl = "https://mock-woo-store.com";
$mockConsumerKey = "ck_1234567890123456789012345678901234567890";
$mockConsumerSecret = "cs_1234567890123456789012345678901234567890";
$mockWebhookSecret = "webhook_secret_xyz";
$storeId = WooCommerceStore::saveStore(
$companyId,
$mockStoreUrl,
$mockConsumerKey,
$mockConsumerSecret,
$mockWebhookSecret
);
echo " WooCommerce Store saved. ID: $storeId\n";
// Verify decryption
$store = WooCommerceStore::findByCompany($companyId);
$decrypted = WooCommerceStore::getDecryptedCredentials($store);
if ($decrypted['consumer_key'] === $mockConsumerKey && $decrypted['consumer_secret'] === $mockConsumerSecret) {
echo " ✅ Credentials decryption verified successfully.\n";
} else {
echo " ❌ Credentials decryption FAILED.\n";
}
// 3. Test Phone Trailing Digit Matching
echo "3. Testing phone trailing digit comparison helper...\n";
$testCases = [
['+966555555555', '0555555555', true],
['00966555555555', '966555555555', true],
['0555555555', '555555555', true],
['962799999999', '0799999999', true],
['12345', '12345', true],
['12345', '54321', false],
];
foreach ($testCases as $case) {
$res = WooCommerceService::comparePhones($case[0], $case[1]);
$status = ($res === $case[2]) ? "PASS" : "FAIL";
echo " Compare '{$case[0]}' with '{$case[1]}': " . ($res ? "MATCH" : "NO MATCH") . " ($status)\n";
}
// 4. Test Subscription Limits and Dynamic Reset (تصفير ديناميكي)
echo "\n4. Testing Subscription Limits and Usage...\n";
// Insert a Custom Plan for testing
// ID 999 Plan: Max 3 requests, 1 voice note, 1 ocr
Database::execute("INSERT INTO subscription_plans (id, name, price, max_sessions, max_requests, max_voice_requests, max_ocr_requests, features) VALUES (999, 'Test Plan', 0.00, 1, 3, 1, 1, '{}') ON DUPLICATE KEY UPDATE max_requests=3, max_voice_requests=1, max_ocr_requests=1");
// Create active subscription starting 5 days ago and ending 25 days from now
$startsAt = date('Y-m-d H:i:s', strtotime('-5 days'));
$endsAt = date('Y-m-d H:i:s', strtotime('+25 days'));
Database::execute(
"INSERT INTO company_subscriptions (company_id, plan_id, status, starts_at, ends_at) VALUES (?, 999, 'active', ?, ?)",
[$companyId, $startsAt, $endsAt]
);
$activeSub = CompanySubscription::findActiveByCompany($companyId);
echo " Active Subscription found: Plan ID {$activeSub['plan_id']}\n";
echo " Billing cycle starts: {$activeSub['starts_at']}, ends: {$activeSub['ends_at']}\n";
// Test dynamic usage record initialization
$usage = CompanySubscriptionUsage::getOrCreateCurrentUsage($companyId, $activeSub);
echo " Initialized usage: Requests={$usage['request_count']}, Voice={$usage['voice_count']}, OCR={$usage['ocr_count']}\n";
// Check limits
echo " Checking initial limits:\n";
echo " - Has request limit? " . (CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request') ? "YES" : "NO") . "\n";
echo " - Has voice limit? " . (CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice') ? "YES" : "NO") . "\n";
echo " - Has OCR limit? " . (CompanySubscriptionUsage::hasRemainingLimit($companyId, 'ocr') ? "YES" : "NO") . "\n";
// Increment and check limits
echo " Incrementing request and voice usage...\n";
CompanySubscriptionUsage::incrementUsage($companyId, 'request');
CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
$usage = CompanySubscriptionUsage::getOrCreateCurrentUsage($companyId, $activeSub);
echo " Current usage: Requests={$usage['request_count']}, Voice={$usage['voice_count']}, OCR={$usage['ocr_count']}\n";
echo " - Has remaining voice limit? " . (CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice') ? "YES" : "NO") . "\n";
// Exceed request limits
echo " Exceeding request limits...\n";
CompanySubscriptionUsage::incrementUsage($companyId, 'request'); // Request 2
CompanySubscriptionUsage::incrementUsage($companyId, 'request'); // Request 3
$usage = CompanySubscriptionUsage::getOrCreateCurrentUsage($companyId, $activeSub);
echo " Current usage: Requests={$usage['request_count']}\n";
echo " - Has remaining request limit? " . (CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request') ? "YES" : "NO") . "\n";
// 5. Test Webhook Signature verification logic
echo "\n5. Testing Webhook Signature verification...\n";
$mockPayload = json_encode(['id' => 1025, 'status' => 'completed', 'total' => '150.00']);
$signature = base64_encode(hash_hmac('sha256', $mockPayload, $mockWebhookSecret, true));
echo " Calculated Signature: $signature\n";
// Verify matching logic
$calculatedSig = base64_encode(hash_hmac('sha256', $mockPayload, $mockWebhookSecret, true));
if (hash_equals($calculatedSig, $signature)) {
echo " ✅ Signature verification logic PASSED.\n";
} else {
echo " ❌ Signature verification logic FAILED.\n";
}
// Clean up mock company after tests
Database::execute("DELETE FROM company_subscriptions WHERE company_id = ?", [$companyId]);
Database::execute("DELETE FROM company_subscription_usage WHERE company_id = ?", [$companyId]);
Database::execute("DELETE FROM woocommerce_stores WHERE company_id = ?", [$companyId]);
Database::execute("DELETE FROM companies WHERE id = ?", [$companyId]);
echo "\n=== Tests Completed successfully! ===\n";