feat(phase-1): complete backend core, savings ledger, multi-channel auth, and dynamic QR verification
This commit is contained in:
+18
-16
@@ -21,33 +21,35 @@
|
||||
|
||||
### 1.1 بنية قاعدة البيانات (MySQL Database)
|
||||
- [x] تصميم مخطط قاعدة البيانات المعتمد (`docs/04_database_schema.sql`).
|
||||
- [x] إضافة جدول سجل وفورات المشترك (`savings_ledger`) لربط الخصومات بعداد التوفير التراكمي.
|
||||
- [x] إضافة جدول حركات المصادقة والتوجيه (`otp_dispatches`) لمتابعة مسار الرسائل والمكالمات.
|
||||
- [x] إدخال البيانات الأولية (Seeders): المحافظات العراقية، أنواع العضويات، ومجموعة شركاء تجريبية (مستشفى الكفيل، فندق بابل، معهد التطوير).
|
||||
- [ ] استيراد وإنشاء الجداول على قاعدة بيانات السيرفر الحي (`urukprize_db`).
|
||||
- [ ] إضافة جدول سجل وفورات المشترك (`savings_ledger`) لربط الخصومات بعداد التوفير التراكمي.
|
||||
- [ ] إضافة جدول حركات المصادقة والتوجيه (`otp_dispatches`) لمتابعة مسار الرسائل والمكالمات.
|
||||
- [ ] إدخال البيانات الأولية (Seeders): المحافظات العراقية، أنواع العضويات، ومجموعة شركاء تجريبية (مستشفى الكفيل، فندق بابل، معهد التطوير).
|
||||
|
||||
### 1.2 بيئة العمل وخدمات النواة (Core Architecture)
|
||||
- [x] إعداد موجه التطبيق المباشر (`index.php`) لدعم الواجهات السحابية والملفات الثابتة.
|
||||
- [x] معمارية الفئات المتوافقة مع معايير PSR-4 بدون اعتمادات خارجية ثقيلة.
|
||||
- [ ] إعداد ملف البيئة الإنتاجي (`backend/.env`) على السيرفر (بيانات الاتصال بقاعدة البيانات، مفاتيح JWT التشفيرية، وأسرار البوابات).
|
||||
- [ ] تشغيل واختبار نقطة فحص الحالة الصحية للنظام (`GET /api/v1/health`).
|
||||
- [x] إعداد ملف البيئة النموذجي الكامل (`backend/.env.example`) مع مفاتيح JWT و Telegram و OTP.
|
||||
- [x] تشغيل واختبار نقطة فحص الحالة الصحية للنظام (`GET /api/v1/health`).
|
||||
- [ ] إعداد ملف البيئة الإنتاجي (`backend/.env`) على السيرفر الحي.
|
||||
|
||||
### 1.3 محرك المصادقة وإدارة الجلسات (Authentication & Security)
|
||||
- [ ] مسار تسجيل المشترك الجديد برقم الهاتف العراقي (`POST /api/v1/auth/register`).
|
||||
- [ ] مسار طلب رمز التحقق واختيار قناة التوجيه الذكية (`POST /api/v1/auth/request-otp`).
|
||||
- [ ] مسار تأكيد الرمز وتسجيل الدخول عبر توكن JWT الموثق بالبصمة الرقمية (`POST /api/v1/auth/verify-otp`).
|
||||
- [ ] برمجة وسيط التحقق الأمني وبصمة العتاد (`AuthMiddleware` & `HardwareFingerprint`).
|
||||
- [x] مسار تسجيل المشترك الجديد برقم الهاتف العراقي وبصمة الجهاز.
|
||||
- [x] مسار طلب رمز التحقق واختيار قناة التوجيه الذكية (`POST /api/v1/auth/request-otp`) مع دعم الرنين الصامت، التيليغرام، الواتساب كابتشا، و SMS.
|
||||
- [x] مسار تأكيد الرمز وتسجيل الدخول عبر توكن JWT الموثق بالبصمة الرقمية (`POST /api/v1/auth/verify-otp`).
|
||||
- [x] برمجة وسيط التحقق الأمني وبصمة العتاد ومكافحة التلاعب (`AuthMiddleware` & `SecurityHeaderMiddleware`).
|
||||
|
||||
### 1.4 محرك الاشتراكات وإصدار العضويات (Subscriptions & Cards)
|
||||
- [ ] مسار إنشاء طلب عضوية جديدة وتوليد رقم العضوية المعتمد (`URUK-2026-XXXXX`).
|
||||
- [ ] مسار جلب تفاصيل البطاقة الرقمية وحالة العضوية الحالية (`GET /api/v1/member/card`).
|
||||
- [ ] محرك توليد وتشفير الرمز الديناميكي (`Dynamic QR Code Generator Service`) المتغير كل 30 ثانية لمنع سرقة الشاشة.
|
||||
- [ ] محرك احتساب وتتبع وفورات المشترك (`GET /api/v1/member/savings-summary`).
|
||||
- [x] مسار إنشاء طلب عضوية جديدة وتوليد رقم العضوية المعتمد (`URUK-2026-XXXXX`).
|
||||
- [x] مسار جلب تفاصيل البطاقة الرقمية وحالة العضوية الحالية (`GET /api/v1/subscription/my-card`).
|
||||
- [x] محرك توليد وتشفير الرمز الديناميكي (`Dynamic QR Code Generator Service`) المتغير كل 90 ثانية لمنع سرقة الشاشة.
|
||||
- [x] محرك احتساب وتتبع وفورات المشترك ونظام التلعيب والشارات (`GET /api/v1/subscription/savings-summary`).
|
||||
- [x] محرك فحص البطاقة واعتماد الخصم في الاستقبال وتوثيق الوفر (`POST /api/v1/qr/verify-token`).
|
||||
|
||||
### 1.5 محرك معالجة المدفوعات (Payment Processing APIs)
|
||||
- [ ] مسار رفع إشعار التحويل البنكي اليدوي من المشترك (`POST /api/v1/payment/submit-receipt`).
|
||||
- [ ] مسار استقبال بيانات الدفع الآلي واستقراء الإشعارات من هواتف البوابة (`POST /api/v1/gateway/p2p-sync`).
|
||||
- [ ] منطق المطابقة اللحظية: مطابقة المبلغ والمعرف وتفعيل الاشتراك فوراً إلى `ACTIVE`.
|
||||
- [x] مسار رفع إشعار التحويل البنكي اليدوي من المشترك وتفعيل الانتظار (`POST /api/v1/subscription/submit`).
|
||||
- [x] مسار استقبال بيانات الدفع الآلي واستقراء الإشعارات من هواتف البوابة (`POST /api/v1/payments/ingest-notification`).
|
||||
- [x] منطق المطابقة اللحظية: مطابقة المبلغ والمعرف وتفعيل الاشتراك فوراً إلى `ACTIVE` بدون عمولة.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,3 +22,12 @@ SWIFTPAY_ENDPOINT=https://api.swiftpayiq.com/v1
|
||||
|
||||
# SuperQi / ZainCash Notification Listener Secret
|
||||
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
|
||||
|
||||
|
||||
@@ -15,36 +15,79 @@ class AuthController
|
||||
{
|
||||
/**
|
||||
* POST /api/v1/auth/request-otp
|
||||
* Supports SILENT_CALL (zero cost), TELEGRAM (free), WHATSAPP_CAPTCHA, and SMS_OTPIQ.
|
||||
*/
|
||||
public function requestOtp(Request $request): void
|
||||
{
|
||||
$phone = trim((string)$request->get('phone'));
|
||||
$channel = strtoupper(trim((string)$request->get('channel', 'SILENT_CALL')));
|
||||
|
||||
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
|
||||
$otp = (string)random_int(100000, 999999);
|
||||
|
||||
// Store OTP temporarily in DB or cache
|
||||
$otpHash = hash('sha256', $otp);
|
||||
$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('
|
||||
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())
|
||||
');
|
||||
$stmt->execute([
|
||||
':ip' => $request->getIp(),
|
||||
':payload' => json_encode(['phone' => $phone, 'otp' => $otp]),
|
||||
':payload' => json_encode(['phone' => $phone, 'channel' => $channel]),
|
||||
]);
|
||||
|
||||
$otpService = new OtpIqService();
|
||||
$res = $otpService->sendOtp($phone, $otp);
|
||||
|
||||
Response::success([
|
||||
Response::success(array_merge([
|
||||
'phone' => $phone,
|
||||
'channels' => ['whatsapp', 'sms'],
|
||||
'channel' => $channel,
|
||||
'expires_in_seconds' => 300,
|
||||
'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();
|
||||
|
||||
// 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->execute([':phone' => $phone]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -35,12 +35,14 @@ class QrController
|
||||
$token = trim((string)$request->get('token'));
|
||||
$partnerId = (int)$request->get('partner_id', 1);
|
||||
$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)) {
|
||||
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']) {
|
||||
Response::error($res['message'], 400);
|
||||
|
||||
@@ -160,4 +160,94 @@ class SubscriptionController
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,13 @@ class QrTokenService
|
||||
/**
|
||||
* 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
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 2) {
|
||||
@@ -126,6 +131,32 @@ class QrTokenService
|
||||
':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 [
|
||||
'success' => true,
|
||||
'is_valid' => true,
|
||||
@@ -136,6 +167,9 @@ class QrTokenService
|
||||
'expires_at' => $record['expires_at'],
|
||||
'partner_name' => $partner['name_ar'] ?? 'Partner',
|
||||
'discount_percentage' => $discount,
|
||||
'original_bill_iqd' => $billAmount,
|
||||
'saved_amount_iqd' => $savedAmount,
|
||||
'paid_amount_iqd' => $paidAmount,
|
||||
'verified_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ $userMiddlewares = [SecurityHeaderMiddleware::class, AuthMiddleware::class];
|
||||
|
||||
$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/savings-summary', [SubscriptionController::class, 'getSavingsSummary'], $userMiddlewares);
|
||||
$router->post('/api/v1/subscription/submit', [SubscriptionController::class, 'submitSubscription'], $userMiddlewares);
|
||||
$router->get('/api/v1/qr/dynamic-token', [QrController::class, 'getDynamicToken'], $userMiddlewares);
|
||||
|
||||
|
||||
@@ -210,5 +210,55 @@ CREATE TABLE IF NOT EXISTS `gateway_tasks` (
|
||||
INDEX `idx_assigned_device` (`assigned_device_id`)
|
||||
) 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;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user