From d686f8928b07104556d7d7cee738607759d5a7bc Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 23 May 2026 03:23:22 +0300 Subject: [PATCH] Deploy: 2026-05-23 03:23:22 --- backend/app/Controllers/AuthController.php | 37 +++- backend/app/Controllers/BillingController.php | 86 ++++++++ .../app/Controllers/SuperAdminController.php | 79 ++++++-- .../app/Controllers/WhatsAppController.php | 22 ++- backend/app/Models/WhatsAppSession.php | 19 ++ .../create_saas_and_woocommerce_tables.sql | 4 +- backend/migrate_billing_tables.php | 39 ++++ backend/public/index.html | 184 +++++++++++++++++- backend/public/index.php | 5 + backend/seed_default_plans.php | 12 +- 10 files changed, 463 insertions(+), 24 deletions(-) create mode 100644 backend/app/Controllers/BillingController.php create mode 100644 backend/migrate_billing_tables.php diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php index b88ce0a..e5a8732 100644 --- a/backend/app/Controllers/AuthController.php +++ b/backend/app/Controllers/AuthController.php @@ -51,8 +51,19 @@ class AuthController extends BaseController 'role' => 'admin' ]); + // Automatically Assign 14-Day Free Trial (Plan ID: 4) + $startsAt = date('Y-m-d H:i:s'); + $endsAt = date('Y-m-d H:i:s', strtotime('+14 days')); + \App\Models\CompanySubscription::subscribeCompany( + $companyId, + 4, // Trial Plan ID + 14, // Duration in days + 'auto_trial', + 'system' + ); + $response->json([ - 'message' => 'Company and Admin user registered successfully.', + 'message' => 'Company and Admin user registered successfully. 14-Day Free Trial activated.', 'company_id' => $companyId, 'user_id' => $userId ], 201); @@ -133,15 +144,35 @@ class AuthController extends BaseController return; } + $isSuperAdmin = (int)$user['company_id'] === 1; + + // Fetch subscription info for the UI + $subscription = \App\Models\CompanySubscription::findActiveByCompany($user['company_id']); + $subStatus = $subscription ? $subscription['status'] : 'expired'; + $trialDaysLeft = 0; + + if ($subscription && $subscription['status'] === 'active' && strpos((string)$subscription['payment_gateway'], 'auto_trial') !== false) { + $subStatus = 'trialing'; // Treat as trialing + } + + if ($subStatus === 'trialing' || ($subscription && $subscription['plan_id'] == 4)) { + $endsAt = strtotime($subscription['ends_at']); + $now = time(); + $trialDaysLeft = max(0, ceil(($endsAt - $now) / 86400)); + } + $response->json([ 'user' => [ 'id' => $user['id'], 'company_id' => $user['company_id'], 'name' => $user['name'], - 'email' => Security::decrypt($user['email']), // Decrypt email before sending back + 'email' => Security::decrypt($user['email']), 'role' => $user['role'], 'status' => $user['status'], - 'created_at' => $user['created_at'] + 'created_at' => $user['created_at'], + 'is_super_admin' => $isSuperAdmin, + 'subscription_status' => $subStatus, + 'trial_days_left' => $trialDaysLeft ] ]); } diff --git a/backend/app/Controllers/BillingController.php b/backend/app/Controllers/BillingController.php new file mode 100644 index 0000000..9dfe00c --- /dev/null +++ b/backend/app/Controllers/BillingController.php @@ -0,0 +1,86 @@ + 0 ORDER BY price ASC"); + + $response->json([ + 'status' => 'success', + 'data' => $plans + ]); + } + + /** + * Upgrade or submit payment for a plan + * POST /api/billing/upgrade + */ + public function upgrade(Request $request, Response $response): void + { + $companyId = $request->company_id; + $body = $request->getBody(); + + $planId = $body['plan_id'] ?? null; + $paymentMethod = $body['payment_method'] ?? 'manual'; // 'paymob', 'cliq', 'binance', etc. + $receiptReference = $body['receipt_reference'] ?? null; + + if (!$planId) { + $response->status(400)->json(['error' => 'Missing plan_id']); + return; + } + + $plan = Database::selectOne("SELECT * FROM subscription_plans WHERE id = ?", [$planId]); + if (!$plan) { + $response->status(404)->json(['error' => 'Plan not found']); + return; + } + + try { + if ($paymentMethod === 'paymob') { + // Here we would integrate Paymob API to generate a payment link + // For now, we simulate returning a checkout URL. + $checkoutUrl = "https://paymob.com/checkout/mock_url_for_plan_{$planId}_company_{$companyId}"; + + $response->json([ + 'status' => 'success', + 'message' => 'Redirect to Paymob to complete payment', + 'checkout_url' => $checkoutUrl + ]); + } else { + // Manual Payment (CliQ, Binance, Bank Transfer) + if (empty($receiptReference)) { + $response->status(400)->json(['error' => 'Please provide a receipt reference or transaction ID']); + return; + } + + // Delete any existing pending requests for this company to avoid spam + Database::execute("DELETE FROM company_subscriptions WHERE company_id = ? AND status = 'pending_approval'", [$companyId]); + + // Insert a pending subscription request + Database::execute(" + INSERT INTO company_subscriptions (company_id, plan_id, status, starts_at, ends_at, payment_method, receipt_reference) + VALUES (?, ?, 'pending_approval', NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), ?, ?) + ", [$companyId, $planId, $paymentMethod, $receiptReference]); + + $response->json([ + 'status' => 'success', + 'message' => 'Payment receipt submitted successfully. Your account will be upgraded after admin approval.' + ]); + } + } catch (\Exception $e) { + $response->status(500)->json(['error' => 'Failed to process upgrade request: ' . $e->getMessage()]); + } + } +} diff --git a/backend/app/Controllers/SuperAdminController.php b/backend/app/Controllers/SuperAdminController.php index 791ae66..48a3e47 100644 --- a/backend/app/Controllers/SuperAdminController.php +++ b/backend/app/Controllers/SuperAdminController.php @@ -46,24 +46,28 @@ class SuperAdminController extends BaseController cs.plan_id, sp.name as plan_name, cs.status as subscription_status, - cs.starts_at as subscription_starts, - cs.ends_at as subscription_ends, - (SELECT COUNT(*) FROM whatsapp_sessions WHERE company_id = c.id) as sessions_count, - (SELECT COUNT(*) FROM whatsapp_sessions WHERE company_id = c.id AND status = 'connected') as active_sessions, - COALESCE(cu.request_count, 0) as request_usage, - COALESCE(cu.voice_count, 0) as voice_usage, - COALESCE(cu.ocr_count, 0) as ocr_usage + SELECT c.id, c.name, c.status as company_status, c.created_at, + cs.plan_id, cs.status as sub_status, cs.starts_at, cs.ends_at, cs.payment_method, cs.receipt_reference, + p.name as plan_name FROM companies c - LEFT JOIN company_subscriptions cs ON cs.company_id = c.id AND cs.status = 'active' - LEFT JOIN subscription_plans sp ON cs.plan_id = sp.id - LEFT JOIN company_subscription_usage cu ON cu.company_id = c.id - AND cu.billing_start <= CURRENT_DATE() - AND cu.billing_end >= CURRENT_DATE() - ORDER BY c.id ASC + LEFT JOIN company_subscriptions cs ON c.id = cs.company_id AND cs.status IN ('active', 'trialing', 'pending_approval') + LEFT JOIN subscription_plans p ON cs.plan_id = p.id + ORDER BY c.created_at DESC "); - // Fetch list of available subscription plans - $plans = Database::select("SELECT id, name, price, max_sessions FROM subscription_plans ORDER BY price ASC"); + $pendingApprovals = []; + $activeCompanies = []; + + foreach ($companies as $c) { + if ($c['sub_status'] === 'pending_approval') { + $pendingApprovals[] = $c; + } else { + $activeCompanies[] = $c; + } + } + + // 4. Fetch all available plans to allow admin to upgrade them manually + $plans = Database::select("SELECT id, name, price FROM subscription_plans ORDER BY price ASC"); $response->json([ 'status' => 'success', @@ -73,7 +77,8 @@ class SuperAdminController extends BaseController 'total_sessions' => (int)$sessionsCount, 'connected_sessions' => (int)$connectedSessions ], - 'companies' => $companies, + 'companies' => $activeCompanies, + 'pending_approvals' => $pendingApprovals, 'plans' => $plans ] ]); @@ -138,4 +143,46 @@ class SuperAdminController extends BaseController $response->status(500)->json(['error' => 'Failed to update subscription: ' . $e->getMessage()]); } } + + /** + * Approve a pending billing request + * POST /api/admin/companies/approve-billing + */ + public function approveBilling(Request $request, Response $response): void + { + $body = $request->getBody(); + $targetCompanyId = isset($body['company_id']) ? (int)$body['company_id'] : null; + + if (!$targetCompanyId) { + $response->status(400)->json(['error' => 'Missing company_id']); + return; + } + + try { + // Find the pending subscription + $pending = Database::selectOne("SELECT * FROM company_subscriptions WHERE company_id = ? AND status = 'pending_approval' ORDER BY id DESC LIMIT 1", [$targetCompanyId]); + if (!$pending) { + $response->status(404)->json(['error' => 'No pending approval found for this company']); + return; + } + + // Deactivate other active/trialing subscriptions + Database::execute("UPDATE company_subscriptions SET status = 'expired' WHERE company_id = ? AND status IN ('active', 'trialing')", [$targetCompanyId]); + + // Mark the pending one as active + Database::execute("UPDATE company_subscriptions SET status = 'active' WHERE id = ?", [$pending['id']]); + + // Clear active subscription cache for the company + if (class_exists('App\Core\Cache')) { + \App\Core\Cache::delete("company_subscription_{$targetCompanyId}"); + } + + $response->json([ + 'status' => 'success', + 'message' => 'Billing approved and subscription activated successfully' + ]); + } catch (\Exception $e) { + $response->status(500)->json(['error' => 'Failed to approve billing: ' . $e->getMessage()]); + } + } } diff --git a/backend/app/Controllers/WhatsAppController.php b/backend/app/Controllers/WhatsAppController.php index 8001802..c6e2890 100644 --- a/backend/app/Controllers/WhatsAppController.php +++ b/backend/app/Controllers/WhatsAppController.php @@ -442,7 +442,27 @@ class WhatsAppController extends BaseController } elseif ($body['state'] === 'connected') { $updateData['qr_code'] = null; // Clear QR when connected if (!empty($body['phone'])) { - $updateData['phone'] = $body['phone']; + // Anti-Abuse: Prevent Duplicate Phone Numbers Across Companies + $existingPhoneSession = \App\Models\WhatsAppSession::findByPhone($body['phone']); + if ($existingPhoneSession && (int)$existingPhoneSession['company_id'] !== (int)$session['company_id']) { + // This phone is already linked to another company! We must disconnect this malicious session. + $nodeUrl = 'http://127.0.0.1:3722/api/sessions/delete'; + $payload = json_encode(['session_key' => $session['session_key']]); + $ch = curl_init($nodeUrl); + 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']); + curl_exec($ch); + curl_close($ch); + + error_log("Anti-Abuse Block: Company ID {$session['company_id']} tried to link phone {$body['phone']} which is already linked to Company ID {$existingPhoneSession['company_id']}. Session disconnected."); + + $updateData['status'] = 'disconnected'; + $updateData['qr_code'] = null; + } else { + $updateData['phone'] = $body['phone']; + } } } elseif ($body['state'] === 'disconnected') { $updateData['qr_code'] = null; diff --git a/backend/app/Models/WhatsAppSession.php b/backend/app/Models/WhatsAppSession.php index 7068eac..179e058 100644 --- a/backend/app/Models/WhatsAppSession.php +++ b/backend/app/Models/WhatsAppSession.php @@ -86,6 +86,25 @@ class WhatsAppSession extends BaseModel return $session; } + /** + * Find session by phone number (useful to prevent duplicates across companies) + */ + public static function findByPhone(string $phone) + { + $phoneHash = Security::blindIndex($phone); + $session = Database::selectOne( + "SELECT * FROM " . static::$table . " WHERE phone_hash = ? LIMIT 1", + [$phoneHash] + ); + + if ($session) { + $session['phone'] = $session['phone'] ? Security::decrypt($session['phone']) : null; + $session['qr_code'] = $session['qr_code'] ? Security::decrypt($session['qr_code']) : null; + } + + return $session; + } + /** * Create or retrieve a new session for a company */ diff --git a/backend/create_saas_and_woocommerce_tables.sql b/backend/create_saas_and_woocommerce_tables.sql index 042f74f..25c71de 100644 --- a/backend/create_saas_and_woocommerce_tables.sql +++ b/backend/create_saas_and_woocommerce_tables.sql @@ -22,11 +22,13 @@ 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', + `status` ENUM('active', 'trialing', 'canceled', 'expired', 'past_due', 'pending_approval') DEFAULT 'active', `starts_at` TIMESTAMP NOT NULL, `ends_at` TIMESTAMP NOT NULL, `canceled_at` TIMESTAMP NULL DEFAULT NULL, `payment_gateway` VARCHAR(50) NULL, + `payment_method` VARCHAR(50) NULL COMMENT 'paymob, cliq, binance', + `receipt_reference` VARCHAR(255) NULL COMMENT 'External transaction ID or receipt URL', `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, diff --git a/backend/migrate_billing_tables.php b/backend/migrate_billing_tables.php new file mode 100644 index 0000000..97e0413 --- /dev/null +++ b/backend/migrate_billing_tables.php @@ -0,0 +1,39 @@ +getMessage() . "\n"; +} diff --git a/backend/public/index.html b/backend/public/index.html index 4230e76..a6eb2bd 100644 --- a/backend/public/index.html +++ b/backend/public/index.html @@ -760,7 +760,7 @@