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
+88 -11
View File
@@ -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);
+3 -1
View File
@@ -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.');
}
}