From cfbd9c000990aedee67c18304ba0fd28b71ac609 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 5 May 2026 01:44:06 +0300 Subject: [PATCH] Update: 2026-05-05 01:44:06 --- app/modules_app/invoices/upload.php | 100 +++++---- scripts/PROJECT_DOCUMENTATION.md | 323 +++++++++++++++++++++++++++- 2 files changed, 374 insertions(+), 49 deletions(-) diff --git a/app/modules_app/invoices/upload.php b/app/modules_app/invoices/upload.php index bdcab14..6c1893d 100644 --- a/app/modules_app/invoices/upload.php +++ b/app/modules_app/invoices/upload.php @@ -5,6 +5,9 @@ use App\Core\Database; use App\Middleware\AuthMiddleware; +use App\Core\AI; +use App\Core\Encryption; +use App\Middleware\QuotaMiddleware; // 1. Auth Check $decoded = AuthMiddleware::check(); @@ -12,33 +15,31 @@ $tenantId = $decoded['tenant_id']; $userId = $decoded['user_id']; // --- QUOTA CHECK --- -\App\Middleware\QuotaMiddleware::checkInvoiceQuota($tenantId); +QuotaMiddleware::checkInvoiceQuota($tenantId); // ------------------- $db = Database::getInstance(); $allowedRoles = ['admin', 'accountant', 'employee']; if (!in_array($decoded['role'], $allowedRoles)) { - json_error('Unauthorized to upload invoices', 403); + json_error('غير مصرح لك برفع الفواتير', 403); } + // 2. Validate Request $data = input(); $companyId = $data['company_id'] ?? null; if (!$companyId || !isset($_FILES['invoice'])) { - json_error('Company ID and invoice file are required', 422); + json_error('رقم الشركة وملف الفاتورة مطلوبان', 422); } // 3. Permission Check -$tenantId = $decoded['tenant_id']; -$userId = $decoded['user_id']; - // Everyone (except Super Admin) must belong to the same tenant as the company $stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL"); $stmt->execute([$companyId, $tenantId]); if (!$stmt->fetch()) { - json_error('Access denied to this company or invalid company ID', 403); + json_error('الوصول مرفوض لهذه الشركة أو رقم الشركة غير صحيح', 403); } // 4. Handle File Upload (Step-by-step for permission safety) @@ -70,23 +71,30 @@ if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) { json_error('فشل في قراءة الملف المرفوع', 500); } $base64Data = base64_encode($fileContent); - - $extracted = \App\Core\AI::extractInvoiceData($base64Data, $mimeType); - + + $extracted = AI::extractInvoiceData($base64Data, $mimeType); + if (!$extracted) { - // Still save basic record if AI fails - $stmt = $db->prepare("INSERT INTO invoices (tenant_id, company_id, uploaded_by, original_file_path, status, created_at) VALUES (?, ?, ?, ?, 'uploaded', NOW())"); - $stmt->execute([$tenantId, $companyId, $userId, $targetFile]); - json_success(['id' => $db->lastInsertId()], 'تم رفع الفاتورة ولكن فشل استخراج البيانات تلقائياً'); + // Still save basic record if AI fails, ensuring all NOT NULL and new columns are met + $invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); + $stmt = $db->prepare(" + INSERT INTO invoices ( + id, tenant_id, company_id, uploaded_by, original_file_path, status, created_at + ) VALUES ( + ?, ?, ?, ?, ?, 'uploaded', NOW() + ) + "); + $stmt->execute([$invoiceId, $tenantId, $companyId, $userId, $targetFile]); + json_success(['id' => $invoiceId], 'تم رفع الفاتورة ولكن فشل استخراج البيانات تلقائياً'); } // 6. Save Extracted Data with Encryption try { $db->beginTransaction(); - // 5.5 Duplicate Prevention Check (Now inside transaction for safety) + // 5.5 Duplicate Prevention Check $supplierTin = $extracted['supplier']['tin'] ?? ''; - $invoiceNum = $extracted['invoice_number'] ?? ''; + $invoiceNum = $extracted['invoice_number'] ?? ''; $invoiceDate = $extracted['invoice_date'] ?? ''; $invoiceHash = null; @@ -102,10 +110,6 @@ if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) { } } - $invoiceId = bin2hex(random_bytes(16)); // Generate UUID - // Let's use a standard UUID format if possible, but MySQL CHAR(36) accepts anything. - // Actually, let's just use the DB's UUID() function but FETCH it back or generate it here. - // I'll use a better UUID generator logic. $invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); $stmt = $db->prepare(" @@ -127,38 +131,40 @@ if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) { "); $stmt->execute([ - 'id' => $invoiceId, - 'tenant_id' => $tenantId, + 'id' => $invoiceId, + 'tenant_id' => $tenantId, 'company_id' => $companyId, - 'uploaded_by'=> $userId, - 'path' => $targetFile, - 'num' => $extracted['invoice_number'] ?? null, - 'date' => $extracted['invoice_date'] ?? null, - 'type' => $extracted['invoice_type'] ?? 'cash', - 'cat' => $extracted['invoice_category'] ?? 'simplified', - 's_tin' => \App\Core\Encryption::encrypt($extracted['supplier']['tin'] ?? ''), - 's_name' => \App\Core\Encryption::encrypt($extracted['supplier']['name'] ?? ''), - 's_addr' => \App\Core\Encryption::encrypt($extracted['supplier']['address'] ?? ''), - 'b_tin' => \App\Core\Encryption::encrypt($extracted['buyer']['tin'] ?? ''), - 'b_name' => \App\Core\Encryption::encrypt($extracted['buyer']['name'] ?? ''), - 'b_nid' => \App\Core\Encryption::encrypt($extracted['buyer']['national_id'] ?? ''), - 'sub' => $extracted['subtotal'] ?? 0, - 'tax' => $extracted['tax_amount'] ?? 0, - 'disc' => $extracted['discount_total'] ?? 0, - 'total' => $extracted['grand_total'] ?? 0, - 'cur' => $extracted['currency_code'] ?? 'JOD', - 'hash' => $invoiceHash, - 'warnings' => isset($extracted['validation_warnings']) && !empty($extracted['validation_warnings']) ? json_encode($extracted['validation_warnings']) : null + 'uploaded_by' => $userId, + 'path' => $targetFile, + 'num' => $extracted['invoice_number'] ?? null, + 'date' => $extracted['invoice_date'] ?? null, + 'type' => $extracted['invoice_type'] ?? 'cash', + 'cat' => $extracted['invoice_category'] ?? 'simplified', + 's_tin' => Encryption::encrypt($extracted['supplier']['tin'] ?? ''), + 's_name' => Encryption::encrypt($extracted['supplier']['name'] ?? ''), + 's_addr' => Encryption::encrypt($extracted['supplier']['address'] ?? ''), + 'b_tin' => Encryption::encrypt($extracted['buyer']['tin'] ?? ''), + 'b_name' => Encryption::encrypt($extracted['buyer']['name'] ?? ''), + 'b_nid' => Encryption::encrypt($extracted['buyer']['national_id'] ?? ''), + 'sub' => $extracted['subtotal'] ?? 0, + 'tax' => $extracted['tax_amount'] ?? 0, + 'disc' => $extracted['discount_total'] ?? 0, + 'total' => $extracted['grand_total'] ?? 0, + 'cur' => $extracted['currency_code'] ?? 'JOD', + 'hash' => $invoiceHash, + 'warnings' => isset($extracted['validation_warnings']) && !empty($extracted['validation_warnings']) ? json_encode($extracted['validation_warnings']) : null ]); // Save Line Items if (!empty($extracted['lines'])) { $lineStmt = $db->prepare(" - INSERT INTO invoice_lines (invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); foreach ($extracted['lines'] as $item) { + $lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); $lineStmt->execute([ + $lineId, $invoiceId, $item['line_number'] ?? 1, $item['description'] ?? 'N/A', @@ -171,11 +177,11 @@ if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) { } $db->commit(); - + // --- INCREMENT QUOTA --- - \App\Middleware\QuotaMiddleware::incrementInvoiceUsage($tenantId); + QuotaMiddleware::incrementInvoiceUsage($tenantId); // ----------------------- - + json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح'); } catch (\Exception $e) { @@ -187,4 +193,4 @@ if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) { $uploadError = $_FILES['invoice']['error'] ?? 'Unknown'; error_log("UPLOAD ERROR: move_uploaded_file failed. Error Code: $uploadError. Target: $targetFile. Tmp: " . ($_FILES['invoice']['tmp_name'] ?? 'N/A')); json_error('Failed to save uploaded file. PHP Error Code: ' . $uploadError, 500); -} +} \ No newline at end of file diff --git a/scripts/PROJECT_DOCUMENTATION.md b/scripts/PROJECT_DOCUMENTATION.md index ab34412..dd3a465 100644 --- a/scripts/PROJECT_DOCUMENTATION.md +++ b/scripts/PROJECT_DOCUMENTATION.md @@ -241,6 +241,251 @@ CREATE TABLE audit_logs ( ); ``` +## File: `migrate_phase2.php` + +```php + "ALTER TABLE invoices ADD COLUMN invoice_hash VARCHAR(64) NULL", + + // 2. Add validation_warnings for AI Pre-Audit + 'add_validation_warnings' => "ALTER TABLE invoices ADD COLUMN validation_warnings JSON NULL", + + // 2.5 Add deleted_at for soft delete (Missing in Phase 1 for this table) + 'add_invoices_soft_delete' => "ALTER TABLE invoices ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL", + + // 3. Create Unique Index to prevent duplicates within the same tenant & company + // Using a regular index for now, application logic will handle uniqueness to allow nulls + 'add_hash_index' => "CREATE INDEX idx_invoice_hash ON invoices(tenant_id, company_id, invoice_hash)", +]; + +$success = 0; +$skipped = 0; +$failed = 0; + +foreach ($migrations as $name => $sql) { + try { + $db->exec($sql); + echo " ✅ {$name}\n"; + $success++; + } catch (\PDOException $e) { + $msg = $e->getMessage(); + // Ignore "duplicate column" (1060), "duplicate key name" (1061), or "already exists" errors + if (str_contains($msg, 'Duplicate column') || str_contains($msg, 'Duplicate key name') || str_contains($msg, 'already exists')) { + echo " ⏭️ {$name} (already exists)\n"; + $skipped++; + } else { + echo " ❌ {$name}: {$msg}\n"; + $failed++; + } + } +} + +echo "\n═══════════════════════════════════════════\n"; +echo " Migration Complete!\n"; +echo " ✅ Success: {$success} | ⏭️ Skipped: {$skipped} | ❌ Failed: {$failed}\n"; +echo "═══════════════════════════════════════════\n"; + +``` + +## File: `migrate_phase1.php` + +```php + "ALTER TABLE companies ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL", + + // 2. Add deleted_at to users + 'users_soft_delete' => "ALTER TABLE users ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL", + + // 3. Add email_hash to users (if missing) + 'users_email_hash' => "ALTER TABLE users ADD COLUMN email_hash VARCHAR(64) NULL", + + // 4. Create subscription_plans table + 'subscription_plans_table' => " + CREATE TABLE IF NOT EXISTS subscription_plans ( + id VARCHAR(20) PRIMARY KEY, + name_ar VARCHAR(100) NOT NULL, + name_en VARCHAR(100) NOT NULL, + max_companies INT NOT NULL DEFAULT 1, + max_invoices_month INT NOT NULL DEFAULT 30, + max_users INT NOT NULL DEFAULT 2, + price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00, + ai_features BOOLEAN DEFAULT FALSE, + jofotara_enabled BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + features_json JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + ", + + // 4.5 Fix collation if table already exists + 'subscription_plans_collation_fix' => "ALTER TABLE subscription_plans CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + + // 5. Ensure subscriptions table exists with all needed columns + 'subscriptions_table' => " + CREATE TABLE IF NOT EXISTS subscriptions ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL UNIQUE, + plan_id VARCHAR(20) NOT NULL DEFAULT 'free', + max_companies INT NOT NULL DEFAULT 1, + max_invoices_per_month INT NOT NULL DEFAULT 15, + max_users INT NOT NULL DEFAULT 1, + price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00, + invoices_used_this_month INT NOT NULL DEFAULT 0, + status ENUM('active','past_due','cancelled','trial') DEFAULT 'trial', + current_period_start DATETIME NULL, + current_period_end DATETIME NULL, + trial_ends_at DATETIME NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (plan_id) REFERENCES subscription_plans(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + ", + + // 5.5 Fix collation if table already exists + 'subscriptions_collation_fix' => "ALTER TABLE subscriptions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + + // 6. Add plan_id column to subscriptions if upgrading from old schema + 'subscriptions_plan_id' => "ALTER TABLE subscriptions ADD COLUMN plan_id VARCHAR(20) NOT NULL DEFAULT 'free'", + + // 7. Add max_users column to subscriptions if missing + 'subscriptions_max_users' => "ALTER TABLE subscriptions ADD COLUMN max_users INT NOT NULL DEFAULT 1", + + // 8. Add trial_ends_at to subscriptions if missing + 'subscriptions_trial' => "ALTER TABLE subscriptions ADD COLUMN trial_ends_at DATETIME NULL", + + // 9. Index on subscriptions status + 'subscriptions_status_idx' => "CREATE INDEX idx_sub_status ON subscriptions(status)", +]; + +$success = 0; +$skipped = 0; +$failed = 0; + +foreach ($migrations as $name => $sql) { + try { + $db->exec($sql); + echo " ✅ {$name}\n"; + $success++; + } catch (\PDOException $e) { + $msg = $e->getMessage(); + // Ignore "duplicate column" (1060), "duplicate key name" (1061), or "already exists" errors + if (str_contains($msg, 'Duplicate column') || str_contains($msg, 'Duplicate key name') || str_contains($msg, 'already exists')) { + echo " ⏭️ {$name} (already exists)\n"; + $skipped++; + } else { + echo " ❌ {$name}: {$msg}\n"; + $failed++; + } + } +} + +echo "\n───────────────────────────────────────────\n"; + +// Seed subscription plans +echo "\n📦 Seeding subscription plans...\n"; + +$plans = [ + ['free', 'مجانية', 'Free', 1, 15, 1, 0.00, 1, 1, 10], + ['basic', 'أساسية', 'Basic', 3, 100, 3, 15.00, 1, 1, 20], + ['office', 'مكتبية', 'Office', 10, 500, 10, 45.00, 1, 1, 30], + ['pro', 'احترافية', 'Pro', 25, 2000, 25, 99.00, 1, 1, 40], + ['enterprise', 'مؤسسية', 'Enterprise', 999, 99999, 999, 249.00, 1, 1, 50], +]; + +$planStmt = $db->prepare(" + INSERT INTO subscription_plans (id, name_ar, name_en, max_companies, max_invoices_month, max_users, price_jod, ai_features, jofotara_enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name_ar = VALUES(name_ar), + name_en = VALUES(name_en), + max_companies = VALUES(max_companies), + max_invoices_month = VALUES(max_invoices_month), + max_users = VALUES(max_users), + price_jod = VALUES(price_jod), + ai_features = VALUES(ai_features), + jofotara_enabled = VALUES(jofotara_enabled), + sort_order = VALUES(sort_order) +"); + +foreach ($plans as $plan) { + $planStmt->execute($plan); + echo " ✅ Plan: {$plan[0]} ({$plan[1]})\n"; +} + +// Auto-assign 'free' plan to any tenant without a subscription +echo "\n🔗 Auto-assigning free plan to tenants without subscriptions...\n"; + +$stmt = $db->query(" + SELECT t.id FROM tenants t + LEFT JOIN subscriptions s ON s.tenant_id = t.id + WHERE s.id IS NULL +"); +$orphanTenants = $stmt->fetchAll(); + +if (!empty($orphanTenants)) { + $insertSub = $db->prepare(" + INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, trial_ends_at) + VALUES (?, 'free', 1, 15, 1, 0.00, 'trial', NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), DATE_ADD(NOW(), INTERVAL 14 DAY)) + "); + foreach ($orphanTenants as $tenant) { + try { + $insertSub->execute([$tenant['id']]); + echo " ✅ Assigned free plan to tenant: {$tenant['id']}\n"; + } catch (\Exception $e) { + echo " ⚠️ Tenant {$tenant['id']}: " . $e->getMessage() . "\n"; + } + } +} else { + echo " ℹ️ All tenants already have subscriptions.\n"; +} + +echo "\n═══════════════════════════════════════════\n"; +echo " Migration Complete!\n"; +echo " ✅ Success: {$success} | ⏭️ Skipped: {$skipped} | ❌ Failed: {$failed}\n"; +echo "═══════════════════════════════════════════\n"; + +``` + ## File: `migrate.php` ```php @@ -371,7 +616,7 @@ try { // 2. Setup Super Admin details $adminEmail = 'admin@musadaq.app'; $adminName = 'Hamza'; - $adminPassword = 'password123'; // Default password + $adminPassword = env('SEED_ADMIN_PASSWORD', 'password123'); // Default for dev only // Check if user already exists $emailHash = hash('sha256', strtolower($adminEmail)); @@ -404,7 +649,7 @@ try { echo "[OK] Super Admin created successfully!\n"; echo "----------------------------------------\n"; echo "Email: $adminEmail\n"; - echo "Password: $adminPassword\n"; + echo "Password: [FROM ENV]\n"; echo "Role: super_admin\n"; echo "----------------------------------------\n"; } @@ -419,6 +664,80 @@ try { ``` +## File: `backfill_hashes.php` + +```php +query("SELECT id, company_id, supplier_tin, invoice_number, invoice_date FROM invoices WHERE invoice_hash IS NULL"); +$invoices = $stmt->fetchAll(); + +if (empty($invoices)) { + echo "✅ No invoices found needing a hash update.\n"; + exit; +} + +echo "📦 Found " . count($invoices) . " invoices to process...\n"; + +$updated = 0; +$skipped = 0; + +foreach ($invoices as $inv) { + // Decrypt supplier_tin if it's encrypted (assuming it is based on upload logic) + $rawTin = Encryption::decrypt($inv['supplier_tin'] ?? '') ?: ($inv['supplier_tin'] ?? ''); + + $invoiceNum = $inv['invoice_number'] ?? ''; + $invoiceDate = $inv['invoice_date'] ?? ''; + + if (empty($rawTin) || empty($invoiceNum) || empty($invoiceDate)) { + echo " ⚠️ Skipping invoice [{$inv['id']}]: Missing critical data for hash calculation.\n"; + $skipped++; + continue; + } + + // Generate Hash + $rawHashString = $inv['company_id'] . '_' . $rawTin . '_' . $invoiceNum . '_' . $invoiceDate; + $invoiceHash = hash('sha256', strtolower($rawHashString)); + + try { + $updateStmt = $db->prepare("UPDATE invoices SET invoice_hash = ? WHERE id = ?"); + $updateStmt->execute([$invoiceHash, $inv['id']]); + $updated++; + echo " ✅ Updated invoice [{$inv['id']}] | Hash: " . substr($invoiceHash, 0, 8) . "...\n"; + } catch (\PDOException $e) { + if (str_contains($e->getMessage(), 'Duplicate entry')) { + echo " 🚫 Duplicate hash found for invoice [{$inv['id']}]. Skipping update.\n"; + $skipped++; + } else { + echo " ❌ Failed to update [{$inv['id']}]: " . $e->getMessage() . "\n"; + $skipped++; + } + } +} + +echo "\n═══════════════════════════════════════════\n"; +echo " Backfill Complete!\n"; +echo " ✅ Updated: {$updated} | ⏭️ Skipped/Failed: {$skipped}\n"; +echo "═══════════════════════════════════════════\n"; + +``` + ## File: `debug_data.php` ```php