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