29 KiB
29 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_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: 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: 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));