Initial commit: Uruk Prize Platform architecture, backend core, mobile app, gateway caller & deployment pipeline
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user