diff --git a/backend/app/Controllers/WhatsAppController.php b/backend/app/Controllers/WhatsAppController.php index e65c0a6..aeefc01 100644 --- a/backend/app/Controllers/WhatsAppController.php +++ b/backend/app/Controllers/WhatsAppController.php @@ -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.'; - - // Append real-time info context to Gemini system prompt - if (!empty($infoContext)) { - $systemPrompt .= "\n\n" . $infoContext; - } - if (!empty($sallaContext)) { - $systemPrompt .= "\n\n" . $sallaContext; - } - - // 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']; + // Dynamically fetch WooCommerce order context if connected + $wooContext = ""; + if (!empty($msgData['phone'])) { + $wooContext = $this->fetchWooCommerceOrderContext($session['company_id'], $msgData['phone'], $incomingText); + } + + $systemPrompt = $rule['ai_prompt'] ?: 'You are a helpful customer support assistant.'; + + // 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; + } + + // 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); - - // 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); - } + // 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); } - } 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; + } } diff --git a/backend/app/Controllers/WooCommerceController.php b/backend/app/Controllers/WooCommerceController.php new file mode 100644 index 0000000..9fca89c --- /dev/null +++ b/backend/app/Controllers/WooCommerceController.php @@ -0,0 +1,271 @@ +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); + } + } +} diff --git a/backend/app/Core/Flows/ConversationFlowEngine.php b/backend/app/Core/Flows/ConversationFlowEngine.php index b4a2d81..db6c7e5 100644 --- a/backend/app/Core/Flows/ConversationFlowEngine.php +++ b/backend/app/Core/Flows/ConversationFlowEngine.php @@ -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'])) { diff --git a/backend/app/Core/Flows/DriverRegistrationFlow.php b/backend/app/Core/Flows/DriverRegistrationFlow.php index b65c8f1..4502fe4 100644 --- a/backend/app/Core/Flows/DriverRegistrationFlow.php +++ b/backend/app/Core/Flows/DriverRegistrationFlow.php @@ -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); diff --git a/backend/app/Middlewares/SubscriptionMiddleware.php b/backend/app/Middlewares/SubscriptionMiddleware.php new file mode 100644 index 0000000..cf42437 --- /dev/null +++ b/backend/app/Middlewares/SubscriptionMiddleware.php @@ -0,0 +1,51 @@ +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; + } + } +} diff --git a/backend/app/Models/CompanySubscription.php b/backend/app/Models/CompanySubscription.php new file mode 100644 index 0000000..f79a6cc --- /dev/null +++ b/backend/app/Models/CompanySubscription.php @@ -0,0 +1,60 @@ += ? + 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 + ]); + } +} diff --git a/backend/app/Models/CompanySubscriptionUsage.php b/backend/app/Models/CompanySubscriptionUsage.php new file mode 100644 index 0000000..d805624 --- /dev/null +++ b/backend/app/Models/CompanySubscriptionUsage.php @@ -0,0 +1,117 @@ + $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; + } +} diff --git a/backend/app/Models/SubscriptionPlan.php b/backend/app/Models/SubscriptionPlan.php new file mode 100644 index 0000000..d0093a6 --- /dev/null +++ b/backend/app/Models/SubscriptionPlan.php @@ -0,0 +1,25 @@ + $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] + ); + } +} diff --git a/backend/app/Services/GeminiService.php b/backend/app/Services/GeminiService.php index 381a93d..7a2e1f7 100644 --- a/backend/app/Services/GeminiService.php +++ b/backend/app/Services/GeminiService.php @@ -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)]; } diff --git a/backend/app/Services/WooCommerceService.php b/backend/app/Services/WooCommerceService.php new file mode 100644 index 0000000..4181447 --- /dev/null +++ b/backend/app/Services/WooCommerceService.php @@ -0,0 +1,119 @@ + 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); + } +} diff --git a/backend/create_saas_and_woocommerce_tables.sql b/backend/create_saas_and_woocommerce_tables.sql new file mode 100644 index 0000000..042f74f --- /dev/null +++ b/backend/create_saas_and_woocommerce_tables.sql @@ -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`); diff --git a/backend/public/index.php b/backend/public/index.php index dff205a..80491be 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -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) { diff --git a/backend/public/run_migrations_temp.php b/backend/public/run_migrations_temp.php new file mode 100644 index 0000000..b051925 --- /dev/null +++ b/backend/public/run_migrations_temp.php @@ -0,0 +1,35 @@ +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"; +} diff --git a/backend/public/test_woocommerce_limits.php b/backend/public/test_woocommerce_limits.php new file mode 100644 index 0000000..39ef07d --- /dev/null +++ b/backend/public/test_woocommerce_limits.php @@ -0,0 +1,141 @@ + 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";