feat(phase-1): complete backend core, savings ledger, multi-channel auth, and dynamic QR verification

This commit is contained in:
Hamza-Ayed
2026-09-18 17:54:37 +03:00
parent 4fe451ab5e
commit e044123c9b
8 changed files with 295 additions and 30 deletions
+18 -16
View File
@@ -21,33 +21,35 @@
### 1.1 بنية قاعدة البيانات (MySQL Database) ### 1.1 بنية قاعدة البيانات (MySQL Database)
- [x] تصميم مخطط قاعدة البيانات المعتمد (`docs/04_database_schema.sql`). - [x] تصميم مخطط قاعدة البيانات المعتمد (`docs/04_database_schema.sql`).
- [x] إضافة جدول سجل وفورات المشترك (`savings_ledger`) لربط الخصومات بعداد التوفير التراكمي.
- [x] إضافة جدول حركات المصادقة والتوجيه (`otp_dispatches`) لمتابعة مسار الرسائل والمكالمات.
- [x] إدخال البيانات الأولية (Seeders): المحافظات العراقية، أنواع العضويات، ومجموعة شركاء تجريبية (مستشفى الكفيل، فندق بابل، معهد التطوير).
- [ ] استيراد وإنشاء الجداول على قاعدة بيانات السيرفر الحي (`urukprize_db`). - [ ] استيراد وإنشاء الجداول على قاعدة بيانات السيرفر الحي (`urukprize_db`).
- [ ] إضافة جدول سجل وفورات المشترك (`savings_ledger`) لربط الخصومات بعداد التوفير التراكمي.
- [ ] إضافة جدول حركات المصادقة والتوجيه (`otp_dispatches`) لمتابعة مسار الرسائل والمكالمات.
- [ ] إدخال البيانات الأولية (Seeders): المحافظات العراقية، أنواع العضويات، ومجموعة شركاء تجريبية (مستشفى الكفيل، فندق بابل، معهد التطوير).
### 1.2 بيئة العمل وخدمات النواة (Core Architecture) ### 1.2 بيئة العمل وخدمات النواة (Core Architecture)
- [x] إعداد موجه التطبيق المباشر (`index.php`) لدعم الواجهات السحابية والملفات الثابتة. - [x] إعداد موجه التطبيق المباشر (`index.php`) لدعم الواجهات السحابية والملفات الثابتة.
- [x] معمارية الفئات المتوافقة مع معايير PSR-4 بدون اعتمادات خارجية ثقيلة. - [x] معمارية الفئات المتوافقة مع معايير PSR-4 بدون اعتمادات خارجية ثقيلة.
- [ ] إعداد ملف البيئة الإنتاجي (`backend/.env`) على السيرفر (بيانات الاتصال بقاعدة البيانات، مفاتيح JWT التشفيرية، وأسرار البوابات). - [x] إعداد ملف البيئة النموذجي الكامل (`backend/.env.example`) مع مفاتيح JWT و Telegram و OTP.
- [ ] تشغيل واختبار نقطة فحص الحالة الصحية للنظام (`GET /api/v1/health`). - [x] تشغيل واختبار نقطة فحص الحالة الصحية للنظام (`GET /api/v1/health`).
- [ ] إعداد ملف البيئة الإنتاجي (`backend/.env`) على السيرفر الحي.
### 1.3 محرك المصادقة وإدارة الجلسات (Authentication & Security) ### 1.3 محرك المصادقة وإدارة الجلسات (Authentication & Security)
- [ ] مسار تسجيل المشترك الجديد برقم الهاتف العراقي (`POST /api/v1/auth/register`). - [x] مسار تسجيل المشترك الجديد برقم الهاتف العراقي وبصمة الجهاز.
- [ ] مسار طلب رمز التحقق واختيار قناة التوجيه الذكية (`POST /api/v1/auth/request-otp`). - [x] مسار طلب رمز التحقق واختيار قناة التوجيه الذكية (`POST /api/v1/auth/request-otp`) مع دعم الرنين الصامت، التيليغرام، الواتساب كابتشا، و SMS.
- [ ] مسار تأكيد الرمز وتسجيل الدخول عبر توكن JWT الموثق بالبصمة الرقمية (`POST /api/v1/auth/verify-otp`). - [x] مسار تأكيد الرمز وتسجيل الدخول عبر توكن JWT الموثق بالبصمة الرقمية (`POST /api/v1/auth/verify-otp`).
- [ ] برمجة وسيط التحقق الأمني وبصمة العتاد (`AuthMiddleware` & `HardwareFingerprint`). - [x] برمجة وسيط التحقق الأمني وبصمة العتاد ومكافحة التلاعب (`AuthMiddleware` & `SecurityHeaderMiddleware`).
### 1.4 محرك الاشتراكات وإصدار العضويات (Subscriptions & Cards) ### 1.4 محرك الاشتراكات وإصدار العضويات (Subscriptions & Cards)
- [ ] مسار إنشاء طلب عضوية جديدة وتوليد رقم العضوية المعتمد (`URUK-2026-XXXXX`). - [x] مسار إنشاء طلب عضوية جديدة وتوليد رقم العضوية المعتمد (`URUK-2026-XXXXX`).
- [ ] مسار جلب تفاصيل البطاقة الرقمية وحالة العضوية الحالية (`GET /api/v1/member/card`). - [x] مسار جلب تفاصيل البطاقة الرقمية وحالة العضوية الحالية (`GET /api/v1/subscription/my-card`).
- [ ] محرك توليد وتشفير الرمز الديناميكي (`Dynamic QR Code Generator Service`) المتغير كل 30 ثانية لمنع سرقة الشاشة. - [x] محرك توليد وتشفير الرمز الديناميكي (`Dynamic QR Code Generator Service`) المتغير كل 90 ثانية لمنع سرقة الشاشة.
- [ ] محرك احتساب وتتبع وفورات المشترك (`GET /api/v1/member/savings-summary`). - [x] محرك احتساب وتتبع وفورات المشترك ونظام التلعيب والشارات (`GET /api/v1/subscription/savings-summary`).
- [x] محرك فحص البطاقة واعتماد الخصم في الاستقبال وتوثيق الوفر (`POST /api/v1/qr/verify-token`).
### 1.5 محرك معالجة المدفوعات (Payment Processing APIs) ### 1.5 محرك معالجة المدفوعات (Payment Processing APIs)
- [ ] مسار رفع إشعار التحويل البنكي اليدوي من المشترك (`POST /api/v1/payment/submit-receipt`). - [x] مسار رفع إشعار التحويل البنكي اليدوي من المشترك وتفعيل الانتظار (`POST /api/v1/subscription/submit`).
- [ ] مسار استقبال بيانات الدفع الآلي واستقراء الإشعارات من هواتف البوابة (`POST /api/v1/gateway/p2p-sync`). - [x] مسار استقبال بيانات الدفع الآلي واستقراء الإشعارات من هواتف البوابة (`POST /api/v1/payments/ingest-notification`).
- [ ] منطق المطابقة اللحظية: مطابقة المبلغ والمعرف وتفعيل الاشتراك فوراً إلى `ACTIVE`. - [x] منطق المطابقة اللحظية: مطابقة المبلغ والمعرف وتفعيل الاشتراك فوراً إلى `ACTIVE` بدون عمولة.
--- ---
+9
View File
@@ -22,3 +22,12 @@ SWIFTPAY_ENDPOINT=https://api.swiftpayiq.com/v1
# SuperQi / ZainCash Notification Listener Secret # SuperQi / ZainCash Notification Listener Secret
NOTIFICATION_INGEST_SECRET=secure_ingest_key_for_android_bridge_listener NOTIFICATION_INGEST_SECRET=secure_ingest_key_for_android_bridge_listener
# Official Telegram Verification Bot
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_BOT_USERNAME=UrukPrizeBot
# JWT & Dynamic Encryption Secrets
JWT_SECRET=super_secret_jwt_signature_key_2026_uruk
QR_TOKEN_LIFETIME=90
+88 -11
View File
@@ -15,36 +15,79 @@ class AuthController
{ {
/** /**
* POST /api/v1/auth/request-otp * POST /api/v1/auth/request-otp
* Supports SILENT_CALL (zero cost), TELEGRAM (free), WHATSAPP_CAPTCHA, and SMS_OTPIQ.
*/ */
public function requestOtp(Request $request): void public function requestOtp(Request $request): void
{ {
$phone = trim((string)$request->get('phone')); $phone = trim((string)$request->get('phone'));
$channel = strtoupper(trim((string)$request->get('channel', 'SILENT_CALL')));
if (empty($phone) || strlen($phone) < 8) { if (empty($phone) || strlen($phone) < 8) {
Response::error('Phone number is required.'); Response::error('Valid Iraqi phone number is required.');
}
$validChannels = ['SILENT_CALL', 'TELEGRAM', 'WHATSAPP_CAPTCHA', 'SMS_OTPIQ'];
if (!in_array($channel, $validChannels, true)) {
$channel = 'SILENT_CALL';
} }
// Generate 6-digit cryptographic OTP code // Generate 6-digit cryptographic OTP code
$otp = (string)random_int(100000, 999999); $otp = (string)random_int(100000, 999999);
$otpHash = hash('sha256', $otp);
// Store OTP temporarily in DB or cache
$pdo = Database::getConnection(); $pdo = Database::getConnection();
// 1. Record OTP dispatch in otp_dispatches table
$dispStmt = $pdo->prepare('
INSERT INTO otp_dispatches (phone, channel, otp_code_hash, status, cost_iqd, expires_at, created_at)
VALUES (:phone, :channel, :hash, "DISPATCHED", :cost, DATE_ADD(NOW(), INTERVAL 5 MINUTE), NOW())
');
$costIqd = ($channel === 'SMS_OTPIQ') ? 10.00 : 0.00;
$dispStmt->execute([
':phone' => $phone,
':channel' => $channel,
':hash' => $otpHash,
':cost' => $costIqd,
]);
// 2. Dispatch via requested channel
$channelPayload = [];
if ($channel === 'SILENT_CALL') {
// Enqueue task for Android gateway caller phone
$taskStmt = $pdo->prepare('
INSERT INTO gateway_tasks (task_type, target_phone, otp_code, status, timeout_seconds, created_at)
VALUES ("FLASH_CALL", :phone, :otp, "PENDING", 30, NOW())
');
$taskStmt->execute([':phone' => $phone, ':otp' => $otp]);
$channelPayload['message'] = 'ستصلك رنة هاتفية سريعة خاطفة للتحقق مجاناً.';
} elseif ($channel === 'TELEGRAM') {
$channelPayload['telegram_url'] = 'https://t.me/UrukPrizeBot?start=otp_' . $otp;
$channelPayload['message'] = 'تم توجيه رمز التحقق إلى بوت التيليغرام الرسمي للجائزة.';
} elseif ($channel === 'WHATSAPP_CAPTCHA') {
$channelPayload['captcha_image'] = NabihCaptchaService::generateOtpImage($otp);
$channelPayload['message'] = 'تم إنشاء صورة كابتشا مشفرة لإرسالها عبر الواتساب.';
} elseif ($channel === 'SMS_OTPIQ') {
$otpService = new OtpIqService();
$otpService->sendOtp($phone, $otp);
$channelPayload['message'] = 'تم إرسال رمز التحقق عبر رسالة SMS رسمية.';
}
// Audit log
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
INSERT INTO audit_logs (user_id, action, endpoint, method, ip_address, response_code, payload_snippet, created_at) INSERT INTO audit_logs (user_id, action, endpoint, method, ip_address, response_code, payload_snippet, created_at)
VALUES (0, "OTP_REQUESTED", "/api/v1/auth/request-otp", "POST", :ip, 200, :payload, NOW()) VALUES (0, "OTP_REQUESTED", "/api/v1/auth/request-otp", "POST", :ip, 200, :payload, NOW())
'); ');
$stmt->execute([ $stmt->execute([
':ip' => $request->getIp(), ':ip' => $request->getIp(),
':payload' => json_encode(['phone' => $phone, 'otp' => $otp]), ':payload' => json_encode(['phone' => $phone, 'channel' => $channel]),
]); ]);
$otpService = new OtpIqService(); Response::success(array_merge([
$res = $otpService->sendOtp($phone, $otp);
Response::success([
'phone' => $phone, 'phone' => $phone,
'channels' => ['whatsapp', 'sms'], 'channel' => $channel,
'expires_in_seconds' => 300,
'mock_otp' => getenv('APP_ENV') === 'development' ? $otp : null, 'mock_otp' => getenv('APP_ENV') === 'development' ? $otp : null,
], 'Verification OTP dispatched via OTPIQ Smart Fallback.'); ], $channelPayload), 'Verification OTP dispatched via ' . $channel);
} }
/** /**
@@ -65,7 +108,41 @@ class AuthController
$pdo = Database::getConnection(); $pdo = Database::getConnection();
// Check or find user // 1. Verify OTP code against otp_dispatches
$otpHash = hash('sha256', $otp);
$isDev = getenv('APP_ENV') === 'development' && $otp === '123456';
if (!$isDev) {
$verStmt = $pdo->prepare('
SELECT id FROM otp_dispatches
WHERE phone = :phone AND otp_code_hash = :hash AND expires_at > NOW() AND status = "DISPATCHED"
ORDER BY id DESC LIMIT 1
');
$verStmt->execute([':phone' => $phone, ':hash' => $otpHash]);
$dispRecord = $verStmt->fetch(PDO::FETCH_ASSOC);
if (!$dispRecord) {
// Fallback check audit_logs for backward compatibility
$fallbackStmt = $pdo->prepare('
SELECT id FROM audit_logs
WHERE endpoint = "/api/v1/auth/request-otp"
AND payload_snippet LIKE :needle
AND created_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
ORDER BY id DESC LIMIT 1
');
$fallbackStmt->execute([':needle' => '%"phone":"' . $phone . '","otp":"' . $otp . '"%']);
$auditRecord = $fallbackStmt->fetch(PDO::FETCH_ASSOC);
if (!$auditRecord) {
Response::error('Invalid or expired OTP code.', 401);
}
} else {
$updDisp = $pdo->prepare('UPDATE otp_dispatches SET status = "VERIFIED", verified_at = NOW() WHERE id = :id');
$updDisp->execute([':id' => $dispRecord['id']]);
}
}
// 2. Check or find user
$stmt = $pdo->prepare('SELECT id, phone, full_name, role, status FROM users WHERE phone = :phone LIMIT 1'); $stmt = $pdo->prepare('SELECT id, phone, full_name, role, status FROM users WHERE phone = :phone LIMIT 1');
$stmt->execute([':phone' => $phone]); $stmt->execute([':phone' => $phone]);
$user = $stmt->fetch(PDO::FETCH_ASSOC); $user = $stmt->fetch(PDO::FETCH_ASSOC);
+3 -1
View File
@@ -35,12 +35,14 @@ class QrController
$token = trim((string)$request->get('token')); $token = trim((string)$request->get('token'));
$partnerId = (int)$request->get('partner_id', 1); $partnerId = (int)$request->get('partner_id', 1);
$staffName = trim((string)$request->get('staff_name', 'موظف الاستقبال')); $staffName = trim((string)$request->get('staff_name', 'موظف الاستقبال'));
$billAmount = (float)$request->get('bill_amount', 0.0);
$serviceName = trim((string)$request->get('service_name', 'استخدام خدمات الشريك'));
if (empty($token)) { if (empty($token)) {
Response::error('QR token is required for verification.'); Response::error('QR token is required for verification.');
} }
$res = QrTokenService::verifyScannedToken($token, $partnerId, $staffName); $res = QrTokenService::verifyScannedToken($token, $partnerId, $staffName, $billAmount, $serviceName);
if (!$res['success']) { if (!$res['success']) {
Response::error($res['message'], 400); Response::error($res['message'], 400);
@@ -160,4 +160,94 @@ class SubscriptionController
Response::success($card, 'Digital card details retrieved.'); Response::success($card, 'Digital card details retrieved.');
} }
/**
* GET /api/v1/subscription/savings-summary
* Returns total cash saved, breakdown by partner type, loyalty tier, and recent savings logs.
*/
public function getSavingsSummary(Request $request): void
{
$userId = (int)$request->getHeader('x-user-id');
$pdo = Database::getConnection();
// 1. Get totals
$totStmt = $pdo->prepare('
SELECT
COALESCE(SUM(saved_amount), 0) AS total_saved_iqd,
COALESCE(SUM(paid_amount), 0) AS total_paid_iqd,
COUNT(id) AS total_services_count
FROM savings_ledger
WHERE user_id = :uid
');
$totStmt->execute([':uid' => $userId]);
$totals = $totStmt->fetch(PDO::FETCH_ASSOC) ?: [
'total_saved_iqd' => 0,
'total_paid_iqd' => 0,
'total_services_count' => 0,
];
$totalSaved = (float)$totals['total_saved_iqd'];
$servicesCount = (int)$totals['total_services_count'];
// 2. Breakdown by partner type (HOSPITAL, HOTEL, TRAINING_CENTER)
$breakdownStmt = $pdo->prepare('
SELECT
p.type,
COUNT(s.id) AS count,
COALESCE(SUM(s.saved_amount), 0) AS saved_iqd
FROM savings_ledger s
JOIN partners p ON p.id = s.partner_id
WHERE s.user_id = :uid
GROUP BY p.type
');
$breakdownStmt->execute([':uid' => $userId]);
$breakdownRows = $breakdownStmt->fetchAll(PDO::FETCH_ASSOC);
$breakdown = [
'HOSPITAL' => ['count' => 0, 'saved_iqd' => 0.0, 'label' => 'المستشفيات والعيادات'],
'HOTEL' => ['count' => 0, 'saved_iqd' => 0.0, 'label' => 'الفنادق والإقامة'],
'TRAINING_CENTER' => ['count' => 0, 'saved_iqd' => 0.0, 'label' => 'الدورات والورش الأكاديمية'],
];
foreach ($breakdownRows as $row) {
$type = $row['type'];
if (isset($breakdown[$type])) {
$breakdown[$type]['count'] = (int)$row['count'];
$breakdown[$type]['saved_iqd'] = (float)$row['saved_iqd'];
}
}
// 3. Loyalty Tier Calculation (Gamification)
if ($totalSaved >= 500000) {
$tier = ['code' => 'DIAMOND', 'name_ar' => 'عضو ماسي أوروك', 'badge' => '💎', 'level' => 4];
} elseif ($totalSaved >= 250000) {
$tier = ['code' => 'GOLD', 'name_ar' => 'عضو ذهبي ممتاز', 'badge' => '🥇', 'level' => 3];
} elseif ($totalSaved >= 100000) {
$tier = ['code' => 'SILVER', 'name_ar' => 'عضو فضي متقدم', 'badge' => '🥈', 'level' => 2];
} else {
$tier = ['code' => 'BRONZE', 'name_ar' => 'عضو برونزي', 'badge' => '🥉', 'level' => 1];
}
// 4. Fetch last 10 logs
$logsStmt = $pdo->prepare('
SELECT s.id, s.service_name, s.original_amount, s.discount_percentage, s.saved_amount, s.paid_amount,
s.created_at, p.name_ar AS partner_name, p.type AS partner_type
FROM savings_ledger s
JOIN partners p ON p.id = s.partner_id
WHERE s.user_id = :uid
ORDER BY s.id DESC
LIMIT 10
');
$logsStmt->execute([':uid' => $userId]);
$recentLogs = $logsStmt->fetchAll(PDO::FETCH_ASSOC);
Response::success([
'total_saved_iqd' => $totalSaved,
'total_paid_iqd' => (float)$totals['total_paid_iqd'],
'total_services_count' => $servicesCount,
'breakdown' => $breakdown,
'tier' => $tier,
'recent_logs' => $recentLogs,
], 'Savings and gamification summary retrieved.');
}
} }
+36 -2
View File
@@ -60,8 +60,13 @@ class QrTokenService
/** /**
* Verifies a QR token scanned by a hospital or hotel receptionist. * Verifies a QR token scanned by a hospital or hotel receptionist.
*/ */
public static function verifyScannedToken(string $token, int $partnerId, ?string $staffName = null): array public static function verifyScannedToken(
{ string $token,
int $partnerId,
?string $staffName = null,
float $billAmount = 0.0,
string $serviceName = 'استخدام خدمات الشريك'
): array {
// Decode base64 payload to get user ID without signature first // Decode base64 payload to get user ID without signature first
$parts = explode('.', $token); $parts = explode('.', $token);
if (count($parts) !== 2) { if (count($parts) !== 2) {
@@ -126,6 +131,32 @@ class QrTokenService
':sig' => substr($parts[1], 0, 64), ':sig' => substr($parts[1], 0, 64),
]); ]);
$savedAmount = 0.0;
$paidAmount = 0.0;
// Record savings into savings_ledger if bill amount is provided
if ($billAmount > 0) {
$savedAmount = round($billAmount * ($discount / 100.0), 2);
$paidAmount = round($billAmount - $savedAmount, 2);
$saveStmt = $pdo->prepare('
INSERT INTO savings_ledger
(subscription_id, user_id, partner_id, service_name, original_amount, discount_percentage, saved_amount, paid_amount, currency, verified_by_staff, created_at)
VALUES (:sub_id, :uid, :pid, :sname, :orig, :disc, :saved, :paid, "IQD", :staff, NOW())
');
$saveStmt->execute([
':sub_id' => $record['sub_id'],
':uid' => $record['user_id'],
':pid' => $partnerId,
':sname' => $serviceName,
':orig' => $billAmount,
':disc' => $discount,
':saved' => $savedAmount,
':paid' => $paidAmount,
':staff' => $staffName ?: 'Front Desk',
]);
}
return [ return [
'success' => true, 'success' => true,
'is_valid' => true, 'is_valid' => true,
@@ -136,6 +167,9 @@ class QrTokenService
'expires_at' => $record['expires_at'], 'expires_at' => $record['expires_at'],
'partner_name' => $partner['name_ar'] ?? 'Partner', 'partner_name' => $partner['name_ar'] ?? 'Partner',
'discount_percentage' => $discount, 'discount_percentage' => $discount,
'original_bill_iqd' => $billAmount,
'saved_amount_iqd' => $savedAmount,
'paid_amount_iqd' => $paidAmount,
'verified_at' => date('Y-m-d H:i:s'), 'verified_at' => date('Y-m-d H:i:s'),
]; ];
} }
+1
View File
@@ -123,6 +123,7 @@ $userMiddlewares = [SecurityHeaderMiddleware::class, AuthMiddleware::class];
$router->get('/api/v1/auth/profile', [AuthController::class, 'getProfile'], $userMiddlewares); $router->get('/api/v1/auth/profile', [AuthController::class, 'getProfile'], $userMiddlewares);
$router->get('/api/v1/subscription/my-card', [SubscriptionController::class, 'getMyCard'], $userMiddlewares); $router->get('/api/v1/subscription/my-card', [SubscriptionController::class, 'getMyCard'], $userMiddlewares);
$router->get('/api/v1/subscription/savings-summary', [SubscriptionController::class, 'getSavingsSummary'], $userMiddlewares);
$router->post('/api/v1/subscription/submit', [SubscriptionController::class, 'submitSubscription'], $userMiddlewares); $router->post('/api/v1/subscription/submit', [SubscriptionController::class, 'submitSubscription'], $userMiddlewares);
$router->get('/api/v1/qr/dynamic-token', [QrController::class, 'getDynamicToken'], $userMiddlewares); $router->get('/api/v1/qr/dynamic-token', [QrController::class, 'getDynamicToken'], $userMiddlewares);
+50
View File
@@ -210,5 +210,55 @@ CREATE TABLE IF NOT EXISTS `gateway_tasks` (
INDEX `idx_assigned_device` (`assigned_device_id`) INDEX `idx_assigned_device` (`assigned_device_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 12. جدول محفظة التوفير المالي التراكمي للمشترك (Member Savings Ledger)
CREATE TABLE IF NOT EXISTS `savings_ledger` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`subscription_id` BIGINT UNSIGNED NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL,
`partner_id` BIGINT UNSIGNED NOT NULL,
`service_name` VARCHAR(191) NOT NULL COMMENT 'e.g. تحاليل وفحوصات شاملة، إقامة فندقية ليلتين، دورة تدريبية',
`original_amount` DECIMAL(14, 2) NOT NULL COMMENT 'Original bill before discount in IQD',
`discount_percentage` DECIMAL(5, 2) NOT NULL COMMENT 'e.g. 25.00',
`saved_amount` DECIMAL(14, 2) NOT NULL COMMENT 'Actual cash saved by member in IQD',
`paid_amount` DECIMAL(14, 2) NOT NULL COMMENT 'Amount actually paid by member in IQD',
`currency` VARCHAR(8) NOT NULL DEFAULT 'IQD',
`verified_by_staff` VARCHAR(128) NULL,
`notes` TEXT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `fk_savings_sub` FOREIGN KEY (`subscription_id`) REFERENCES `subscriptions` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_savings_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_savings_partner` FOREIGN KEY (`partner_id`) REFERENCES `partners` (`id`) ON DELETE CASCADE,
INDEX `idx_savings_user` (`user_id`),
INDEX `idx_savings_sub` (`subscription_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 13. جدول إرساليات المصادقة وتتبع القنوات (OTP Dispatches & Multi-Channel Routing)
CREATE TABLE IF NOT EXISTS `otp_dispatches` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`phone` VARCHAR(32) NOT NULL,
`user_id` BIGINT UNSIGNED NULL,
`channel` ENUM('SILENT_CALL', 'TELEGRAM', 'WHATSAPP_CAPTCHA', 'SMS_OTPIQ') NOT NULL,
`otp_code_hash` VARCHAR(64) NOT NULL,
`status` ENUM('QUEUED', 'DISPATCHED', 'DELIVERED', 'VERIFIED', 'EXPIRED', 'FAILED') NOT NULL DEFAULT 'QUEUED',
`gateway_device_id` VARCHAR(64) NULL,
`provider_message_id` VARCHAR(128) NULL,
`cost_iqd` DECIMAL(8, 2) NOT NULL DEFAULT 0.00,
`expires_at` TIMESTAMP NOT NULL,
`verified_at` TIMESTAMP NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_otp_phone_status` (`phone`, `status`),
INDEX `idx_otp_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 14. بيانات أولية للشركاء المعتمدين في العراق (Seed Partners)
INSERT INTO `partners` (`id`, `type`, `name_ar`, `name_en`, `category`, `country`, `city`, `discount_percentage`, `address`, `phone`, `is_active`) VALUES
(1, 'HOSPITAL', 'مستشفى الكفيل التخصصي', 'Al-Kafeel Super Speciality Hospital', 'مستشفى جراحي وتخصصي متقدم', 'Iraq', 'كربلاء المقدسة', 25.00, 'طريق كربلاء - النجف', '+9647801234001', 1),
(2, 'HOSPITAL', 'مستشفى الفاروق التخصصي', 'Al-Farouk Hospital', 'جراحة عامة وباطنية ومختبرات', 'Iraq', 'بغداد', 30.00, 'المنصور - شارع 14 رمضان', '+9647801234002', 1),
(3, 'HOTEL', 'فندق بابل روتانا', 'Babylon Rotana Hotel', 'فندق 5 نجوم وضيافة فاخرة', 'Iraq', 'بغداد', 30.00, 'الجادرية - شارع الكرادة', '+9647801234003', 1),
(4, 'HOTEL', 'فندق أربيل الدولي', 'Erbil International Hotel', 'فندق 5 نجوم ومركز مؤتمرات', 'Iraq', 'أربيل', 35.00, 'شارع 30 متري - قرب القلعة', '+9647801234004', 1),
(5, 'TRAINING_CENTER', 'أكاديمية أوروك الدولية للتدريب والتطوير', 'Uruk International Training Academy', 'إدارة وقيادة وتقنيات حديثة', 'Iraq', 'بغداد', 50.00, 'العرصات - مجمع أوروك الأكاديمي', '+9647801234005', 1)
ON DUPLICATE KEY UPDATE `name_ar` = VALUES(`name_ar`);
SET FOREIGN_KEY_CHECKS = 1; SET FOREIGN_KEY_CHECKS = 1;