Files
musadaq-saas/scripts/PROJECT_DOCUMENTATION.md
2026-05-04 17:29:56 +03:00

16 KiB

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: 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,
    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.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 = 'password123'; // Default password

    // 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: $adminPassword\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: 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));