Deploy: 2026-05-22 23:55:19
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
271
backend/app/Controllers/WooCommerceController.php
Normal file
271
backend/app/Controllers/WooCommerceController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
51
backend/app/Middlewares/SubscriptionMiddleware.php
Normal file
51
backend/app/Middlewares/SubscriptionMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
backend/app/Models/CompanySubscription.php
Normal file
60
backend/app/Models/CompanySubscription.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
117
backend/app/Models/CompanySubscriptionUsage.php
Normal file
117
backend/app/Models/CompanySubscriptionUsage.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
backend/app/Models/SubscriptionPlan.php
Normal file
25
backend/app/Models/SubscriptionPlan.php
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
75
backend/app/Models/WooCommerceStore.php
Normal file
75
backend/app/Models/WooCommerceStore.php
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
119
backend/app/Services/WooCommerceService.php
Normal file
119
backend/app/Services/WooCommerceService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
backend/create_saas_and_woocommerce_tables.sql
Normal file
76
backend/create_saas_and_woocommerce_tables.sql
Normal 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`);
|
||||
@@ -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) {
|
||||
|
||||
35
backend/public/run_migrations_temp.php
Normal file
35
backend/public/run_migrations_temp.php
Normal 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";
|
||||
}
|
||||
141
backend/public/test_woocommerce_limits.php
Normal file
141
backend/public/test_woocommerce_limits.php
Normal 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";
|
||||
Reference in New Issue
Block a user