Files
musadaq-saas/scripts/PROJECT_DOCUMENTATION.md
2026-05-07 15:49:13 +03:00

44 KiB
Raw Blame History

Musadaq Project Documentation

This file contains the complete source code of the project (excluding dependencies and sensitive data).

File: fix_data.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
/**
 * 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
/**
 * 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
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: schema.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
/**
 * 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
/**
 * 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
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: migrate.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
/**
 * 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: backfill_hashes.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
/**
 * 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: debug_data.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));