Files
musadaq-saas/scripts/PROJECT_DOCUMENTATION.md
2026-05-08 14:05:50 +03:00

1624 lines
62 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Musadaq Project Documentation
This file contains the complete source code of the project (excluding dependencies and sensitive data).
## File: `fix_data.php`
```php
<?php
/**
* Data Reset & Fix Script
* Clears corrupted company/assignment data but keeps Users and Tenants.
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "--- Starting Data Reset ---\n";
try {
$db->beginTransaction();
// 1. Clear corrupted data tables
$db->exec("SET FOREIGN_KEY_CHECKS = 0");
$db->exec("TRUNCATE TABLE user_company_assignments");
$db->exec("TRUNCATE TABLE invoices");
$db->exec("TRUNCATE TABLE companies");
$db->exec("SET FOREIGN_KEY_CHECKS = 1");
echo "[OK] Cleared companies, invoices, and assignments.\n";
// 2. Ensure Super Admin does not have a tenant_id (if your schema allows NULL, else set to empty string)
// Actually, schema.sql says tenant_id CHAR(36) NOT NULL.
// This is a flaw in schema.sql for Super Admins. We will leave users alone for now.
// 3. Fix the admin's tenant_id to match the first available tenant
$stmt = $db->query("SELECT id FROM tenants LIMIT 1");
$tenantId = $stmt->fetchColumn();
if ($tenantId) {
$db->exec("UPDATE users SET tenant_id = '$tenantId' WHERE role != 'super_admin'");
echo "[OK] Linked all non-super-admin users to Tenant ID: $tenantId\n";
}
$db->commit();
echo "--- Reset Complete ---\n";
} catch (\Exception $e) {
$db->rollBack();
echo "[ERROR] Reset failed: " . $e->getMessage() . "\n";
}
```
## File: `update_phone.php`
```php
<?php
/**
* Update User Phone Script (Secure)
* Run: php scripts/update_phone.php --email=admin@musadaq.com --phone=963992952235
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
// Parse CLI arguments
$options = getopt("", ["email:", "phone:", "id:"]);
$email = $options['email'] ?? null;
$phone = $options['phone'] ?? null;
$id = $options['id'] ?? null;
if ((!$email && !$id) || !$phone) {
die("Usage: php scripts/update_phone.php --phone=yourphone [--email=your@email.com | --id=user-uuid]\n");
}
$db = Database::getInstance();
// 1. Sanitize phone
try {
$cleanPhone = preg_replace('/[^0-9+]/', '', $phone);
$phoneHash = hash('sha256', $cleanPhone);
$encryptedPhone = Encryption::encrypt($cleanPhone);
// 2. Update user
if ($id) {
$stmt = $db->prepare("UPDATE users SET phone = ?, phone_hash = ? WHERE id = ?");
$stmt->execute([$encryptedPhone, $phoneHash, $id]);
$identifier = "ID $id";
} else {
// Note: Searching by encrypted email will likely fail due to IV randomness. Use ID.
$stmt = $db->prepare("UPDATE users SET phone = ?, phone_hash = ? WHERE email = ?");
$stmt->execute([$encryptedPhone, $phoneHash, $email]);
$identifier = "email $email";
}
if ($stmt->rowCount() > 0) {
echo "✅ Success! Phone updated for $identifier\n";
echo " Encrypted: $encryptedPhone\n";
echo " Hash: $phoneHash\n";
} else {
echo "❌ Failed. User with $identifier not found or no changes made.\n";
}
} catch (Exception $e) {
echo "❌ Error: " . $e->getMessage() . "\n";
}
```
## File: `migrate_payments.php`
```php
<?php
/**
* Migration Script: Payment System & Subscriptions
*/
declare(strict_types=1);
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
try {
echo "Starting migration...\n";
// 1. Create subscription_plans table
echo "Creating subscription_plans table...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS subscription_plans (
id VARCHAR(50) PRIMARY KEY,
name_ar VARCHAR(255) NOT NULL,
name_en VARCHAR(255) NOT NULL,
max_companies INT NOT NULL,
max_invoices_month INT NOT NULL,
max_users INT NOT NULL,
price_jod DECIMAL(10,3) NOT NULL,
ai_features BOOLEAN DEFAULT TRUE,
jofotara_enabled BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// 2. Insert initial plans
echo "Inserting initial plans...\n";
$plans = require __DIR__ . '/../app/config/plans.php';
$stmt = $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),
price_jod = VALUES(price_jod),
max_companies = VALUES(max_companies),
max_invoices_month = VALUES(max_invoices_month)
");
$order = 0;
foreach ($plans as $id => $plan) {
$stmt->execute([
$id,
$plan['name_ar'],
$plan['name_en'],
$plan['max_companies'],
$plan['max_invoices_month'],
$plan['max_users'],
$plan['price_jod'],
$plan['ai_features'] ? 1 : 0,
$plan['jofotara_enabled'] ? 1 : 0,
$order++
]);
}
// 3. Create payment_requests table
echo "Creating payment_requests table...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS payment_requests (
id CHAR(36) PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
plan_id VARCHAR(50) NOT NULL,
amount_jod DECIMAL(10,3) NOT NULL,
internal_reference VARCHAR(50) UNIQUE NOT NULL,
cliq_alias VARCHAR(100) NOT NULL,
payer_name VARCHAR(255) DEFAULT NULL,
bank_reference VARCHAR(100) DEFAULT NULL,
status ENUM('pending','uploaded','verified','approved','rejected') DEFAULT 'pending',
admin_notes TEXT DEFAULT NULL,
verified_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_status (status),
INDEX idx_bank_ref (bank_reference)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// 4. Create bank_transactions table
echo "Creating bank_transactions table...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS bank_transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
bank_reference VARCHAR(100) UNIQUE NOT NULL,
amount DECIMAL(10,3) NOT NULL,
sender_name VARCHAR(255) DEFAULT NULL,
raw_message TEXT NOT NULL,
is_claimed BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ref (bank_reference)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// 5. Update subscriptions table if needed
echo "Updating subscriptions table schema...\n";
// Check if column plan_id exists, if not add it
$cols = $db->query("SHOW COLUMNS FROM subscriptions")->fetchAll(PDO::FETCH_COLUMN);
if (!in_array('plan_id', $cols)) {
$db->exec("ALTER TABLE subscriptions ADD COLUMN plan_id VARCHAR(50) AFTER tenant_id");
$db->exec("ALTER TABLE subscriptions MODIFY COLUMN plan ENUM('free','basic','office','pro','enterprise') DEFAULT 'free'");
}
if (!in_array('max_users', $cols)) {
$db->exec("ALTER TABLE subscriptions ADD COLUMN max_users INT NOT NULL DEFAULT 1 AFTER max_invoices_per_month");
}
echo "Migration completed successfully!\n";
} catch (\Throwable $e) {
echo "Migration failed: " . $e->getMessage() . "\n";
exit(1);
}
```
## File: `list_users.php`
```php
<?php
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
$users = $db->query("SELECT id, email, name FROM users")->fetchAll();
echo "ID | Email | Name\n";
echo "----------------------\n";
foreach ($users as $user) {
$email = App\Core\Encryption::decrypt($user['email']) ?: $user['email'];
$name = App\Core\Encryption::decrypt($user['name']) ?: $user['name'];
echo "{$user['id']} | $email | $name\n";
}
```
## File: `reset_queue.sql`
```sql
-- Reset failed queue items so the cron worker retries them
UPDATE invoice_processing_queue SET status = 'pending', error_message = NULL WHERE status = 'failed';
-- Verify current queue state
SELECT id, batch_id, status, error_message, created_at FROM invoice_processing_queue ORDER BY created_at;
```
## File: `schema.sql`
```sql
-- ════════════════════════════════════════════════════════════
-- مُصادَق — Database Schema v1.0
-- ════════════════════════════════════════════════════════════
-- Tenants (Accounting Offices)
CREATE TABLE tenants (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
phone VARCHAR(20),
status ENUM('active', 'suspended', 'trial') DEFAULT 'trial',
trial_ends_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Users
CREATE TABLE users (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
phone VARCHAR(255),
role ENUM('super_admin','admin','accountant','viewer') NOT NULL,
company_id CHAR(36) NULL, -- assigned company for accountant
refresh_token_hash VARCHAR(255) NULL,
is_active BOOLEAN DEFAULT TRUE,
last_login_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_tenant_email (tenant_id, email),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- API Keys (for external integrations / mobile scanner)
CREATE TABLE api_keys (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
name VARCHAR(100) NOT NULL,
public_key VARCHAR(64) NOT NULL UNIQUE,
secret_hash VARCHAR(255) NOT NULL, -- bcrypt hash of secret
last_used_at DATETIME NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Companies
CREATE TABLE companies (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL,
name_en VARCHAR(255) NULL,
tax_identification_number VARCHAR(20) NOT NULL,
address TEXT NULL,
jofotara_client_id_encrypted TEXT NULL,
jofotara_secret_key_encrypted TEXT NULL,
jofotara_income_source_sequence VARCHAR(50) NULL,
certificate_path VARCHAR(255) NULL,
certificate_password_encrypted VARCHAR(500) NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name),
INDEX idx_tin (tax_identification_number),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- Subscriptions
CREATE TABLE subscriptions (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL UNIQUE,
plan ENUM('basic','office','pro','enterprise') NOT NULL DEFAULT 'basic',
max_companies INT NOT NULL DEFAULT 5,
max_invoices_per_month INT NOT NULL DEFAULT 100,
price_jod DECIMAL(10,2) NOT NULL DEFAULT 0,
invoices_used_this_month INT NOT NULL DEFAULT 0,
status ENUM('active','past_due','cancelled') DEFAULT 'active',
current_period_start DATETIME NULL,
current_period_end 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
);
-- Invoices
CREATE TABLE invoices (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
invoice_number VARCHAR(100) NULL,
invoice_date DATE NULL,
invoice_type ENUM('cash','credit') DEFAULT 'cash',
ubl_type_code CHAR(3) DEFAULT '388',
payment_method_code CHAR(3) DEFAULT '013',
supplier_tin TEXT NULL,
supplier_name TEXT NULL,
supplier_address TEXT NULL,
buyer_tin TEXT NULL,
buyer_national_id TEXT NULL,
buyer_name TEXT NULL,
subtotal DECIMAL(15,3) DEFAULT 0,
discount_total DECIMAL(15,3) DEFAULT 0,
tax_amount DECIMAL(15,3) DEFAULT 0,
grand_total DECIMAL(15,3) DEFAULT 0,
currency_code CHAR(3) DEFAULT 'JOD',
status ENUM('extracted', 'approved', 'rejected') DEFAULT 'extracted',
jofotara_uuid VARCHAR(255) NULL,
qr_code TEXT NULL,
original_file_path TEXT NULL,
invoice_category VARCHAR(20) DEFAULT 'simplified',
validation_errors JSON NULL,
ai_confidence_score DECIMAL(4,3) NULL,
ai_prompt_tokens INT DEFAULT 0,
ai_completion_tokens INT DEFAULT 0,
ai_total_cost DECIMAL(10,6) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_tenant (tenant_id),
INDEX idx_company (company_id),
INDEX idx_status (status),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- JoFotara Submissions (Audit Trail)
CREATE TABLE jofotara_submissions (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
invoice_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
xml_payload LONGTEXT NULL,
xml_hash VARCHAR(64) NULL,
jofotara_uuid VARCHAR(255) NULL,
qr_code_raw TEXT NULL,
response_code VARCHAR(20) NULL,
response_body JSON NULL,
status ENUM('pending','submitted','accepted','rejected','error') DEFAULT 'pending',
error_message TEXT NULL,
retry_count TINYINT DEFAULT 0,
submitted_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Invoice Lines
CREATE TABLE invoice_lines (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
invoice_id CHAR(36) NOT NULL,
line_number INT NOT NULL,
description TEXT NOT NULL,
quantity DECIMAL(15,3) NOT NULL,
unit_price DECIMAL(15,3) NOT NULL,
discount DECIMAL(15,3) DEFAULT 0,
tax_rate DECIMAL(5,4) NOT NULL,
line_total DECIMAL(15,3) NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
);
-- Audit Logs
CREATE TABLE audit_logs (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NULL,
user_id CHAR(36) NULL,
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(50) NULL,
entity_id CHAR(36) NULL,
old_data JSON NULL,
new_data JSON NULL,
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(500) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant (tenant_id),
INDEX idx_action (action),
INDEX idx_created (created_at)
);
```
## File: `migrate_phase2.php`
```php
<?php
/**
* Phase 2 Migration: AI Pre-Audit & Duplicate Prevention
* Run: php scripts/migrate_phase2.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Phase 2 Migration\n";
echo " AI Pre-Audit & Duplicate Prevention\n";
echo "═══════════════════════════════════════════\n\n";
$migrations = [
// 1. Add invoice_hash for duplicate prevention
'add_invoice_hash' => "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
<?php
/**
* Phase 1 Migration: Subscriptions & Quota System
* Run: php scripts/migrate_phase1.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Phase 1 Migration\n";
echo " Subscriptions & Quota System\n";
echo "═══════════════════════════════════════════\n\n";
$migrations = [
// 1. Add deleted_at to companies
'companies_soft_delete' => "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: `debug_collation.php`
```php
<?php
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
$tables = ['users', 'tenants', 'companies'];
foreach ($tables as $table) {
echo "Table: $table\n";
$stmt = $db->query("SHOW FULL COLUMNS FROM $table WHERE Field = 'id'");
$col = $stmt->fetch(PDO::FETCH_ASSOC);
print_r($col);
echo "--------------------------\n";
}
```
## File: `create_ai_usage_table.sql`
```sql
-- AI Usage Log — Token tracking for cost analysis
CREATE TABLE IF NOT EXISTS ai_usage_log (
id CHAR(36) PRIMARY KEY,
input_tokens INT UNSIGNED NOT NULL DEFAULT 0,
output_tokens INT UNSIGNED NOT NULL DEFAULT 0,
total_tokens INT UNSIGNED NOT NULL DEFAULT 0,
cost_usd DECIMAL(12, 8) NOT NULL DEFAULT 0,
cost_jod DECIMAL(12, 8) NOT NULL DEFAULT 0,
model VARCHAR(50) NOT NULL DEFAULT 'gemini-flash-lite',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_created (created_at),
INDEX idx_model (model)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## File: `migrate.php`
```php
<?php
/**
* Advanced Migration Script: Schema Update + Data Encryption
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
$db = Database::getInstance();
echo "--- Starting Security Migration ---\n";
// 1. Add email_hash column if it doesn't exist
try {
$db->exec("ALTER TABLE users ADD COLUMN email_hash VARCHAR(64) AFTER email, ADD INDEX (email_hash)");
echo "[OK] Added email_hash column and index.\n";
} catch (\Exception $e) {
echo "[SKIP] email_hash column might already exist.\n";
}
// 2. Fetch all users to encrypt their data
$stmt = $db->query("SELECT id, name, email FROM users");
$users = $stmt->fetchAll();
echo "Found " . count($users) . " users. Starting encryption...\n";
$updateStmt = $db->prepare("UPDATE users SET name = ?, email = ?, email_hash = ? WHERE id = ?");
foreach ($users as $user) {
// Check if data is already encrypted (to avoid double encryption)
$isAlreadyEncrypted = Encryption::decrypt($user['email']) !== false;
if ($isAlreadyEncrypted) {
echo "User ID {$user['id']} is already encrypted. Skipping.\n";
continue;
}
// Encrypt Name
$encryptedName = Encryption::encrypt($user['name']);
// Encrypt Email
$encryptedEmail = Encryption::encrypt($user['email']);
// Generate Hash for lookup
$emailHash = hash('sha256', strtolower($user['email']));
$updateStmt->execute([
$encryptedName,
$encryptedEmail,
$emailHash,
$user['id']
]);
echo "User ID {$user['id']} migrated successfully.\n";
}
// 4. Create user_company_assignments table
try {
$db->exec("CREATE TABLE IF NOT EXISTS user_company_assignments (
id INT AUTO_INCREMENT,
user_id VARCHAR(100) NOT NULL,
company_id VARCHAR(100) NOT NULL,
assigned_by VARCHAR(100) NOT NULL,
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id),
UNIQUE KEY uq_user_company (user_id, company_id),
CONSTRAINT fk_uca_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_uca_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
CONSTRAINT fk_uca_admin FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo "[OK] User_company_assignments table created.\n";
} catch (\Exception $e) {
echo "[SKIP] user_company_assignments table: " . $e->getMessage() . "\n";
}
// 5. Update invoices table to include uploaded_by
try {
$db->exec("ALTER TABLE invoices ADD COLUMN uploaded_by VARCHAR(100) NULL AFTER status");
$db->exec("ALTER TABLE invoices ADD CONSTRAINT fk_inv_uploader FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL");
echo "[OK] Updated invoices table with uploaded_by tracker.\n";
} catch (\Exception $e) {
echo "[SKIP] invoices table update: " . $e->getMessage() . "\n";
}
echo "--- Migration Complete ---\n";
```
## File: `seed_super_admin.php`
```php
<?php
/**
* Seed Super Admin Script
* Run this from CLI: php scripts/seed_super_admin.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
$db = Database::getInstance();
echo "--- Starting Super Admin Seeding ---\n";
try {
$db->beginTransaction();
// 1. We must create a "System Tenant" for the Super Admin to satisfy the Foreign Key constraint
$systemTenantId = '00000000-0000-0000-0000-000000000000';
// Check if system tenant exists
$stmt = $db->prepare("SELECT id FROM tenants WHERE id = ?");
$stmt->execute([$systemTenantId]);
if (!$stmt->fetch()) {
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, status, created_at) VALUES (?, 'System Administration', 'system@musadaq.com', 'active', NOW())");
$stmt->execute([$systemTenantId]);
echo "[OK] System Tenant created.\n";
}
// 2. Setup Super Admin details
$adminEmail = 'admin@musadaq.app';
$adminName = 'Hamza';
$adminPassword = env('SEED_ADMIN_PASSWORD', 'password123'); // Default for dev only
// Check if user already exists
$emailHash = hash('sha256', strtolower($adminEmail));
$stmt = $db->prepare("SELECT id FROM users WHERE email_hash = ?");
$stmt->execute([$emailHash]);
if ($stmt->fetch()) {
echo "[INFO] Super Admin already exists with this email.\n";
} else {
$adminId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$encryptedName = Encryption::encrypt($adminName);
$encryptedEmail = Encryption::encrypt($adminEmail);
$passwordHash = password_hash($adminPassword, PASSWORD_DEFAULT);
$stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, 'super_admin', 1, NOW())");
$stmt->execute([
$adminId,
$systemTenantId,
$encryptedName,
$encryptedEmail,
$emailHash,
$passwordHash
]);
echo "[OK] Super Admin created successfully!\n";
echo "----------------------------------------\n";
echo "Email: $adminEmail\n";
echo "Password: [FROM ENV]\n";
echo "Role: super_admin\n";
echo "----------------------------------------\n";
}
$db->commit();
echo "--- Seeding Complete ---\n";
} catch (\Exception $e) {
$db->rollBack();
echo "[ERROR] Seeding failed: " . $e->getMessage() . "\n";
}
```
## File: `phase1_migration.sql`
```sql
-- ════════════════════════════════════════════════════════════
-- مُصادَق — Phase 1: AI Usage Tracking + Notifications
-- ════════════════════════════════════════════════════════════
-- AI Usage Log (tracks every AI request)
CREATE TABLE IF NOT EXISTS ai_usage_log (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
company_id CHAR(36) NULL,
action_type ENUM('invoice_extraction','voice_transcribe','voice_intent','report_generation','chatbot') NOT NULL,
model_name VARCHAR(50) NOT NULL,
prompt_tokens INT DEFAULT 0,
completion_tokens INT DEFAULT 0,
total_tokens INT DEFAULT 0,
estimated_cost DECIMAL(10,6) DEFAULT 0,
request_metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_date (tenant_id, created_at),
INDEX idx_action (action_type),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Notifications
CREATE TABLE IF NOT EXISTS notifications (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
type ENUM('invoice_processed','invoice_rejected','quota_warning','month_end','system','achievement') NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_tenant (tenant_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Referral Codes (Phase 2 prep)
CREATE TABLE IF NOT EXISTS referral_codes (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id CHAR(36) NOT NULL,
code VARCHAR(20) NOT NULL UNIQUE,
uses_count INT DEFAULT 0,
max_uses INT DEFAULT 50,
reward_months INT DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Referral Uses (Phase 2 prep)
CREATE TABLE IF NOT EXISTS referral_uses (
id INT AUTO_INCREMENT PRIMARY KEY,
code_id INT NOT NULL,
referred_tenant_id CHAR(36) NOT NULL,
reward_applied BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (code_id) REFERENCES referral_codes(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- User Achievements (Phase 2 prep)
CREATE TABLE IF NOT EXISTS user_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id CHAR(36) NOT NULL,
achievement_code VARCHAR(50) NOT NULL,
points INT NOT NULL DEFAULT 0,
earned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_user_achievement (user_id, achievement_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## File: `update_pricing.sql`
```sql
-- Restore original Musadaq subscription pricing
-- Premium pricing justified by AI extraction + JoFotara + mobile app
UPDATE subscription_plans SET
name_ar = 'مجانية', name_en = 'Free',
max_companies = 1, max_invoices_month = 15, max_users = 1,
price_jod = 0.00, jofotara_enabled = 1
WHERE id = 'free';
UPDATE subscription_plans SET
name_ar = 'أساسية', name_en = 'Basic',
max_companies = 3, max_invoices_month = 100, max_users = 3,
price_jod = 15.00, jofotara_enabled = 1
WHERE id = 'basic';
UPDATE subscription_plans SET
name_ar = 'مكتبية', name_en = 'Office',
max_companies = 10, max_invoices_month = 500, max_users = 10,
price_jod = 45.00, jofotara_enabled = 1
WHERE id = 'office';
UPDATE subscription_plans SET
name_ar = 'احترافية', name_en = 'Pro',
max_companies = 25, max_invoices_month = 2000, max_users = 25,
price_jod = 99.00, jofotara_enabled = 1
WHERE id = 'pro';
UPDATE subscription_plans SET
name_ar = 'مؤسسية', name_en = 'Enterprise',
max_companies = 999, max_invoices_month = 99999, max_users = 999,
price_jod = 249.00, jofotara_enabled = 1
WHERE id = 'enterprise';
```
## File: `backfill_hashes.php`
```php
<?php
/**
* Phase 2 Backfill: Generate hashes for existing invoices
* Run: php scripts/backfill_hashes.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Backfill Invoice Hashes\n";
echo "═══════════════════════════════════════════\n\n";
// 1. Fetch all invoices without a hash
$stmt = $db->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: `migrate_phase3_mobile.php`
```php
<?php
/**
* Phase 3 Migration: Mobile App Support
* Run: php scripts/migrate_phase3_mobile.php
*
* Adds tables and columns required for:
* - Mobile OTP Authentication
* - Device Management
* - Batch Invoice Upload from Scanner
* - Processing Queue for AI extraction
* - Notifications System
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Phase 3 Migration\n";
echo " Mobile App + Batch Processing Support\n";
echo "═══════════════════════════════════════════\n\n";
// --- Fetch Parent Collation dynamically ---
$stmt = $db->query("SHOW FULL COLUMNS FROM users WHERE Field = 'id'");
$userCol = $stmt->fetch(PDO::FETCH_ASSOC);
$charsetCollation = "";
if ($userCol && !empty($userCol['Collation'])) {
$collation = $userCol['Collation'];
list($charset) = explode('_', $collation);
$charsetCollation = "CHARACTER SET {$charset} COLLATE {$collation}";
}
$migrations = [
// ─── 1. User Device Management ─────────────────────────
'create_user_devices' => "
CREATE TABLE IF NOT EXISTS user_devices (
id CHAR(36) {$charsetCollation} PRIMARY KEY DEFAULT (UUID()),
user_id CHAR(36) {$charsetCollation} NOT NULL,
device_fingerprint VARCHAR(64) NOT NULL,
device_name VARCHAR(100) NULL,
platform ENUM('android','ios','web') NOT NULL DEFAULT 'android',
app_version VARCHAR(20) NULL,
push_token TEXT NULL,
device_secret VARCHAR(128) NULL,
is_trusted BOOLEAN DEFAULT FALSE,
last_seen_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_user_device (user_id, device_fingerprint),
INDEX idx_device_fingerprint (device_fingerprint)
)
",
// ─── 2. Users table: Add phone + mobile fields ─────────
'add_users_phone' => "ALTER TABLE users ADD COLUMN phone VARCHAR(255) NULL AFTER email",
'add_users_phone_hash' => "ALTER TABLE users ADD COLUMN phone_hash VARCHAR(64) NULL AFTER phone",
'add_users_pin_hash' => "ALTER TABLE users ADD COLUMN pin_hash VARCHAR(255) NULL AFTER password_hash",
'add_users_biometric' => "ALTER TABLE users ADD COLUMN biometric_enabled BOOLEAN DEFAULT FALSE AFTER pin_hash",
'add_users_phone_index' => "CREATE INDEX idx_phone_hash ON users(phone_hash)",
// ─── 3. Invoice Batches (Mobile Scanner) ───────────────
'create_invoice_batches' => "
CREATE TABLE IF NOT EXISTS invoice_batches (
id CHAR(36) {$charsetCollation} PRIMARY KEY,
tenant_id CHAR(36) {$charsetCollation} NOT NULL,
company_id CHAR(36) {$charsetCollation} NOT NULL,
uploaded_by CHAR(36) {$charsetCollation} NULL,
total_images INT NOT NULL DEFAULT 0,
processed_images INT NOT NULL DEFAULT 0,
failed_images INT NOT NULL DEFAULT 0,
status ENUM('uploading','processing','done','partial_fail','failed') DEFAULT 'uploading',
source ENUM('mobile_scan','web_upload','whatsapp') DEFAULT 'mobile_scan',
pdf_path VARCHAR(500) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
INDEX idx_tenant_status (tenant_id, status),
INDEX idx_company (company_id),
INDEX idx_uploaded_by (uploaded_by),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
)
",
// ─── 4. Invoice Processing Queue ───────────────────────
'create_processing_queue' => "
CREATE TABLE IF NOT EXISTS invoice_processing_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
batch_id CHAR(36) {$charsetCollation} NOT NULL,
invoice_id CHAR(36) {$charsetCollation} NULL,
tenant_id CHAR(36) {$charsetCollation} NOT NULL,
company_id CHAR(36) {$charsetCollation} NOT NULL,
image_path VARCHAR(500) NOT NULL,
image_order INT NOT NULL DEFAULT 0,
status ENUM('pending','processing','done','failed') DEFAULT 'pending',
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 3,
error_message TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME NULL,
INDEX idx_status_tenant (status, tenant_id),
INDEX idx_batch (batch_id),
INDEX idx_pending (status, attempts)
)
",
// ─── 5. Add batch_id to invoices table ─────────────────
'add_invoices_batch_id' => "ALTER TABLE invoices ADD COLUMN batch_id CHAR(36) NULL AFTER company_id",
'add_invoices_batch_index' => "CREATE INDEX idx_batch_id ON invoices(batch_id)",
// ─── 6. Notifications Table ────────────────────────────
'create_notifications' => "
CREATE TABLE IF NOT EXISTS notifications (
id CHAR(36) {$charsetCollation} PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) {$charsetCollation} NOT NULL,
user_id CHAR(36) {$charsetCollation} NULL,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
data JSON NULL,
is_read BOOLEAN DEFAULT FALSE,
read_at DATETIME NULL,
push_sent BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_unread (user_id, is_read),
INDEX idx_tenant (tenant_id),
INDEX idx_type (type),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
",
];
$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();
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: `deploy_production.sh`
```sh
#!/bin/bash
# ─────────────────────────────────────────────────────
# Musadaq Production Deployment Script
# Run this on the production server after syncing files
# ─────────────────────────────────────────────────────
set -e
echo "═══════════════════════════════════════════════"
echo " مُصادَق — Production Deployment Script"
echo "═══════════════════════════════════════════════"
# 1. Install PHP dependencies
echo ""
echo "▶ Step 1: Installing Composer dependencies..."
cd /home/musadaq/htdocs/musadaq.intaleqapp.com
composer install --no-dev --optimize-autoloader
# 2. Ensure storage directories exist
echo ""
echo "▶ Step 2: Creating storage directories..."
mkdir -p storage/invoices
mkdir -p storage/logs
mkdir -p storage/exports
mkdir -p storage/temp
chmod -R 775 storage/
# 3. Set up the Cron Job for AI Queue Worker
echo ""
echo "▶ Step 3: Setting up Cron Job for AI Worker..."
echo ""
echo " Run: crontab -e"
echo " Add this line:"
echo ""
echo " * * * * * /usr/bin/php /home/musadaq/htdocs/musadaq.intaleqapp.com/app/cron/process_batches.php >> /home/musadaq/htdocs/musadaq.intaleqapp.com/storage/logs/cron.log 2>&1"
echo ""
echo " This runs the AI Queue Worker every minute."
echo " The worker has its own lock file to prevent duplicates."
echo ""
# 4. Verify environment variables
echo "▶ Step 4: Checking .env configuration..."
if [ -f .env ]; then
echo " ✅ .env file found"
# Check critical keys
grep -q "GEMINI_API_KEY" .env && echo " ✅ GEMINI_API_KEY set" || echo " ❌ GEMINI_API_KEY missing!"
grep -q "DB_HOST" .env && echo " ✅ DB_HOST set" || echo " ❌ DB_HOST missing!"
grep -q "ENCRYPTION_KEY" .env && echo " ✅ ENCRYPTION_KEY set" || echo " ❌ ENCRYPTION_KEY missing!"
grep -q "JWT_SECRET" .env && echo " ✅ JWT_SECRET set" || echo " ❌ JWT_SECRET missing!"
grep -q "FCM_SERVER_KEY\|FIREBASE" .env && echo " ✅ Firebase key set" || echo " ⚠️ Firebase key missing (push notifications won't work)"
else
echo " ❌ .env file not found! Copy .env.example and configure it."
fi
echo ""
echo "═══════════════════════════════════════════════"
echo " ✅ Deployment Complete!"
echo ""
echo " Next steps:"
echo " 1. Add the Cron Job (shown above)"
echo " 2. Test the API: curl https://musadaq.intaleqapp.com/api/v1/auth/login"
echo " 3. Monitor logs: tail -f storage/logs/cron.log"
echo "═══════════════════════════════════════════════"
```
## File: `create_referral_tables.sql`
```sql
-- Referral System Tables
CREATE TABLE IF NOT EXISTS referral_codes (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
code VARCHAR(20) NOT NULL UNIQUE,
is_active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_code (code),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS referrals (
id CHAR(36) PRIMARY KEY,
referrer_id CHAR(36) NOT NULL,
referred_id CHAR(36) NULL,
referral_code VARCHAR(20) NOT NULL,
status ENUM('clicked', 'registered', 'subscribed') DEFAULT 'clicked',
reward_claimed TINYINT(1) DEFAULT 0,
reward_type VARCHAR(50) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
converted_at TIMESTAMP NULL,
INDEX idx_referrer (referrer_id),
INDEX idx_code (referral_code),
FOREIGN KEY (referrer_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## File: `complete_migration.sql`
```sql
-- ════════════════════════════════════════════════════════════
-- مُصادَق — Complete Phase 1 Migration (MySQL 8.0 Compatible)
-- ════════════════════════════════════════════════════════════
-- 1. Invoice Line Items (AI extracted data)
CREATE TABLE IF NOT EXISTS invoice_lines (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
invoice_id CHAR(36) NOT NULL,
line_number INT NOT NULL,
description VARCHAR(255) NOT NULL,
quantity DECIMAL(10,3) DEFAULT 1,
unit_price DECIMAL(15,4) NOT NULL,
tax_rate DECIMAL(5,2) DEFAULT 16.00,
tax_amount DECIMAL(15,4) DEFAULT 0,
discount DECIMAL(15,4) DEFAULT 0,
total_amount DECIMAL(15,4) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_invoice (invoice_id),
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. JoFotara Submissions Log
CREATE TABLE IF NOT EXISTS jofotara_submissions (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
invoice_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
jofotara_uuid VARCHAR(100) NULL,
xml_content LONGTEXT NULL,
status ENUM('accepted', 'rejected', 'pending') DEFAULT 'pending',
qr_code_raw TEXT NULL,
response_body JSON NULL,
submitted_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_invoice (invoice_id),
INDEX idx_tenant (tenant_id),
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. AI Usage Log
CREATE TABLE IF NOT EXISTS ai_usage_log (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
company_id CHAR(36) NULL,
action_type ENUM('invoice_extraction','voice_transcribe','voice_intent','report_generation','chatbot') NOT NULL,
model_name VARCHAR(50) NOT NULL,
prompt_tokens INT DEFAULT 0,
completion_tokens INT DEFAULT 0,
total_tokens INT DEFAULT 0,
estimated_cost DECIMAL(10,6) DEFAULT 0,
request_metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_date (tenant_id, created_at),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 4. Notifications
CREATE TABLE IF NOT EXISTS notifications (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NULL,
type ENUM('invoice_processed','invoice_rejected','quota_warning','month_end','system','achievement') NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ════════════════════════════════════════════════════════════
-- 5. Safe ALTER TABLE (MySQL 8 compatible — no IF NOT EXISTS)
-- Run each block separately. If column already exists,
-- MySQL will show "Duplicate column" error — just skip it.
-- ════════════════════════════════════════════════════════════
-- 5a. Companies: JoFotara credentials
-- Run these ONE BY ONE. Skip any that say "Duplicate column name"
ALTER TABLE companies ADD COLUMN jofotara_client_id VARCHAR(255) NULL;
ALTER TABLE companies ADD COLUMN jofotara_secret_key VARCHAR(255) NULL;
ALTER TABLE companies ADD COLUMN jofotara_status ENUM('active', 'inactive', 'pending') DEFAULT 'inactive';
-- 5b. Invoices: AI + JoFotara metadata
-- Run these ONE BY ONE. Skip any that say "Duplicate column name"
ALTER TABLE invoices ADD COLUMN invoice_category ENUM('simplified', 'standard') DEFAULT 'simplified';
ALTER TABLE invoices ADD COLUMN ubl_type_code VARCHAR(10) DEFAULT '388';
ALTER TABLE invoices ADD COLUMN payment_method_code VARCHAR(10) DEFAULT '013';
ALTER TABLE invoices ADD COLUMN validation_warnings JSON NULL;
ALTER TABLE invoices ADD COLUMN ai_confidence DECIMAL(5,2) DEFAULT 0;
ALTER TABLE invoices ADD COLUMN jofotara_uuid VARCHAR(100) NULL;
```
## File: `debug_data.php`
```php
<?php
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "--- TENANTS ---\n";
$stmt = $db->query("SELECT * FROM tenants");
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
echo "\n--- USERS ---\n";
$stmt = $db->query("SELECT u.id, u.name, u.role, u.tenant_id, t.name as tenant_name FROM users u LEFT JOIN tenants t ON u.tenant_id = t.id");
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
echo "\n--- COMPANIES ---\n";
$stmt = $db->query("SELECT * FROM companies");
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
```
## File: `create_test_account.php`
```php
<?php
/**
* Create Test Account for App Reviewers
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
try {
$db = Database::getInstance();
$db->beginTransaction();
// 1. Generate UUIDs
$tenantId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$userId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
// 2. Test Account Data
$tenantName = "مكتب المراجعة التجريبي";
$tenantEmail = "reviewer@musadaq.jo";
$userName = "App Reviewer";
$userEmail = "reviewer@musadaq.jo";
$userPassword = "Reviewer2026!";
// 3. Encrypt data
$encryptedTenantName = Encryption::encrypt($tenantName);
$encryptedTenantEmail = Encryption::encrypt($tenantEmail);
$encryptedUserName = Encryption::encrypt($userName);
$encryptedUserEmail = Encryption::encrypt($userEmail);
$emailHash = hash('sha256', strtolower($userEmail));
$passwordHash = password_hash($userPassword, PASSWORD_DEFAULT);
// 4. Delete existing if any (prevent duplicates on re-run)
$stmt = $db->prepare("DELETE FROM users WHERE email_hash = ?");
$stmt->execute([$emailHash]);
// 5. Insert Tenant
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, status, created_at) VALUES (?, ?, ?, 'active', NOW())");
$stmt->execute([
$tenantId,
$encryptedTenantName,
$encryptedTenantEmail
]);
// 6. Insert User (Manager)
$stmtUser = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, 'admin', NOW())");
$stmtUser->execute([
$userId,
$tenantId,
$encryptedUserName,
$encryptedUserEmail,
$emailHash,
$passwordHash
]);
// 7. Insert Gamification Profile (Optional but good for testing dashboard)
$stmtProfile = $db->prepare("INSERT INTO user_profiles (user_id, points, current_level, rank_title) VALUES (?, 1500, 2, 'مُحاسب مبتدئ') ON DUPLICATE KEY UPDATE points=1500");
$stmtProfile->execute([$userId]);
$db->commit();
echo "✅ Test Account Created Successfully!\n";
echo "=====================================\n";
echo "Email: $userEmail\n";
echo "Password: $userPassword\n";
echo "=====================================\n";
} catch (\Exception $e) {
$db->rollBack();
echo "❌ Error: " . $e->getMessage() . "\n";
}
```
## File: `create_notifications_table.sql`
```sql
-- Notifications Table
CREATE TABLE IF NOT EXISTS notifications (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT,
type ENUM('info', 'success', 'warning', 'error') DEFAULT 'info',
category VARCHAR(50) DEFAULT 'general',
entity_type VARCHAR(50) NULL,
entity_id CHAR(36) NULL,
is_read TINYINT(1) DEFAULT 0,
read_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_tenant (tenant_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```