445 lines
16 KiB
Markdown
445 lines
16 KiB
Markdown
# 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: `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,
|
|
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
|
|
<?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 = '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
|
|
<?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));
|
|
|
|
```
|
|
|