Initial commit: Uruk Prize Platform architecture, backend core, mobile app, gateway caller & deployment pipeline

This commit is contained in:
Hamza-Ayed
2026-09-18 17:12:05 +03:00
commit 879dd36f1b
1149 changed files with 97122 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Core\Request;
use Core\Response;
use Core\Database;
use PDO;
class AdminController
{
/**
* POST /api/v1/admin/partners/add
* Directly add a new hospital or hotel contract on the fly.
*/
public function addPartner(Request $request): void
{
$userId = (int)$request->getHeader('x-user-id');
$type = strtoupper(trim((string)$request->get('type', 'HOSPITAL')));
$nameAr = trim((string)$request->get('name_ar'));
$nameEn = trim((string)$request->get('name_en', ''));
$category = trim((string)$request->get('category', 'عام'));
$country = trim((string)$request->get('country', 'العراق'));
$city = trim((string)$request->get('city', 'بغداد'));
$discount = (float)$request->get('discount_percentage', $type === 'HOSPITAL' ? 50.00 : 50.00);
$phone = trim((string)$request->get('phone', ''));
$address = trim((string)$request->get('address', ''));
if (empty($nameAr)) {
Response::error('Partner Arabic name is required.');
}
$pdo = Database::getConnection();
$stmt = $pdo->prepare('
INSERT INTO partners (type, name_ar, name_en, category, country, city, discount_percentage, address, phone, is_active, added_by_admin, created_at)
VALUES (:type, :name_ar, :name_en, :category, :country, :city, :discount, :address, :phone, 1, :admin_id, NOW())
');
$stmt->execute([
':type' => $type,
':name_ar' => $nameAr,
':name_en' => $nameEn,
':category' => $category,
':country' => $country,
':city' => $city,
':discount' => $discount,
':address' => $address,
':phone' => $phone,
':admin_id' => $userId,
]);
$newId = (int)$pdo->lastInsertId();
Response::success([
'partner_id' => $newId,
'name_ar' => $nameAr,
'discount' => $discount,
'message' => 'تمت إضافة الشريك بنجاح ومتاح فورياً لجميع المشتركين.',
], 'Partner created successfully.');
}
/**
* GET /api/v1/admin/payments/pending
*/
public function listPendingPayments(Request $request): void
{
$pdo = Database::getConnection();
$stmt = $pdo->query('
SELECT t.id, t.subscription_id, t.reference_number, t.amount, t.currency, t.method, t.receipt_image_url, t.created_at,
u.id AS user_id, u.full_name, u.phone, s.membership_number
FROM transactions t
JOIN subscriptions s ON s.id = t.subscription_id
JOIN users u ON u.id = t.user_id
WHERE t.status = "SUBMITTED"
ORDER BY t.id DESC
LIMIT 50
');
$pending = $stmt->fetchAll(PDO::FETCH_ASSOC);
Response::success($pending, 'Pending payments list retrieved.');
}
/**
* POST /api/v1/admin/payments/approve
*/
public function approvePayment(Request $request): void
{
$adminId = (int)$request->getHeader('x-user-id');
$transactionId = (int)$request->get('transaction_id');
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT id, subscription_id, user_id FROM transactions WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $transactionId]);
$trans = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$trans) {
Response::notFound('Transaction not found.');
}
// Update transaction
$updTrans = $pdo->prepare('UPDATE transactions SET status = "VERIFIED_ADMIN", verified_at = NOW() WHERE id = :id');
$updTrans->execute([':id' => $transactionId]);
// Activate subscription
$updSub = $pdo->prepare('
UPDATE subscriptions
SET status = "ACTIVE", starts_at = CURDATE(), expires_at = DATE_ADD(CURDATE(), INTERVAL 2 YEAR), activated_by = :admin_id
WHERE id = :sub_id
');
$updSub->execute([
':admin_id' => $adminId,
':sub_id' => $trans['subscription_id'],
]);
// Activate user
$updUser = $pdo->prepare('UPDATE users SET status = "ACTIVE" WHERE id = :uid');
$updUser->execute([':uid' => $trans['user_id']]);
$pdo->commit();
Response::success([
'transaction_id' => $transactionId,
'subscription_id' => $trans['subscription_id'],
'status' => 'ACTIVE',
], 'Payment approved and membership activated successfully.');
} catch (\Throwable $e) {
$pdo->rollBack();
Response::error('Approval failed: ' . $e->getMessage(), 500);
}
}
/**
* GET /api/v1/admin/stats
*/
public function getStats(Request $request): void
{
$pdo = Database::getConnection();
$membersCount = (int)$pdo->query('SELECT COUNT(*) FROM subscriptions WHERE status = "ACTIVE"')->fetchColumn();
$pendingCount = (int)$pdo->query('SELECT COUNT(*) FROM subscriptions WHERE status = "PENDING_PAYMENT"')->fetchColumn();
$totalRevenue = (float)$pdo->query('SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE status IN ("VERIFIED_AUTO", "VERIFIED_ADMIN")')->fetchColumn();
$hospitalsCount = (int)$pdo->query('SELECT COUNT(*) FROM partners WHERE type = "HOSPITAL" AND is_active = 1')->fetchColumn();
$hotelsCount = (int)$pdo->query('SELECT COUNT(*) FROM partners WHERE type = "HOTEL" AND is_active = 1')->fetchColumn();
Response::success([
'active_members' => $membersCount,
'pending_verifications' => $pendingCount,
'total_revenue_iqd' => $totalRevenue,
'total_revenue_usd' => round($totalRevenue / 1320, 2),
'active_hospitals' => $hospitalsCount,
'active_hotels' => $hotelsCount,
], 'System statistics retrieved.');
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Core\Request;
use Core\Response;
use Core\Security;
use Core\Database;
use App\Services\OtpIqService;
use PDO;
class AuthController
{
/**
* POST /api/v1/auth/request-otp
*/
public function requestOtp(Request $request): void
{
$phone = trim((string)$request->get('phone'));
if (empty($phone) || strlen($phone) < 8) {
Response::error('Phone number is required.');
}
// Generate 6-digit cryptographic OTP code
$otp = (string)random_int(100000, 999999);
// Store OTP temporarily in DB or cache
$pdo = Database::getConnection();
$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]),
]);
$otpService = new OtpIqService();
$res = $otpService->sendOtp($phone, $otp);
Response::success([
'phone' => $phone,
'channels' => ['whatsapp', 'sms'],
'mock_otp' => getenv('APP_ENV') === 'development' ? $otp : null,
], 'Verification OTP dispatched via OTPIQ Smart Fallback.');
}
/**
* POST /api/v1/auth/verify-otp
* Receives phone, otp, device_fingerprint, device_model, and optional full_name.
*/
public function verifyOtp(Request $request): void
{
$phone = trim((string)$request->get('phone'));
$otp = trim((string)$request->get('otp'));
$fingerprint = trim((string)$request->get('device_fingerprint'));
$model = trim((string)$request->get('device_model', 'Unknown Device'));
$fullName = trim((string)$request->get('full_name', 'عضو جائزة أوروك'));
if (empty($phone) || empty($otp) || empty($fingerprint)) {
Response::error('Phone, OTP, and Device Fingerprint are mandatory.');
}
$pdo = Database::getConnection();
// 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);
if (!$user) {
// Register new user
$ins = $pdo->prepare('
INSERT INTO users (phone, full_name, role, status, created_at)
VALUES (:phone, :name, "MEMBER", "ACTIVE", NOW())
');
$ins->execute([':phone' => $phone, ':name' => $fullName]);
$userId = (int)$pdo->lastInsertId();
$role = 'MEMBER';
} else {
$userId = (int)$user['id'];
$role = $user['role'];
}
// Generate per-device secret key and session token
$deviceSecret = Security::generateRandomHex(32);
$sessionToken = Security::generateRandomHex(32);
// Bind device fingerprint and store secret in user_security table
$secStmt = $pdo->prepare('
INSERT INTO user_security (user_id, device_fingerprint, device_secret, device_model, last_token, last_ip, last_active_at)
VALUES (:uid, :fp, :secret, :model, :token, :ip, NOW())
ON DUPLICATE KEY UPDATE
device_secret = VALUES(device_secret),
device_model = VALUES(device_model),
last_token = VALUES(last_token),
last_ip = VALUES(last_ip),
last_active_at = NOW(),
is_locked = 0
');
$secStmt->execute([
':uid' => $userId,
':fp' => $fingerprint,
':secret' => $deviceSecret,
':model' => $model,
':token' => $sessionToken,
':ip' => $request->getIp(),
]);
Response::success([
'user' => [
'id' => $userId,
'phone' => $phone,
'full_name' => $user['full_name'] ?? $fullName,
'role' => $role,
],
'token' => $sessionToken,
'device_secret' => $deviceSecret,
'device_fingerprint' => $fingerprint,
], 'Authentication successful. Device bound and secured.');
}
/**
* GET /api/v1/auth/profile
*/
public function getProfile(Request $request): void
{
$userId = (int)$request->getHeader('x-user-id');
$pdo = Database::getConnection();
$stmt = $pdo->prepare('
SELECT u.id, u.phone, u.full_name, u.role, u.status, u.created_at,
s.membership_number, s.status AS subscription_status, s.expires_at
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = "ACTIVE"
WHERE u.id = :uid
ORDER BY s.id DESC
LIMIT 1
');
$stmt->execute([':uid' => $userId]);
$profile = $stmt->fetch(PDO::FETCH_ASSOC);
Response::success($profile, 'User profile retrieved.');
}
}
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Core\Request;
use Core\Response;
use Core\Database;
use PDO;
class GatewayController
{
private string $configuredAppKey;
public function __construct()
{
$this->configuredAppKey = getenv('GATEWAY_APP_KEY') ?: 'uruk_gateway_secret_2026';
}
/**
* Authenticate gateway device app_key
*/
private function authenticate(string $appKey): bool
{
return hash_equals($this->configuredAppKey, $appKey);
}
/**
* GET /api/v1/gateway/pending-call or /pending-call.php
*/
public function pendingCall(Request $request): void
{
$deviceId = (string)$request->get('device_id');
$appKey = (string)$request->get('app_key');
if (!$this->authenticate($appKey) || empty($deviceId)) {
Response::json([
'task_id' => null,
'phone' => null,
'caller_id' => null,
'otp' => null,
'timeout_seconds' => null,
'error' => 'Unauthorized or missing device_id'
], 401);
return;
}
$pdo = Database::getConnection();
// Update device heartbeat
$this->heartbeat($pdo, $deviceId);
// Fetch oldest pending FLASH_CALL task
$stmt = $pdo->prepare('
SELECT id, target_phone, otp_code, timeout_seconds
FROM gateway_tasks
WHERE task_type = "FLASH_CALL"
AND (assigned_device_id = :dev OR assigned_device_id IS NULL)
AND status = "PENDING"
AND created_at >= NOW() - INTERVAL 60 SECOND
ORDER BY id ASC
LIMIT 1
');
$stmt->execute([':dev' => $deviceId]);
$task = $stmt->fetch(PDO::FETCH_ASSOC);
if ($task) {
// Lock task to this device
$upd = $pdo->prepare('UPDATE gateway_tasks SET status = "PROCESSING", assigned_device_id = :dev WHERE id = :id');
$upd->execute([':dev' => $deviceId, ':id' => $task['id']]);
Response::json([
'task_id' => (int)$task['id'],
'phone' => $task['target_phone'],
'caller_id' => null,
'otp' => $task['otp_code'],
'timeout_seconds' => (int)($task['timeout_seconds'] ?: 25),
]);
return;
}
Response::json([
'task_id' => null,
'phone' => null,
'caller_id' => null,
'otp' => null,
'timeout_seconds' => null,
]);
}
/**
* GET /api/v1/gateway/pending-sms or /pending-sms.php
*/
public function pendingSms(Request $request): void
{
$deviceId = (string)$request->get('device_id');
$appKey = (string)$request->get('app_key');
if (!$this->authenticate($appKey) || empty($deviceId)) {
Response::json([
'task_id' => null,
'phone' => null,
'caller_id' => null,
'otp' => null,
'timeout_seconds' => null,
'error' => 'Unauthorized or missing device_id'
], 401);
return;
}
$pdo = Database::getConnection();
$this->heartbeat($pdo, $deviceId);
$stmt = $pdo->prepare('
SELECT id, target_phone, otp_code, timeout_seconds
FROM gateway_tasks
WHERE task_type = "SMS"
AND (assigned_device_id = :dev OR assigned_device_id IS NULL)
AND status = "PENDING"
AND created_at >= NOW() - INTERVAL 120 SECOND
ORDER BY id ASC
LIMIT 1
');
$stmt->execute([':dev' => $deviceId]);
$task = $stmt->fetch(PDO::FETCH_ASSOC);
if ($task) {
$upd = $pdo->prepare('UPDATE gateway_tasks SET status = "PROCESSING", assigned_device_id = :dev WHERE id = :id');
$upd->execute([':dev' => $deviceId, ':id' => $task['id']]);
Response::json([
'task_id' => (int)$task['id'],
'phone' => $task['target_phone'],
'caller_id' => null,
'otp' => $task['otp_code'],
'timeout_seconds' => (int)($task['timeout_seconds'] ?: 30),
]);
return;
}
Response::json([
'task_id' => null,
'phone' => null,
'caller_id' => null,
'otp' => null,
'timeout_seconds' => null,
]);
}
/**
* POST /api/v1/gateway/call-done or /call-done.php
*/
public function callDone(Request $request): void
{
$taskId = (int)$request->get('task_id');
$deviceId = (string)$request->get('device_id');
$appKey = (string)$request->get('app_key');
$result = (string)$request->get('result');
if (!$this->authenticate($appKey)) {
Response::json(['success' => false, 'message' => 'Unauthorized'], 401);
return;
}
$pdo = Database::getConnection();
$newStatus = (strtoupper($result) === 'SUCCESS' || strtoupper($result) === 'DONE') ? 'COMPLETED' : 'FAILED';
$stmt = $pdo->prepare('
UPDATE gateway_tasks
SET status = :st, completed_at = NOW()
WHERE id = :id AND assigned_device_id = :dev
');
$stmt->execute([':st' => $newStatus, ':id' => $taskId, ':dev' => $deviceId]);
Response::json([
'success' => true,
'message' => "Call task {$taskId} recorded as {$newStatus}.",
'device_id' => $deviceId,
]);
}
/**
* POST /api/v1/gateway/sms-done or /sms-done.php
*/
public function smsDone(Request $request): void
{
$taskId = (int)$request->get('task_id');
$deviceId = (string)$request->get('device_id');
$appKey = (string)$request->get('app_key');
$result = (string)$request->get('result');
if (!$this->authenticate($appKey)) {
Response::json(['success' => false, 'message' => 'Unauthorized'], 401);
return;
}
$pdo = Database::getConnection();
$newStatus = (strtoupper($result) === 'SUCCESS' || strtoupper($result) === 'SENT') ? 'COMPLETED' : 'FAILED';
$stmt = $pdo->prepare('
UPDATE gateway_tasks
SET status = :st, completed_at = NOW()
WHERE id = :id AND assigned_device_id = :dev
');
$stmt->execute([':st' => $newStatus, ':id' => $taskId, ':dev' => $deviceId]);
Response::json([
'success' => true,
'message' => "SMS task {$taskId} recorded as {$newStatus}.",
'device_id' => $deviceId,
]);
}
/**
* POST /api/v1/gateway/register-device or /register-device.php
*/
public function registerDevice(Request $request): void
{
$deviceId = (string)$request->get('device_id');
$phoneNumber = (string)$request->get('phone_number');
$simSlot = (int)$request->get('sim_slot', 1);
$appKey = (string)$request->get('app_key');
if (!$this->authenticate($appKey) || empty($deviceId)) {
Response::json(['success' => false, 'message' => 'Unauthorized or missing device_id'], 401);
return;
}
$pdo = Database::getConnection();
$stmt = $pdo->prepare('
INSERT INTO gateway_devices (device_id, phone_number, sim_slot, status, last_heartbeat)
VALUES (:did, :phone, :slot, "ACTIVE", NOW())
ON DUPLICATE KEY UPDATE
phone_number = VALUES(phone_number),
sim_slot = VALUES(sim_slot),
status = "ACTIVE",
last_heartbeat = NOW()
');
$stmt->execute([
':did' => $deviceId,
':phone' => $phoneNumber,
':slot' => $simSlot,
]);
Response::json([
'success' => true,
'message' => 'Gateway device registered and active.',
'device_id' => $deviceId,
]);
}
private function heartbeat(PDO $pdo, string $deviceId): void
{
try {
$stmt = $pdo->prepare('
INSERT INTO gateway_devices (device_id, status, last_heartbeat)
VALUES (:did, "ACTIVE", NOW())
ON DUPLICATE KEY UPDATE status = "ACTIVE", last_heartbeat = NOW()
');
$stmt->execute([':did' => $deviceId]);
} catch (\Throwable $e) {
// Ignore heartbeat failures if table schema is pending
}
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Core\Request;
use Core\Response;
use Core\Database;
use PDO;
class PartnerController
{
/**
* GET /api/v1/partners
* Query params: type (HOSPITAL, HOTEL), country, city, search
*/
public function listPartners(Request $request): void
{
$type = strtoupper(trim((string)$request->getQuery('type', '')));
$country = trim((string)$request->getQuery('country', ''));
$city = trim((string)$request->getQuery('city', ''));
$search = trim((string)$request->getQuery('search', ''));
$pdo = Database::getConnection();
$sql = 'SELECT id, type, name_ar, name_en, category, country, city, discount_percentage, address, phone, latitude, longitude
FROM partners
WHERE is_active = 1';
$params = [];
if (!empty($type)) {
$sql .= ' AND type = :type';
$params[':type'] = $type;
}
if (!empty($country)) {
$sql .= ' AND country = :country';
$params[':country'] = $country;
}
if (!empty($city)) {
$sql .= ' AND city = :city';
$params[':city'] = $city;
}
if (!empty($search)) {
$sql .= ' AND (name_ar LIKE :search OR name_en LIKE :search OR category LIKE :search)';
$params[':search'] = '%' . $search . '%';
}
$sql .= ' ORDER BY discount_percentage DESC, id DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$partners = $stmt->fetchAll(PDO::FETCH_ASSOC);
Response::success($partners, 'Partners directory retrieved.');
}
/**
* GET /api/v1/partners/{id}
*/
public function getPartner(Request $request, array $params): void
{
$partnerId = (int)($params['id'] ?? 0);
$pdo = Database::getConnection();
$stmt = $pdo->prepare('SELECT * FROM partners WHERE id = :id AND is_active = 1 LIMIT 1');
$stmt->execute([':id' => $partnerId]);
$partner = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$partner) {
Response::notFound('Partner not found.');
}
Response::success($partner, 'Partner details retrieved.');
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Core\Request;
use Core\Response;
use App\Services\SuperQiParserService;
class PaymentController
{
/**
* POST /api/v1/payments/ingest-notification
* Invoked securely by the Android Bridge Listener app when a SuperQi/ZainCash notification arrives.
*/
public function ingestNotification(Request $request): void
{
$secret = $request->getHeader('x-listener-secret');
$expectedSecret = getenv('NOTIFICATION_INGEST_SECRET') ?: 'secure_ingest_key_for_android_bridge_listener';
if ($secret !== $expectedSecret) {
Response::forbidden('Invalid listener secret.');
}
$body = $request->getBody();
$result = SuperQiParserService::processIngestedNotification($body);
if (!$result['success']) {
Response::error($result['message'] ?? 'Ingestion failed', 400);
}
Response::success($result, 'Notification ingested and processed.');
}
/**
* POST /api/v1/payments/swiftpay-webhook
*/
public function swiftPayWebhook(Request $request): void
{
// Handle SwiftPayIQ transaction webhook
$payload = $request->getBody();
Response::success(['status' => 'received'], 'Webhook processed.');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Core\Request;
use Core\Response;
use App\Services\QrTokenService;
class QrController
{
/**
* GET /api/v1/qr/dynamic-token
* User's Flutter app requests a rotating QR token.
*/
public function getDynamicToken(Request $request): void
{
$userId = (int)$request->getHeader('x-user-id');
$res = QrTokenService::generateToken($userId);
if (!$res['success']) {
Response::forbidden($res['message']);
}
Response::success($res, 'Dynamic QR token generated.');
}
/**
* POST /api/v1/qr/verify-token
* Scanned by hospital/hotel front desk or verification portal.
*/
public function verifyToken(Request $request): void
{
$token = trim((string)$request->get('token'));
$partnerId = (int)$request->get('partner_id', 1);
$staffName = trim((string)$request->get('staff_name', 'موظف الاستقبال'));
if (empty($token)) {
Response::error('QR token is required for verification.');
}
$res = QrTokenService::verifyScannedToken($token, $partnerId, $staffName);
if (!$res['success']) {
Response::error($res['message'], 400);
}
Response::success($res, 'Member verified successfully.');
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Core\Request;
use Core\Response;
use Core\Security;
use Core\Database;
use App\Services\SuperQiParserService;
use PDO;
class SubscriptionController
{
/**
* GET /api/v1/subscription/plans
*/
public function getPlans(Request $request): void
{
$appConfig = require __DIR__ . '/../../config/app.php';
$plans = [
[
'id' => 'uruk-2year-standard',
'name' => 'عضوية جائزة أوروك التنفيذية (سنتان)',
'duration_years' => 2,
'price_usd' => (float)$appConfig['membership_price_usd'],
'price_iqd' => (float)$appConfig['membership_price_iqd'],
'benefits' => [
'خصم 50% في شبكة المستشفيات والمراكز التخصصية في العراق والأردن ومصر ولبنان',
'خصومات فندقية وسياحية تتراوح بين 40% و60% في أرقى الفنادق الشريكة',
'وصول مجاني لأكثر من 30 تخصصاً تدريبياً معتمداً حضورياً وعبر المنصة الرقمية',
'شهادات معتمدة ومسجلة رسمياً باسم المشترك',
'هوية رقمية ذكية مع رمز QR مشفر للتحقق الفوري',
],
'official_activation_date' => '2026-01-01',
'early_registration_active' => true,
],
];
Response::success($plans, 'Available subscription plans retrieved.');
}
/**
* POST /api/v1/subscription/submit
* User submits payment details (Method, Ref Number, Receipt).
*/
public function submitSubscription(Request $request): void
{
$userId = (int)$request->getHeader('x-user-id');
$method = strtoupper(trim((string)$request->get('method', 'SUPER_QI')));
$refNumber = trim((string)$request->get('reference_number'));
$receiptUrl = trim((string)$request->get('receipt_url', ''));
if (empty($refNumber)) {
Response::error('Payment transaction reference number is required.');
}
$appConfig = require __DIR__ . '/../../config/app.php';
$pdo = Database::getConnection();
// 1. Generate unique membership number (e.g. URUK-2026-XXXX)
$memNumber = 'URUK-' . date('Y') . '-' . strtoupper(substr(uniqid(), -5));
$qrSeed = Security::generateRandomHex(16);
$pdo->beginTransaction();
try {
// Create subscription record with PENDING_PAYMENT
$subStmt = $pdo->prepare('
INSERT INTO subscriptions (user_id, membership_number, plan_name, price_usd, price_local, currency, starts_at, expires_at, status, qr_seed, created_at)
VALUES (:uid, :mem, "2-Year Executive Membership", :usd, :iqd, "IQD", CURDATE(), DATE_ADD(CURDATE(), INTERVAL 2 YEAR), "PENDING_PAYMENT", :seed, NOW())
');
$subStmt->execute([
':uid' => $userId,
':mem' => $memNumber,
':usd' => $appConfig['membership_price_usd'],
':iqd' => $appConfig['membership_price_iqd'],
':seed' => $qrSeed,
]);
$subscriptionId = (int)$pdo->lastInsertId();
// Create transaction record
$transStmt = $pdo->prepare('
INSERT INTO transactions (subscription_id, user_id, method, reference_number, amount, currency, status, receipt_image_url, created_at)
VALUES (:sub_id, :uid, :method, :ref, :amount, "IQD", "SUBMITTED", :receipt, NOW())
');
$transStmt->execute([
':sub_id' => $subscriptionId,
':uid' => $userId,
':method' => $method,
':ref' => $refNumber,
':amount' => $appConfig['membership_price_iqd'],
':receipt' => $receiptUrl,
]);
$pdo->commit();
// 2. Check if a matching SuperQi / ZainCash notification was already ingested by the Android listener
$checkStmt = $pdo->prepare('
SELECT id, amount, status FROM transactions
WHERE reference_number = :ref AND id != :current_id AND status = "VERIFIED_AUTO"
LIMIT 1
');
$checkStmt->execute([':ref' => $refNumber, ':current_id' => (int)$pdo->lastInsertId()]);
$preExistingPayment = $checkStmt->fetch(PDO::FETCH_ASSOC);
if ($preExistingPayment) {
// Instantly activate!
$actSub = $pdo->prepare('UPDATE subscriptions SET status = "ACTIVE" WHERE id = :id');
$actSub->execute([':id' => $subscriptionId]);
$actTrans = $pdo->prepare('UPDATE transactions SET status = "VERIFIED_AUTO", verified_at = NOW() WHERE subscription_id = :id');
$actTrans->execute([':id' => $subscriptionId]);
Response::success([
'subscription_id' => $subscriptionId,
'membership_number' => $memNumber,
'status' => 'ACTIVE',
'message' => 'تم التحقق من الحوالة آلياً بنجاح وتفعيل العضوية فورياً!',
], 'Membership activated automatically via SuperQi verification.');
}
Response::success([
'subscription_id' => $subscriptionId,
'membership_number' => $memNumber,
'status' => 'PENDING_PAYMENT',
'message' => 'تم استلام طلبك بنجاح وجارٍ التحقق من الحوالة وتفعيل بطاقتك.',
], 'Subscription request registered. Verification in progress.');
} catch (\Throwable $e) {
$pdo->rollBack();
Response::error('Failed to submit subscription: ' . $e->getMessage(), 500);
}
}
/**
* GET /api/v1/subscription/my-card
*/
public function getMyCard(Request $request): void
{
$userId = (int)$request->getHeader('x-user-id');
$pdo = Database::getConnection();
$stmt = $pdo->prepare('
SELECT s.id, s.membership_number, s.plan_name, s.status, s.starts_at, s.expires_at,
u.full_name, u.phone, u.avatar_url
FROM subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.user_id = :uid
ORDER BY s.id DESC
LIMIT 1
');
$stmt->execute([':uid' => $userId]);
$card = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$card) {
Response::notFound('No subscription card found for this user.');
}
Response::success($card, 'Digital card details retrieved.');
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Middlewares;
use Core\Middleware;
use Core\Request;
use Core\Response;
use Core\Database;
use PDO;
class AdminMiddleware extends Middleware
{
public function handle(Request $request, callable $next): void
{
$userId = $request->getHeader('x-user-id');
try {
$pdo = Database::getConnection();
$stmt = $pdo->prepare('SELECT role FROM users WHERE id = :user_id LIMIT 1');
$stmt->execute([':user_id' => $userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !in_array($user['role'], ['ADMIN', 'SUPER_ADMIN'], true)) {
Response::forbidden('Administrative privileges required to access this resource.');
}
} catch (\Throwable $e) {
Response::error('Role verification failure: ' . $e->getMessage(), 500);
}
$next($request);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Middlewares;
use Core\Middleware;
use Core\Request;
use Core\Response;
use Core\Database;
use PDO;
class AuthMiddleware extends Middleware
{
public function handle(Request $request, callable $next): void
{
$authHeader = $request->getHeader('authorization');
if (empty($authHeader) || !str_starts_with($authHeader, 'Bearer ')) {
Response::unauthorized('Bearer token required in Authorization header.');
}
$token = substr($authHeader, 7);
$userId = $request->getHeader('x-user-id');
try {
$pdo = Database::getConnection();
$stmt = $pdo->prepare('
SELECT u.id, u.phone, u.full_name, u.role, u.status, us.last_token
FROM users u
JOIN user_security us ON us.user_id = u.id
WHERE u.id = :user_id AND us.last_token = :token
LIMIT 1
');
$stmt->execute([
':user_id' => $userId,
':token' => $token,
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
Response::unauthorized('Invalid or expired authentication session token.');
}
if ($user['status'] !== 'ACTIVE' && $user['status'] !== 'PENDING_VERIFICATION') {
Response::forbidden('User account is not currently active.');
}
} catch (\Throwable $e) {
Response::error('Authentication verification failure: ' . $e->getMessage(), 500);
}
$next($request);
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Middlewares;
use Core\Middleware;
use Core\Request;
use Core\Response;
use Core\Security;
use Core\Database;
use PDO;
class SecurityHeaderMiddleware extends Middleware
{
public function handle(Request $request, callable $next): void
{
$userId = $request->getHeader('x-user-id');
$fingerprint = $request->getHeader('x-device-fingerprint');
$timestamp = (int)$request->getHeader('x-timestamp');
$nonce = $request->getHeader('x-nonce');
$signature = $request->getHeader('x-signature');
// 1. Check presence of all required security headers
if (empty($userId) || empty($fingerprint) || empty($timestamp) || empty($nonce) || empty($signature)) {
Response::unauthorized('Security headers missing: X-User-Id, X-Device-Fingerprint, X-Timestamp, X-Nonce, and X-Signature are mandatory.');
}
// 2. Anti-replay timestamp check (window of 120 seconds)
$currentTime = time();
if (abs($currentTime - $timestamp) > 120) {
Response::unauthorized('Request timestamp expired or skewed. Anti-replay check failed.');
}
// 3. Query user security record from Database
try {
$pdo = Database::getConnection();
$stmt = $pdo->prepare('
SELECT us.device_fingerprint, us.device_secret, us.is_locked, u.status
FROM user_security us
JOIN users u ON u.id = us.user_id
WHERE us.user_id = :user_id AND us.device_fingerprint = :fingerprint
LIMIT 1
');
$stmt->execute([
':user_id' => $userId,
':fingerprint' => $fingerprint,
]);
$secRecord = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$secRecord) {
Response::forbidden('Unrecognized device fingerprint or unauthorized device binding.');
}
if ((int)$secRecord['is_locked'] === 1 || in_array($secRecord['status'], ['SUSPENDED', 'BANNED'], true)) {
Response::forbidden('User device or account has been locked or suspended.');
}
// 4. Verify HMAC-SHA256 signature
$rawBody = $request->getRawBody();
$bodyHash = hash('sha256', $rawBody);
$signPayload = implode("\n", [
$request->getMethod(),
$request->getPath(),
(string)$timestamp,
$nonce,
$bodyHash,
]);
if (!Security::verifyHmacSignature($signPayload, $signature, $secRecord['device_secret'])) {
Response::unauthorized('Invalid cryptographic signature for this device and payload.');
}
// 5. Update last active timestamp and IP asynchronously / inline
$updateStmt = $pdo->prepare('
UPDATE user_security
SET last_active_at = NOW(), last_ip = :ip
WHERE user_id = :user_id AND device_fingerprint = :fingerprint
');
$updateStmt->execute([
':ip' => $request->getIp(),
':user_id' => $userId,
':fingerprint' => $fingerprint,
]);
} catch (\Throwable $e) {
Response::error('Security verification error: ' . $e->getMessage(), 500);
}
$next($request);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Services;
class NabihCaptchaService
{
/**
* Generates a unique, distorted Captcha OTP image with noise to bypass Meta template restrictions.
* Returns the raw PNG binary or base64 encoded data.
*/
public static function generateOtpImage(string $code, int $width = 240, int $height = 80): string
{
$image = imagecreatetruecolor($width, $height);
// 1. Generate randomized subtle background tint
$bgR = random_int(235, 250);
$bgG = random_int(245, 255);
$bgB = random_int(240, 250);
$bgColor = imagecolorallocate($image, $bgR, $bgG, $bgB);
imagefilledrectangle($image, 0, 0, $width, $height, $bgColor);
// 2. Add random noise dots
for ($i = 0; $i < 120; $i++) {
$dotColor = imagecolorallocate(
$image,
random_int(120, 200),
random_int(180, 230),
random_int(150, 210)
);
imagesetpixel($image, random_int(0, $width), random_int(0, $height), $dotColor);
}
// 3. Add random interference crossing lines
for ($i = 0; $i < 6; $i++) {
$lineColor = imagecolorallocate(
$image,
random_int(80, 160),
random_int(180, 220),
random_int(140, 190)
);
imagesetthickness($image, random_int(1, 2));
imageline(
$image,
random_int(0, $width),
random_int(0, $height),
random_int(0, $width),
random_int(0, $height),
$lineColor
);
}
// 4. Render Digits with randomized spacing and slight position jitter
$textColor = imagecolorallocate($image, 15, 60, 50); // Deep Teal / Charcoal
$len = strlen($code);
$spacing = (int)($width / ($len + 1));
for ($idx = 0; $idx < $len; $idx++) {
$char = $code[$idx];
$x = ($idx + 1) * $spacing - 10 + random_int(-3, 3);
$y = (int)($height / 2) - 10 + random_int(-4, 4);
// Using standard built-in font for zero dependencies
imagestring($image, 5, $x, $y, $char, $textColor);
}
ob_start();
imagepng($image);
$imageData = ob_get_clean();
imagedestroy($image);
return (string)$imageData;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Services;
class OtpIqService
{
private string $apiKey;
private string $senderId;
private string $endpoint;
public function __construct()
{
$this->apiKey = getenv('OTPIQ_API_KEY') ?: '';
$this->senderId = getenv('OTPIQ_SENDER_ID') ?: 'URUK-PRIZE';
$this->endpoint = getenv('OTPIQ_ENDPOINT') ?: 'https://api.otpiq.com/api/sms';
}
/**
* Sends OTP via OTPIQ with Smart Fallback (WhatsApp first, then SMS).
*/
public function sendOtp(string $phoneNumber, string $otpCode): array
{
// If API key is not configured, run in Mock / Dev mode
if (empty($this->apiKey)) {
error_log("[MOCK OTPIQ] Sent OTP {$otpCode} to {$phoneNumber} via WhatsApp/SMS Smart Fallback.");
return [
'success' => true,
'mode' => 'mock',
'phone' => $phoneNumber,
'otp' => $otpCode,
'provider' => 'mock-smart-fallback',
];
}
$payload = [
'recipient' => $phoneNumber,
'sender_id' => $this->senderId,
'type' => 'otp',
'code' => $otpCode,
'message' => "رمز التحقق الخاص بك لجائزة أوروك الدولية هو: {$otpCode}. لا تشاركه مع أحد.",
'channels' => ['whatsapp', 'sms'], // Smart fallback pipeline
];
$ch = curl_init($this->endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey,
],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error || $httpCode >= 400) {
return [
'success' => false,
'error' => $error ?: "HTTP {$httpCode}: " . substr((string)$response, 0, 100),
];
}
$data = json_decode((string)$response, true);
return [
'success' => true,
'data' => $data,
];
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Services;
class PhoneFormatterService
{
/**
* Normalizes a phone number to standard E.164 digits format (without '+').
* Automatically handles leading zeros and national dialing prefixes.
*
* Example Iraq: 07701234567 -> 9647701234567
* Example Iraq: +964 0780 1234 -> 9647801234
* Example Jordan: 0791234567 -> 962791234567
*/
public static function normalize(string $phone, string $defaultCountryCode = '964'): string
{
// 1. Remove all non-digit characters (+, -, spaces, dots, brackets)
$cleaned = preg_replace('/[^\d]/', '', $phone);
if (empty($cleaned)) {
return '';
}
// 2. Remove international double-zero prefixes (e.g. 00964 -> 964)
if (str_starts_with($cleaned, '00')) {
$cleaned = substr($cleaned, 2);
}
// 3. Supported Country Code Detection & Cleaning
$knownCountryCodes = ['964', '962', '20', '961', '966', '971'];
foreach ($knownCountryCodes as $cc) {
if (str_starts_with($cleaned, $cc)) {
$localPart = substr($cleaned, strlen($cc));
// Remove any redundant leading zero after country code (e.g. 964 0770... -> 964 770...)
$localPart = ltrim($localPart, '0');
return $cc . $localPart;
}
}
// 4. If no recognized country code is attached, treat as national number with default country code
// Remove leading national zero (e.g. 0770... -> 770...)
$nationalPart = ltrim($cleaned, '0');
return $defaultCountryCode . $nationalPart;
}
/**
* Formats normalized number for display in UI.
* e.g. 9647701234567 -> +964 770 123 4567
*/
public static function formatDisplay(string $normalized): string
{
if (str_starts_with($normalized, '964') && strlen($normalized) === 13) {
return '+964 ' . substr($normalized, 3, 3) . ' ' . substr($normalized, 6, 3) . ' ' . substr($normalized, 9);
}
if (str_starts_with($normalized, '962') && strlen($normalized) === 12) {
return '+962 ' . substr($normalized, 3, 2) . ' ' . substr($normalized, 5, 3) . ' ' . substr($normalized, 8);
}
return '+' . $normalized;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Core\Security;
use Core\Database;
use PDO;
class QrTokenService
{
/**
* Generates a dynamic QR token valid for 90 seconds.
*/
public static function generateToken(int $userId): array
{
$pdo = Database::getConnection();
$stmt = $pdo->prepare('
SELECT s.id AS sub_id, s.membership_number, s.status, s.qr_seed, s.expires_at, u.full_name
FROM subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.user_id = :uid AND s.status = "ACTIVE"
ORDER BY s.id DESC
LIMIT 1
');
$stmt->execute([':uid' => $userId]);
$sub = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$sub) {
return [
'success' => false,
'message' => 'No active membership found for this user.',
];
}
$appConfig = require __DIR__ . '/../../config/app.php';
$secConfig = require __DIR__ . '/../../config/security.php';
$secret = $appConfig['secret'] . ':' . $sub['qr_seed'];
$lifetime = (int)($secConfig['qr_token_lifetime'] ?? 90);
$token = Security::generateDynamicQrToken(
$userId,
$sub['membership_number'],
$lifetime,
$secret
);
return [
'success' => true,
'token' => $token,
'membership_number' => $sub['membership_number'],
'full_name' => $sub['full_name'],
'expires_in' => $lifetime,
'valid_until' => time() + $lifetime,
];
}
/**
* Verifies a QR token scanned by a hospital or hotel receptionist.
*/
public static function verifyScannedToken(string $token, int $partnerId, ?string $staffName = null): array
{
// Decode base64 payload to get user ID without signature first
$parts = explode('.', $token);
if (count($parts) !== 2) {
return ['success' => false, 'message' => 'Invalid QR token format.'];
}
$json = base64_decode(strtr($parts[0], '-_', '+/'));
$tempPayload = json_decode((string)$json, true);
if (!isset($tempPayload['uid'], $tempPayload['mem'])) {
return ['success' => false, 'message' => 'Corrupted token payload.'];
}
$pdo = Database::getConnection();
$stmt = $pdo->prepare('
SELECT s.id AS sub_id, s.membership_number, s.status, s.qr_seed, s.expires_at,
u.id AS user_id, u.full_name, u.phone, u.avatar_url
FROM subscriptions s
JOIN users u ON u.id = s.user_id
WHERE u.id = :uid AND s.membership_number = :mem
LIMIT 1
');
$stmt->execute([
':uid' => (int)$tempPayload['uid'],
':mem' => $tempPayload['mem'],
]);
$record = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$record) {
return ['success' => false, 'message' => 'Membership record not found.'];
}
$appConfig = require __DIR__ . '/../../config/app.php';
$secret = $appConfig['secret'] . ':' . $record['qr_seed'];
$verified = Security::verifyDynamicQrToken($token, $secret);
if (!$verified) {
return [
'success' => false,
'message' => 'Token has expired or signature is counterfeit. Please refresh code in app.',
];
}
// Get partner discount details
$pStmt = $pdo->prepare('SELECT id, name_ar, type, discount_percentage FROM partners WHERE id = :pid LIMIT 1');
$pStmt->execute([':pid' => $partnerId]);
$partner = $pStmt->fetch(PDO::FETCH_ASSOC);
$discount = $partner ? (float)$partner['discount_percentage'] : 50.00;
// Log verification entry for audit & statistics
$logStmt = $pdo->prepare('
INSERT INTO verification_logs (subscription_id, partner_id, verifier_staff_name, applied_discount, service_type, scanned_token_signature, verified_at)
VALUES (:sub_id, :pid, :staff, :discount, :stype, :sig, NOW())
');
$logStmt->execute([
':sub_id' => $record['sub_id'],
':pid' => $partnerId,
':staff' => $staffName ?: 'Front Desk',
':discount' => $discount,
':stype' => $partner['type'] ?? 'HOSPITAL',
':sig' => substr($parts[1], 0, 64),
]);
return [
'success' => true,
'is_valid' => true,
'membership_number' => $record['membership_number'],
'member_name' => $record['full_name'],
'member_phone' => $record['phone'],
'status' => $record['status'],
'expires_at' => $record['expires_at'],
'partner_name' => $partner['name_ar'] ?? 'Partner',
'discount_percentage' => $discount,
'verified_at' => date('Y-m-d H:i:s'),
];
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Core\Database;
use PDO;
class SuperQiParserService
{
/**
* Ingests and processes a notification or parsed receipt from the Android listener bridge.
*/
public static function processIngestedNotification(array $data): array
{
$refNumber = trim((string)($data['reference_number'] ?? ''));
$amount = (float)($data['amount'] ?? 0);
$sender = trim((string)($data['sender'] ?? ''));
$recipient = trim((string)($data['recipient'] ?? ''));
$rawText = (string)($data['raw_text'] ?? '');
if (empty($refNumber) || $amount <= 0) {
return [
'success' => false,
'message' => 'Invalid transaction reference or amount.',
];
}
$pdo = Database::getConnection();
// 1. Check if transaction has already been recorded and verified
$stmt = $pdo->prepare('SELECT id, status FROM transactions WHERE reference_number = :ref LIMIT 1');
$stmt->execute([':ref' => $refNumber]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing && $existing['status'] === 'VERIFIED_AUTO') {
return [
'success' => true,
'message' => 'Transaction already processed and verified previously.',
'transaction_id' => $existing['id'],
];
}
// 2. Search for a pending subscription matching this reference or pending payment
// First, check if any user submitted this reference number
$stmt = $pdo->prepare('
SELECT t.id AS trans_id, t.subscription_id, s.user_id, s.plan_name, u.phone, u.full_name
FROM transactions t
JOIN subscriptions s ON s.id = t.subscription_id
JOIN users u ON u.id = s.user_id
WHERE t.reference_number = :ref AND t.status = "SUBMITTED"
LIMIT 1
');
$stmt->execute([':ref' => $refNumber]);
$matched = $stmt->fetch(PDO::FETCH_ASSOC);
if ($matched) {
$pdo->beginTransaction();
try {
// Update transaction
$updTrans = $pdo->prepare('
UPDATE transactions
SET status = "VERIFIED_AUTO", amount = :amount, verified_at = NOW(),
raw_webhook_payload = :raw
WHERE id = :id
');
$updTrans->execute([
':amount' => $amount,
':raw' => json_encode($data, JSON_UNESCAPED_UNICODE),
':id' => $matched['trans_id'],
]);
// Activate subscription
$updSub = $pdo->prepare('
UPDATE subscriptions
SET status = "ACTIVE", starts_at = CURDATE(), expires_at = DATE_ADD(CURDATE(), INTERVAL 2 YEAR)
WHERE id = :sub_id
');
$updSub->execute([':sub_id' => $matched['subscription_id']]);
// Ensure user status is active
$updUser = $pdo->prepare('UPDATE users SET status = "ACTIVE" WHERE id = :user_id');
$updUser->execute([':user_id' => $matched['user_id']]);
$pdo->commit();
return [
'success' => true,
'matched' => true,
'message' => 'Subscription activated automatically for ' . $matched['full_name'],
'user_id' => $matched['user_id'],
'phone' => $matched['phone'],
];
} catch (\Throwable $e) {
$pdo->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
// If no user has claimed it yet, record it in transactions as unassigned credit
$insStmt = $pdo->prepare('
INSERT INTO transactions (subscription_id, user_id, method, reference_number, sender_account_or_phone, recipient_account, amount, currency, status, raw_webhook_payload, verified_at)
VALUES (0, 0, "SUPER_QI", :ref, :sender, :recipient, :amount, "IQD", "VERIFIED_AUTO", :raw, NOW())
ON DUPLICATE KEY UPDATE amount = VALUES(amount), raw_webhook_payload = VALUES(raw_webhook_payload)
');
$insStmt->execute([
':ref' => $refNumber,
':sender' => $sender,
':recipient' => $recipient,
':amount' => $amount,
':raw' => json_encode($data, JSON_UNESCAPED_UNICODE),
]);
return [
'success' => true,
'matched' => false,
'message' => 'Transaction recorded. Awaiting user claim in app.',
'reference_number' => $refNumber,
];
}
}