Update: 2026-05-07 03:06:15

This commit is contained in:
Hamza-Ayed
2026-05-07 03:06:15 +03:00
parent 272971fc5b
commit bfb6368ec8
28 changed files with 3292 additions and 188 deletions

View File

@@ -0,0 +1,149 @@
<?php
/**
* Bank Bot Webhook
* POST /api/v1/payments/bot-webhook
*
* Receives SMS notifications from the Android bot.
* Extracts the reference number and amount.
* Matches with pending payment requests to auto-activate subscriptions.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\Security;
use App\Core\Validator;
$data = Security::sanitize(input());
// Simple Auth for the Bot
$botToken = env('BOT_WEBHOOK_TOKEN', 'musadaq-bot-secret-123');
$providedToken = $_SERVER['HTTP_X_BOT_TOKEN'] ?? $data['token'] ?? '';
if ($providedToken !== $botToken) {
json_error('Unauthorized', 401);
}
$errors = Validator::validate($data, [
'raw_message' => 'required'
]);
if ($errors) {
json_error('رسالة البنك مطلوبة.', 422);
}
$rawMessage = $data['raw_message'];
$bankReference = trim($data['bank_reference'] ?? '');
$amount = (float)($data['amount'] ?? 0);
$senderName = $data['sender_name'] ?? 'غير معروف';
if (empty($bankReference) || $amount <= 0) {
json_error('بيانات التحويل غير مكتملة.', 422);
}
$db = Database::getInstance();
try {
$db->beginTransaction();
// 1. Insert into bank_transactions
$stmt = $db->prepare("
INSERT INTO bank_transactions (bank_reference, amount, sender_name, raw_message, is_claimed, created_at)
VALUES (?, ?, ?, ?, 0, NOW())
ON DUPLICATE KEY UPDATE raw_message = VALUES(raw_message)
");
$stmt->execute([$bankReference, $amount, $senderName, $rawMessage]);
$transactionId = $db->lastInsertId();
if (!$transactionId) {
$transactionId = $db->query("SELECT id FROM bank_transactions WHERE bank_reference = '$bankReference'")->fetchColumn();
}
// 2. Check if there is a pending payment request waiting for this reference
$stmt = $db->prepare("SELECT * FROM payment_requests WHERE bank_reference = ? AND status IN ('pending', 'uploaded')");
$stmt->execute([$bankReference]);
$payment = $stmt->fetch();
$message = 'تم استلام وتخزين الحوالة البنكية.';
if ($payment) {
// Match found! Check amount
$expectedAmount = (float)$payment['amount_jod'];
if (abs($expectedAmount - $amount) < 0.01) {
// Amount matches exactly -> Auto Approve
activateSubscription($db, $payment, $payment['user_id']);
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', verified_at = NOW() WHERE id = ?");
$stmt->execute([$payment['id']]);
$stmt = $db->prepare("UPDATE bank_transactions SET is_claimed = 1 WHERE id = ?");
$stmt->execute([$transactionId]);
$message = 'تم استلام الحوالة ومطابقتها وتفعيل الاشتراك بنجاح.';
} else {
// Amount mismatch -> Needs manual review
$stmt = $db->prepare("UPDATE payment_requests SET admin_notes = 'تم وصول الحوالة ولكن المبلغ غير متطابق' WHERE id = ?");
$stmt->execute([$payment['id']]);
$message = 'تم استلام الحوالة، لكن المبلغ لم يتطابق مع الطلب.';
}
}
$db->commit();
json_success(['status' => 'received'], $message);
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("Bot Webhook Error: " . $e->getMessage());
json_error('حدث خطأ أثناء معالجة رسالة البوت.', 500);
}
/**
* Auto-activate subscription upon verified payment
*/
function activateSubscription(\PDO $db, array $payment, string $userId): void
{
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
$stmt->execute([$payment['plan_id']]);
$plan = $stmt->fetch();
if (!$plan) return;
$startDate = date('Y-m-d H:i:s');
$endDate = date('Y-m-d H:i:s', strtotime('+30 days'));
$stmt = $db->prepare("
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at)
VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW())
ON DUPLICATE KEY UPDATE
plan_id = VALUES(plan_id),
max_companies = VALUES(max_companies),
max_invoices_per_month = VALUES(max_invoices_per_month),
max_users = VALUES(max_users),
price_jod = VALUES(price_jod),
status = 'active',
current_period_start = VALUES(current_period_start),
current_period_end = VALUES(current_period_end),
updated_at = NOW()
");
$stmt->execute([
't_id' => $payment['tenant_id'],
'p_id' => $plan['id'],
'max_c' => $plan['max_companies'],
'max_i' => $plan['max_invoices_month'],
'max_u' => $plan['max_users'],
'price' => $plan['price_jod'],
'start' => $startDate,
'end' => $endDate
]);
// Log activation
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)");
$logStmt->execute([
$payment['tenant_id'],
$userId,
$payment['id'],
json_encode(['plan_id' => $plan['id'], 'auto_verified' => true, 'source' => 'bot_webhook'])
]);
}

View File

@@ -0,0 +1,107 @@
<?php
/**
* Create Payment Request (Admin/Accountant)
* POST /api/v1/payments/create
*
* Creates a payment request for subscription upgrade.
* Returns CliQ alias and reference number for transfer.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\Validator;
use App\Core\Security;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
// Only admin or accountant can create payment requests
if (!in_array($decoded['role'], ['admin', 'accountant'])) {
json_error('غير مصرح لك بإنشاء طلب دفع.', 403);
}
$data = Security::sanitize(input());
$errors = Validator::validate($data, [
'plan_id' => 'required',
]);
if ($errors) {
json_error('معرف الباقة مطلوب.', 422);
}
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
$planId = $data['plan_id'];
try {
// 1. Get plan details
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
$stmt->execute([$planId]);
$plan = $stmt->fetch();
if (!$plan) {
json_error('الباقة المختارة غير صالحة أو غير نشطة.', 422);
}
// 2. Check for existing pending payment for this tenant
$stmt = $db->prepare("SELECT id FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1");
$stmt->execute([$tenantId]);
$existing = $stmt->fetch();
if ($existing) {
json_error('لديك طلب دفع قائم بالفعل. يرجى إتمامه أو إلغاؤه أولاً.', 409);
}
// 3. Generate unique reference number (MSQ-XXXXXX)
$referenceNumber = 'MSQ-' . strtoupper(substr(md5(uniqid((string)mt_rand(), true)), 0, 8));
// 4. Get CliQ alias from config
$cliqAlias = env('CLIQ_ALIAS', 'musadaq-pay');
// 5. Get payer name
$stmt = $db->prepare("SELECT name, phone FROM users WHERE id = ?");
$stmt->execute([$userId]);
$user = $stmt->fetch();
// 6. Create payment request
$paymentId = generate_uuid();
$stmt = $db->prepare("
INSERT INTO payment_requests (id, tenant_id, user_id, plan_id, amount_jod, internal_reference, cliq_alias, payer_name, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW())
");
$stmt->execute([
$paymentId,
$tenantId,
$userId,
$planId,
$plan['price_jod'],
$referenceNumber,
$cliqAlias,
$user['name'] ?? ''
]);
// 7. Log
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'payment.created', 'payment', ?, ?)");
$logStmt->execute([
$tenantId,
$userId,
$paymentId,
json_encode(['plan_id' => $planId, 'amount' => $plan['price_jod'], 'ref' => $referenceNumber])
]);
json_success([
'payment_id' => $paymentId,
'reference_number' => $referenceNumber,
'cliq_alias' => $cliqAlias,
'amount_jod' => (float)$plan['price_jod'],
'plan_name' => $plan['name_ar'] ?? $plan['name_en'],
'payer_name' => $user['name'] ?? '',
'instructions' => "قم بالتحويل عبر CliQ إلى الاسم المستعار: {$cliqAlias} بمبلغ {$plan['price_jod']} دينار أردني.",
], 'تم إنشاء طلب الدفع بنجاح');
} catch (\Exception $e) {
error_log("Payment Create Error: " . $e->getMessage());
json_error('حدث خطأ أثناء إنشاء طلب الدفع.', 500);
}

View File

@@ -0,0 +1,65 @@
<?php
/**
* List All Payment Requests (Super Admin)
* GET /api/v1/payments/list
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
if ($decoded['role'] !== 'super_admin') {
json_error('هذه الصفحة لمدير النظام فقط.', 403);
}
$db = Database::getInstance();
$status = $_GET['status'] ?? null;
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = 20;
$offset = ($page - 1) * $limit;
try {
$where = '';
$params = [];
if ($status && in_array($status, ['pending', 'uploaded', 'verified', 'approved', 'rejected'])) {
$where = 'WHERE pr.status = ?';
$params[] = $status;
}
$stmt = $db->prepare("
SELECT pr.*,
u.name AS user_name, u.phone AS user_phone,
sp.name_ar AS plan_name_ar, sp.name_en AS plan_name_en
FROM payment_requests pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id
$where
ORDER BY pr.created_at DESC
LIMIT $limit OFFSET $offset
");
$stmt->execute($params);
$payments = $stmt->fetchAll();
// Total count
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM payment_requests pr $where");
$countStmt->execute($params);
$total = $countStmt->fetch()['total'];
json_success([
'payments' => $payments,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => (int)$total,
'pages' => ceil($total / $limit)
]
], 'طلبات الدفع');
} catch (\Exception $e) {
error_log("Payment List Error: " . $e->getMessage());
json_error('حدث خطأ أثناء جلب طلبات الدفع.', 500);
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* My Payment Requests (Admin/Accountant)
* GET /api/v1/payments/my-requests
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$tenantId = $decoded['tenant_id'];
$db = Database::getInstance();
try {
$stmt = $db->prepare("
SELECT pr.id, pr.plan_id, pr.amount_jod, pr.reference_number, pr.cliq_alias,
pr.status, pr.ai_match_score, pr.created_at, pr.verified_at,
sp.name_ar AS plan_name
FROM payment_requests pr
LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id
WHERE pr.tenant_id = ?
ORDER BY pr.created_at DESC
");
$stmt->execute([$tenantId]);
$requests = $stmt->fetchAll();
json_success($requests, 'طلبات الدفع الخاصة بك');
} catch (\Exception $e) {
error_log("My Payment Requests Error: " . $e->getMessage());
json_error('حدث خطأ أثناء جلب طلبات الدفع.', 500);
}

View File

@@ -0,0 +1,107 @@
<?php
/**
* Review Payment Request (Super Admin only)
* POST /api/v1/payments/review
*
* Manually approve or reject a payment request.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\Security;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
if ($decoded['role'] !== 'super_admin') {
json_error('هذه العملية لمدير النظام فقط.', 403);
}
$data = Security::sanitize(input());
$paymentId = $data['payment_id'] ?? null;
$action = $data['action'] ?? null; // 'approve' or 'reject'
$notes = $data['notes'] ?? '';
if (!$paymentId || !in_array($action, ['approve', 'reject'])) {
json_error('معرف الطلب ونوع الإجراء (approve/reject) مطلوبان.', 422);
}
$db = Database::getInstance();
try {
$stmt = $db->prepare("SELECT * FROM payment_requests WHERE id = ? AND status IN ('pending','uploaded','verified')");
$stmt->execute([$paymentId]);
$payment = $stmt->fetch();
if (!$payment) {
json_error('طلب الدفع غير موجود أو تم معالجته.', 404);
}
$db->beginTransaction();
if ($action === 'approve') {
// Activate subscription
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
$stmt->execute([$payment['plan_id']]);
$plan = $stmt->fetch();
if ($plan) {
$startDate = date('Y-m-d H:i:s');
$endDate = date('Y-m-d H:i:s', strtotime('+30 days'));
$stmt = $db->prepare("
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at)
VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW())
ON DUPLICATE KEY UPDATE
plan_id = VALUES(plan_id),
max_companies = VALUES(max_companies),
max_invoices_per_month = VALUES(max_invoices_per_month),
max_users = VALUES(max_users),
price_jod = VALUES(price_jod),
status = 'active',
current_period_start = VALUES(current_period_start),
current_period_end = VALUES(current_period_end),
updated_at = NOW()
");
$stmt->execute([
't_id' => $payment['tenant_id'],
'p_id' => $plan['id'],
'max_c' => $plan['max_companies'],
'max_i' => $plan['max_invoices_month'],
'max_u' => $plan['max_users'],
'price' => $plan['price_jod'],
'start' => $startDate,
'end' => $endDate
]);
}
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', admin_notes = ?, verified_at = NOW(), updated_at = NOW() WHERE id = ?");
$stmt->execute([$notes, $paymentId]);
} else {
$stmt = $db->prepare("UPDATE payment_requests SET status = 'rejected', admin_notes = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$notes, $paymentId]);
}
// Audit log
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, ?, 'payment', ?, ?)");
$logStmt->execute([
$payment['tenant_id'],
$decoded['user_id'],
"payment.{$action}d",
$paymentId,
json_encode(['notes' => $notes, 'reviewer' => $decoded['user_id']])
]);
$db->commit();
json_success([
'payment_id' => $paymentId,
'new_status' => $action === 'approve' ? 'approved' : 'rejected'
], $action === 'approve' ? 'تم اعتماد الدفع وتفعيل الاشتراك' : 'تم رفض طلب الدفع');
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("Payment Review Error: " . $e->getMessage());
json_error('حدث خطأ أثناء مراجعة طلب الدفع.', 500);
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* Payment & Revenue Statistics (Super Admin)
* GET /api/v1/payments/stats
*/
declare(strict_types=1);
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
if ($decoded['role'] !== 'super_admin') {
json_error('هذه الصفحة لمدير النظام فقط.', 403);
}
$db = Database::getInstance();
try {
// Total revenue
$stmt = $db->query("SELECT COALESCE(SUM(amount_jod), 0) as total_revenue FROM payment_requests WHERE status = 'approved'");
$totalRevenue = (float)$stmt->fetch()['total_revenue'];
// This month revenue
$stmt = $db->query("SELECT COALESCE(SUM(amount_jod), 0) as month_revenue FROM payment_requests WHERE status = 'approved' AND MONTH(verified_at) = MONTH(NOW()) AND YEAR(verified_at) = YEAR(NOW())");
$monthRevenue = (float)$stmt->fetch()['month_revenue'];
// Payment counts by status
$stmt = $db->query("
SELECT status, COUNT(*) as count
FROM payment_requests
GROUP BY status
");
$statusCounts = [];
while ($row = $stmt->fetch()) {
$statusCounts[$row['status']] = (int)$row['count'];
}
// Active subscriptions count
$stmt = $db->query("SELECT COUNT(*) as active FROM subscriptions WHERE status = 'active' AND current_period_end > NOW()");
$activeSubscriptions = (int)$stmt->fetch()['active'];
// Revenue by plan
$stmt = $db->query("
SELECT sp.name_ar, sp.name_en, COUNT(pr.id) as count, COALESCE(SUM(pr.amount_jod), 0) as revenue
FROM payment_requests pr
LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id
WHERE pr.status = 'approved'
GROUP BY pr.plan_id
ORDER BY revenue DESC
");
$revenueByPlan = $stmt->fetchAll();
// Recent payments (last 10)
$stmt = $db->query("
SELECT pr.id, pr.amount_jod, pr.status, pr.reference_number, pr.ai_match_score, pr.created_at, pr.verified_at,
u.name AS payer_name, sp.name_ar AS plan_name
FROM payment_requests pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id
ORDER BY pr.created_at DESC
LIMIT 10
");
$recentPayments = $stmt->fetchAll();
json_success([
'total_revenue' => $totalRevenue,
'month_revenue' => $monthRevenue,
'active_subscriptions' => $activeSubscriptions,
'payment_counts' => $statusCounts,
'revenue_by_plan' => $revenueByPlan,
'recent_payments' => $recentPayments,
], 'إحصائيات الإيرادات والاشتراكات');
} catch (\Exception $e) {
error_log("Payment Stats Error: " . $e->getMessage());
json_error('حدث خطأ أثناء جلب الإحصائيات.', 500);
}

View File

@@ -0,0 +1,284 @@
<?php
/**
* Upload Payment Receipt (Admin/Accountant)
* POST /api/v1/payments/upload-receipt
*
* Receives a screenshot/photo of the CliQ payment receipt.
* AI analyzes the image and matches against the payment request.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\AI;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
if (!in_array($decoded['role'], ['admin', 'accountant'])) {
json_error('غير مصرح لك برفع وصل الدفع.', 403);
}
$paymentId = $_POST['payment_id'] ?? null;
if (!$paymentId) {
json_error('معرف طلب الدفع مطلوب.', 422);
}
if (!isset($_FILES['receipt']) || $_FILES['receipt']['error'] !== UPLOAD_ERR_OK) {
json_error('صورة وصل الدفع مطلوبة.', 422);
}
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
try {
// 1. Verify payment request exists and belongs to this tenant
$stmt = $db->prepare("SELECT * FROM payment_requests WHERE id = ? AND tenant_id = ? AND status IN ('pending','uploaded')");
$stmt->execute([$paymentId, $tenantId]);
$payment = $stmt->fetch();
if (!$payment) {
json_error('طلب الدفع غير موجود أو تم معالجته بالفعل.', 404);
}
// 2. Save receipt image
$uploadDir = STORAGE_PATH . '/receipts/' . $tenantId;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0750, true);
}
$ext = pathinfo($_FILES['receipt']['name'], PATHINFO_EXTENSION) ?: 'jpg';
$filename = $paymentId . '_' . time() . '.' . $ext;
$filepath = $uploadDir . '/' . $filename;
if (!move_uploaded_file($_FILES['receipt']['tmp_name'], $filepath)) {
json_error('فشل في حفظ صورة الوصل.', 500);
}
// 3. AI Analysis of receipt image
$aiResult = analyzeReceipt($filepath, $payment);
// 4. Calculate match score
$matchScore = calculateMatchScore($aiResult, $payment);
// 5. Update payment request
$newStatus = $matchScore >= 85.0 ? 'verified' : 'uploaded';
$stmt = $db->prepare("
UPDATE payment_requests
SET receipt_image_path = ?,
ai_extracted_data = ?,
ai_match_score = ?,
status = ?,
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([
$filepath,
json_encode($aiResult, JSON_UNESCAPED_UNICODE),
$matchScore,
$newStatus,
$paymentId
]);
// 6. If high confidence match, auto-activate subscription
if ($matchScore >= 85.0) {
activateSubscription($db, $payment, $decoded['user_id']);
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', verified_at = NOW() WHERE id = ?");
$stmt->execute([$paymentId]);
json_success([
'status' => 'approved',
'match_score' => $matchScore,
'message' => 'تم التحقق من الدفع وتفعيل الاشتراك تلقائياً!',
'extracted' => $aiResult,
], 'تم اعتماد الدفع وتفعيل الاشتراك');
}
json_success([
'status' => $newStatus,
'match_score' => $matchScore,
'message' => $matchScore >= 60
? 'تم رفع الوصل. جاري المراجعة من الإدارة.'
: 'لم نتمكن من التحقق التلقائي. تم إرسال الطلب للمراجعة اليدوية.',
'extracted' => $aiResult,
], 'تم رفع وصل الدفع');
} catch (\Exception $e) {
error_log("Payment Receipt Upload Error: " . $e->getMessage());
json_error('حدث خطأ أثناء معالجة وصل الدفع.', 500);
}
/**
* Analyze receipt image using Gemini AI
*/
function analyzeReceipt(string $imagePath, array $payment): array
{
$apiKey = env('GEMINI_API_KEY');
if (!$apiKey) {
return ['error' => 'AI API key not configured'];
}
$imageData = base64_encode(file_get_contents($imagePath));
$mimeType = mime_content_type($imagePath) ?: 'image/jpeg';
$prompt = <<<PROMPT
أنت محلل وصولات دفع ذكي. حلل صورة وصل الدفع/التحويل البنكي واستخرج المعلومات التالية بدقة.
أرجع JSON فقط بدون أي نص إضافي:
{
"amount": <المبلغ المحول كرقم>,
"currency": "<العملة: JOD/USD/etc>",
"sender_name": "<اسم المرسل/الدافع>",
"receiver_name": "<اسم المستقبل>",
"reference_number": "<رقم المرجع أو رقم العملية>",
"transfer_date": "<تاريخ التحويل YYYY-MM-DD>",
"bank_name": "<اسم البنك>",
"is_valid_receipt": <true/false>,
"confidence": <نسبة الثقة 0-100>
}
المبلغ المتوقع: {$payment['amount_jod']} دينار أردني
رقم المرجع المتوقع: {$payment['reference_number']}
الاسم المستعار CliQ: {$payment['cliq_alias']}
PROMPT;
$model = env('GEMINI_MODEL', 'gemini-1.5-flash');
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt],
[
'inline_data' => [
'mime_type' => $mimeType,
'data' => $imageData
]
]
]
]
],
'generationConfig' => [
'responseMimeType' => 'application/json',
'temperature' => 0.1
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Gemini Receipt Analysis Error: $response");
return ['error' => 'AI analysis failed', 'is_valid_receipt' => false];
}
$respData = json_decode($response, true);
$jsonText = $respData['candidates'][0]['content']['parts'][0]['text'] ?? '';
$parsed = json_decode($jsonText, true);
return $parsed ?: ['error' => 'Failed to parse AI response', 'is_valid_receipt' => false];
}
/**
* Calculate match score between AI extraction and expected payment
*/
function calculateMatchScore(array $aiResult, array $payment): float
{
if (!($aiResult['is_valid_receipt'] ?? false)) return 0.0;
$score = 0.0;
// Amount match (40 points)
$extractedAmount = (float)($aiResult['amount'] ?? 0);
$expectedAmount = (float)$payment['amount_jod'];
if (abs($extractedAmount - $expectedAmount) < 0.01) {
$score += 40;
} elseif (abs($extractedAmount - $expectedAmount) < 1.0) {
$score += 20;
}
// Reference number match (30 points)
$extractedRef = strtoupper(trim($aiResult['reference_number'] ?? ''));
$expectedRef = strtoupper(trim($payment['reference_number']));
if ($extractedRef === $expectedRef) {
$score += 30;
} elseif (str_contains($extractedRef, $expectedRef) || str_contains($expectedRef, $extractedRef)) {
$score += 15;
}
// Receiver name / CliQ alias match (15 points)
$receiverName = strtolower($aiResult['receiver_name'] ?? '');
$cliqAlias = strtolower($payment['cliq_alias']);
if (str_contains($receiverName, $cliqAlias) || str_contains($cliqAlias, $receiverName)) {
$score += 15;
}
// AI confidence boost (15 points)
$confidence = (float)($aiResult['confidence'] ?? 0);
$score += ($confidence / 100) * 15;
return min(round($score, 2), 100.0);
}
/**
* Auto-activate subscription upon verified payment
*/
function activateSubscription(\PDO $db, array $payment, string $userId): void
{
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
$stmt->execute([$payment['plan_id']]);
$plan = $stmt->fetch();
if (!$plan) return;
$startDate = date('Y-m-d H:i:s');
$endDate = date('Y-m-d H:i:s', strtotime('+30 days'));
$stmt = $db->prepare("
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at)
VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW())
ON DUPLICATE KEY UPDATE
plan_id = VALUES(plan_id),
max_companies = VALUES(max_companies),
max_invoices_per_month = VALUES(max_invoices_per_month),
max_users = VALUES(max_users),
price_jod = VALUES(price_jod),
status = 'active',
current_period_start = VALUES(current_period_start),
current_period_end = VALUES(current_period_end),
updated_at = NOW()
");
$stmt->execute([
't_id' => $payment['tenant_id'],
'p_id' => $plan['id'],
'max_c' => $plan['max_companies'],
'max_i' => $plan['max_invoices_month'],
'max_u' => $plan['max_users'],
'price' => $plan['price_jod'],
'start' => $startDate,
'end' => $endDate
]);
// Log activation
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)");
$logStmt->execute([
$payment['tenant_id'],
$userId,
$payment['id'],
json_encode(['plan_id' => $plan['id'], 'auto_verified' => true])
]);
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* Verify Bank Reference (Admin/Accountant)
* POST /api/v1/payments/verify-reference
*
* User submits the bank reference number from their bank app.
* We check if our Android bot has already received this reference.
* If yes, auto-activate. If no, mark as pending_verification.
*/
declare(strict_types=1);
use App\Core\Database;
use App\Core\Security;
use App\Core\Validator;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
if (!in_array($decoded['role'], ['admin', 'accountant'])) {
json_error('غير مصرح لك بتأكيد الدفع.', 403);
}
$data = Security::sanitize(input());
$paymentId = $data['payment_id'] ?? null;
$bankReference = trim($data['bank_reference'] ?? '');
$errors = Validator::validate($data, [
'payment_id' => 'required',
'bank_reference' => 'required'
]);
if ($errors || empty($bankReference)) {
json_error('رقم المرجع البنكي مطلوب.', 422);
}
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
try {
// 1. Verify payment request exists and belongs to this tenant
$stmt = $db->prepare("SELECT * FROM payment_requests WHERE id = ? AND tenant_id = ? AND status IN ('pending', 'uploaded')");
$stmt->execute([$paymentId, $tenantId]);
$payment = $stmt->fetch();
if (!$payment) {
json_error('طلب الدفع غير موجود أو تم معالجته بالفعل.', 404);
}
$db->beginTransaction();
// 2. Check if the bot has already recorded this transaction
$stmt = $db->prepare("SELECT * FROM bank_transactions WHERE bank_reference = ? AND is_claimed = 0 LIMIT 1");
$stmt->execute([$bankReference]);
$transaction = $stmt->fetch();
if ($transaction) {
// Match found! Check amount
$expectedAmount = (float)$payment['amount_jod'];
$receivedAmount = (float)$transaction['amount'];
if (abs($expectedAmount - $receivedAmount) < 0.01) {
// Amount matches exactly -> Auto Approve
activateSubscription($db, $payment, $decoded['user_id']);
$stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', bank_reference = ?, verified_at = NOW() WHERE id = ?");
$stmt->execute([$bankReference, $paymentId]);
$stmt = $db->prepare("UPDATE bank_transactions SET is_claimed = 1 WHERE id = ?");
$stmt->execute([$transaction['id']]);
$db->commit();
json_success([
'status' => 'approved',
'message' => 'تم التحقق من الدفع وتفعيل الاشتراك تلقائياً!'
], 'تم اعتماد الدفع وتفعيل الاشتراك');
} else {
// Amount mismatch -> Needs manual review
$stmt = $db->prepare("UPDATE payment_requests SET status = 'uploaded', bank_reference = ?, admin_notes = 'المبلغ غير متطابق' WHERE id = ?");
$stmt->execute([$bankReference, $paymentId]);
$db->commit();
json_success([
'status' => 'uploaded',
'message' => 'تم العثور على الحوالة ولكن المبلغ غير متطابق. تم تحويل الطلب للمراجعة الإدارية.'
], 'قيد المراجعة');
}
} else {
// No matching transaction found yet. Wait for the bot.
$stmt = $db->prepare("UPDATE payment_requests SET status = 'uploaded', bank_reference = ? WHERE id = ?");
$stmt->execute([$bankReference, $paymentId]);
$db->commit();
json_success([
'status' => 'uploaded',
'message' => 'تم حفظ رقم المرجع بنجاح. سيتم تفعيل الاشتراك تلقائياً فور وصول تأكيد الحوالة من البنك.'
], 'تم حفظ المرجع (بانتظار التأكيد)');
}
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("Verify Reference Error: " . $e->getMessage());
json_error('حدث خطأ أثناء معالجة رقم المرجع.', 500);
}
/**
* Auto-activate subscription upon verified payment
*/
function activateSubscription(\PDO $db, array $payment, string $userId): void
{
$stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1");
$stmt->execute([$payment['plan_id']]);
$plan = $stmt->fetch();
if (!$plan) return;
$startDate = date('Y-m-d H:i:s');
$endDate = date('Y-m-d H:i:s', strtotime('+30 days'));
$stmt = $db->prepare("
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at)
VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW())
ON DUPLICATE KEY UPDATE
plan_id = VALUES(plan_id),
max_companies = VALUES(max_companies),
max_invoices_per_month = VALUES(max_invoices_per_month),
max_users = VALUES(max_users),
price_jod = VALUES(price_jod),
status = 'active',
current_period_start = VALUES(current_period_start),
current_period_end = VALUES(current_period_end),
updated_at = NOW()
");
$stmt->execute([
't_id' => $payment['tenant_id'],
'p_id' => $plan['id'],
'max_c' => $plan['max_companies'],
'max_i' => $plan['max_invoices_month'],
'max_u' => $plan['max_users'],
'price' => $plan['price_jod'],
'start' => $startDate,
'end' => $endDate
]);
// Log activation
$logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)");
$logStmt->execute([
$payment['tenant_id'],
$userId,
$payment['id'],
json_encode(['plan_id' => $plan['id'], 'auto_verified' => true])
]);
}

View File

@@ -0,0 +1,101 @@
<?php
/**
* Voice Parse Intent Proxy Endpoint (Grok Variant - xAI)
* POST /v1/voice/parse-intent-grok
*
* Proxies transcribed text to Grok (xAI) to extract intent and parameters.
*/
declare(strict_types=1);
use App\Middleware\AuthMiddleware;
use App\Middleware\RateLimitMiddleware;
use App\Core\Security;
use App\Core\Validator;
// Rate limit: 20 per minute
RateLimitMiddleware::check(20, 60);
$decoded = AuthMiddleware::check();
$data = Security::sanitize(input());
$errors = Validator::validate($data, ['text' => 'required']);
if ($errors) {
json_error('النص مطلوب', 422);
}
$apiKey = env('XAI_API_KEY'); // Ensure this is set in .env
if (!$apiKey) {
json_error('xAI API Key غير متوفر', 500);
}
$text = $data['text'];
$systemPrompt = <<<PROMPT
أنت محلل أوامر لنظام مُصادَق للفوترة الأردني.
استخرج النية والمعاملات من النص وأرجع JSON فقط.
الأوامر المتاحة:
- list_invoices: { company?: string, from?: date, to?: date, status?: string }
- check_quota: {}
- open_scanner: { company?: string }
- search_invoice: { amount?: number, company?: string, number?: string }
- get_report: { type: "tax"|"monthly", period?: string }
- check_status: { invoice_id?: string, company?: string }
- export_pdf: { invoice_id?: string, company?: string }
- navigate: { screen: string }
أرجع JSON بهذا التنسيق:
{
"action": "...",
"params": {...},
"confirmation": "نص قصير تأكيد بالعامية الأردنية أو الفصحى المبسطة"
}
PROMPT;
$payload = [
'model' => 'grok-1', // Update to the correct Grok model when available
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $text]
],
'response_format' => ['type' => 'json_object'],
'temperature' => 0.2
];
$url = "https://api.x.ai/v1/chat/completions";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Grok Error: $response | $error");
json_error('فشل في تحليل الأمر بواسطة Grok', 500);
}
$respData = json_decode($response, true);
if (!isset($respData['choices'][0]['message']['content'])) {
json_error('رد غير متوقع من Grok AI', 500);
}
$jsonText = $respData['choices'][0]['message']['content'];
$parsed = json_decode($jsonText, true);
if (!$parsed) {
json_error('فشل في تحليل الرد كـ JSON', 500);
}
json_success($parsed, 'تم تحليل الأمر بواسطة Grok');