Commit 0aea2391 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Harden Paymob integration with 4-tier credential fallback

Credential resolution order: academy system_settings → current_academy
setting → config/services.php → env variable. Callback endpoint now
resolves academy from the payment record itself (no auth context needed).
Failed payments are explicitly marked with failure reason. Added HTTP
timeouts to all Paymob API calls.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ef15aa1b
...@@ -68,4 +68,10 @@ ATTENDANCE_MIN_RATE_PERCENT=75 ...@@ -68,4 +68,10 @@ ATTENDANCE_MIN_RATE_PERCENT=75
# This value is NOT editable from the admin panel — only via this env variable # This value is NOT editable from the admin panel — only via this env variable
PLATFORM_SERVICE_FEE_PERCENT=3 PLATFORM_SERVICE_FEE_PERCENT=3
# Paymob Payment Gateway (optional — also configurable per-academy in system_settings)
PAYMOB_API_KEY=
PAYMOB_INTEGRATION_ID=
PAYMOB_IFRAME_ID=
PAYMOB_HMAC_SECRET=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
...@@ -14,21 +14,22 @@ ...@@ -14,21 +14,22 @@
class PaymentController extends Controller class PaymentController extends Controller
{ {
public function initiate(Request $request, PaymobService $paymobService): JsonResponse public function initiate(Request $request): JsonResponse
{ {
$request->validate([ $request->validate([
'invoice_uuid' => 'required|string', 'invoice_uuid' => 'required|string',
]); ]);
$user = $request->user();
$paymobService = new PaymobService($user->academy_id);
if (!$paymobService->isConfigured()) { if (!$paymobService->isConfigured()) {
return response()->json([ return response()->json([
'error' => 'payment_not_configured', 'error' => 'payment_not_configured',
'message' => 'الدفع الإلكتروني غير مفعل حالياً', 'message' => 'الدفع الإلكتروني غير مفعل حالياً، يرجى التواصل مع الإدارة',
], 503); ], 503);
} }
$user = $request->user();
$invoice = Invoice::where('uuid', $request->invoice_uuid) $invoice = Invoice::where('uuid', $request->invoice_uuid)
->whereIn('status', ['sent', 'partially_paid', 'overdue']) ->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->where('balance_due', '>', 0) ->where('balance_due', '>', 0)
...@@ -44,7 +45,6 @@ public function initiate(Request $request, PaymobService $paymobService): JsonRe ...@@ -44,7 +45,6 @@ public function initiate(Request $request, PaymobService $paymobService): JsonRe
try { try {
$result = $paymobService->createPaymentIntention($invoice, $user); $result = $paymobService->createPaymentIntention($invoice, $user);
// Create pending payment record
Payment::create([ Payment::create([
'academy_id' => $invoice->academy_id, 'academy_id' => $invoice->academy_id,
'branch_id' => $invoice->branch_id ?? null, 'branch_id' => $invoice->branch_id ?? null,
...@@ -61,16 +61,16 @@ public function initiate(Request $request, PaymobService $paymobService): JsonRe ...@@ -61,16 +61,16 @@ public function initiate(Request $request, PaymobService $paymobService): JsonRe
'gateway_data' => [ 'gateway_data' => [
'provider' => 'paymob', 'provider' => 'paymob',
'paymob_order_id' => $result['order_id'], 'paymob_order_id' => $result['order_id'],
'payment_key' => substr($result['payment_key'], 0, 20) . '...',
], ],
'created_by' => $user->id, 'created_by' => $user->id,
]); ]);
return response()->json([ return response()->json([
'payment_url' => $result['iframe_url'],
'payment_key' => $result['payment_key'], 'payment_key' => $result['payment_key'],
'iframe_url' => $result['iframe_url'],
'order_id' => $result['order_id'], 'order_id' => $result['order_id'],
'amount' => $invoice->balance_due, 'amount' => $invoice->balance_due,
'amount_display' => number_format($invoice->balance_due / 100, 2) . ' ج.م',
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('[Paymob] Payment initiation failed', [ Log::error('[Paymob] Payment initiation failed', [
...@@ -85,26 +85,46 @@ public function initiate(Request $request, PaymobService $paymobService): JsonRe ...@@ -85,26 +85,46 @@ public function initiate(Request $request, PaymobService $paymobService): JsonRe
} }
} }
public function callback(Request $request, PaymobService $paymobService): JsonResponse public function callback(Request $request): JsonResponse
{ {
$data = $request->all(); $data = $request->all();
Log::info('[Paymob] Callback received', ['data' => $data]); Log::info('[Paymob] Callback received', ['keys' => array_keys($data)]);
// Extract transaction data from nested structure // Paymob sends data in either flat format or nested under 'obj'
$transactionData = $data['obj'] ?? $data; $transactionData = $data['obj'] ?? $data;
$orderId = $transactionData['order'] ?? ($transactionData['order']['id'] ?? null);
if (!$orderId) {
Log::warning('[Paymob] Callback missing order reference');
return response()->json(['status' => 'missing_order'], 400);
}
// Find the payment to determine which academy's HMAC to use
$payment = Payment::withoutGlobalScope('academy')
->where('status', PaymentStatus::Pending)
->whereJsonContains('gateway_data->paymob_order_id', (string) $orderId)
->first();
if (!$payment) {
Log::warning('[Paymob] No pending payment for callback order', ['order_id' => $orderId]);
return response()->json(['status' => 'no_payment_found'], 404);
}
// Resolve credentials for the payment's academy
$paymobService = PaymobService::forAcademy($payment->academy_id);
if (!$paymobService->verifyCallback($transactionData)) { if (!$paymobService->verifyCallback($transactionData)) {
Log::warning('[Paymob] HMAC verification failed', $data); Log::warning('[Paymob] HMAC verification failed', ['order_id' => $orderId]);
return response()->json(['status' => 'invalid_hmac'], 400); return response()->json(['status' => 'invalid_hmac'], 400);
} }
$payment = $paymobService->processSuccessfulPayment($transactionData); $processed = $paymobService->processCallback($transactionData);
if ($payment) { if ($processed) {
return response()->json(['status' => 'processed', 'payment_id' => $payment->id]); return response()->json(['status' => 'processed', 'payment_id' => $processed->id]);
} }
return response()->json(['status' => 'ignored']); return response()->json(['status' => 'not_processed']);
} }
} }
...@@ -47,4 +47,11 @@ ...@@ -47,4 +47,11 @@
'key' => env('MESSAGING_HUB_KEY', ''), 'key' => env('MESSAGING_HUB_KEY', ''),
], ],
'paymob' => [
'paymob_api_key' => env('PAYMOB_API_KEY'),
'paymob_integration_id' => env('PAYMOB_INTEGRATION_ID'),
'paymob_iframe_id' => env('PAYMOB_IFRAME_ID'),
'paymob_hmac_secret' => env('PAYMOB_HMAC_SECRET'),
],
]; ];
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment