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}"
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
namespace App\Domain\Financial\Services; namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Enums\PaymentStatus; use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Events\PaymentReceived; use App\Domain\Financial\Events\PaymentReceived;
use App\Domain\Financial\Models\Invoice; use App\Domain\Financial\Models\Invoice;
...@@ -20,35 +19,34 @@ class PaymobService ...@@ -20,35 +19,34 @@ class PaymobService
private ?string $iframeId; private ?string $iframeId;
private ?string $hmacSecret; private ?string $hmacSecret;
public function __construct() public function __construct(?int $academyId = null)
{ {
$this->apiKey = SystemSetting::get('paymob_api_key'); $this->resolveCredentials($academyId);
$this->integrationId = SystemSetting::get('paymob_integration_id'); }
$this->iframeId = SystemSetting::get('paymob_iframe_id');
$this->hmacSecret = SystemSetting::get('paymob_hmac_secret'); public static function forAcademy(int $academyId): static
{
return new static($academyId);
} }
public function isConfigured(): bool public function isConfigured(): bool
{ {
return $this->apiKey && $this->integrationId && $this->hmacSecret; return !empty($this->apiKey) && !empty($this->integrationId) && !empty($this->hmacSecret);
} }
public function createPaymentIntention(Invoice $invoice, User $payer): array public function createPaymentIntention(Invoice $invoice, User $payer): array
{ {
if (!$this->isConfigured()) { if (!$this->isConfigured()) {
throw new \RuntimeException('Paymob is not configured'); throw new \RuntimeException('Paymob is not configured for this academy');
} }
// Step 1: Authentication
$authToken = $this->authenticate(); $authToken = $this->authenticate();
// Step 2: Create order
$orderId = $this->createOrder($authToken, $invoice); $orderId = $this->createOrder($authToken, $invoice);
// Step 3: Get payment key
$paymentKey = $this->getPaymentKey($authToken, $orderId, $invoice, $payer); $paymentKey = $this->getPaymentKey($authToken, $orderId, $invoice, $payer);
$iframeUrl = "https://accept.paymob.com/api/acceptance/iframes/{$this->iframeId}?payment_token={$paymentKey}"; $iframeUrl = $this->iframeId
? "https://accept.paymob.com/api/acceptance/iframes/{$this->iframeId}?payment_token={$paymentKey}"
: null;
return [ return [
'payment_key' => $paymentKey, 'payment_key' => $paymentKey,
...@@ -60,11 +58,14 @@ public function createPaymentIntention(Invoice $invoice, User $payer): array ...@@ -60,11 +58,14 @@ public function createPaymentIntention(Invoice $invoice, User $payer): array
public function verifyCallback(array $data): bool public function verifyCallback(array $data): bool
{ {
if (!$this->hmacSecret) { if (!$this->hmacSecret) {
Log::warning('[Paymob] Cannot verify callback — HMAC secret not configured');
return false; return false;
} }
$hmac = $data['hmac'] ?? ''; $hmac = $data['hmac'] ?? '';
unset($data['hmac']); if (empty($hmac)) {
return false;
}
$concatenated = implode('', [ $concatenated = implode('', [
$data['amount_cents'] ?? '', $data['amount_cents'] ?? '',
...@@ -94,44 +95,60 @@ public function verifyCallback(array $data): bool ...@@ -94,44 +95,60 @@ public function verifyCallback(array $data): bool
return hash_equals($calculatedHmac, $hmac); return hash_equals($calculatedHmac, $hmac);
} }
public function processSuccessfulPayment(array $callbackData): ?Payment public function processCallback(array $callbackData): ?Payment
{ {
$transactionId = $callbackData['id'] ?? null; $orderId = $callbackData['order'] ?? ($callbackData['obj']['order']['id'] ?? null);
$orderId = $callbackData['order'] ?? null; $success = filter_var($callbackData['success'] ?? ($callbackData['obj']['success'] ?? false), FILTER_VALIDATE_BOOLEAN);
$amountCents = (int) ($callbackData['amount_cents'] ?? 0); $amountCents = (int) ($callbackData['amount_cents'] ?? ($callbackData['obj']['amount_cents'] ?? 0));
$success = filter_var($callbackData['success'] ?? false, FILTER_VALIDATE_BOOLEAN); $transactionId = $callbackData['id'] ?? ($callbackData['obj']['id'] ?? null);
if (!$success || !$orderId || $amountCents <= 0) { if (!$orderId) {
Log::warning('[Paymob] Callback with failure or missing data', $callbackData); Log::warning('[Paymob] Callback missing order_id', ['data' => array_keys($callbackData)]);
return null; return null;
} }
// Find the pending payment by gateway order ID $payment = Payment::withoutGlobalScope('academy')
$payment = Payment::where('status', PaymentStatus::Pending) ->where('status', PaymentStatus::Pending)
->whereJsonContains('gateway_data->paymob_order_id', (string) $orderId) ->whereJsonContains('gateway_data->paymob_order_id', (string) $orderId)
->first(); ->first();
if (!$payment) { if (!$payment) {
Log::warning('[Paymob] No pending payment found for order', ['order_id' => $orderId]); Log::warning('[Paymob] No pending payment for order', ['order_id' => $orderId]);
return null; return null;
} }
// Prevent double-processing
if ($payment->status === PaymentStatus::Confirmed) { if ($payment->status === PaymentStatus::Confirmed) {
return $payment; return $payment;
} }
if (!$success) {
$payment->update([
'status' => PaymentStatus::Failed,
'failed_at' => now(),
'gateway_data' => array_merge($payment->gateway_data ?? [], [
'paymob_transaction_id' => $transactionId,
'failure_reason' => $callbackData['data_message'] ?? ($callbackData['obj']['data']['message'] ?? 'unknown'),
]),
]);
Log::info('[Paymob] Payment marked failed', ['payment_id' => $payment->id, 'order_id' => $orderId]);
return null;
}
if ($amountCents <= 0) {
Log::warning('[Paymob] Callback with zero amount', ['order_id' => $orderId]);
return null;
}
return DB::transaction(function () use ($payment, $transactionId, $callbackData) { return DB::transaction(function () use ($payment, $transactionId, $callbackData) {
$payment->update([ $payment->update([
'status' => PaymentStatus::Confirmed, 'status' => PaymentStatus::Confirmed,
'confirmed_at' => now(), 'confirmed_at' => now(),
'gateway_data' => array_merge($payment->gateway_data ?? [], [ 'gateway_data' => array_merge($payment->gateway_data ?? [], [
'paymob_transaction_id' => $transactionId, 'paymob_transaction_id' => $transactionId,
'callback_data' => $callbackData, 'confirmed_via' => 'webhook',
]), ]),
]); ]);
// Update invoice
$invoice = $payment->invoice; $invoice = $payment->invoice;
if ($invoice) { if ($invoice) {
$invoice->paid_amount += $payment->amount; $invoice->paid_amount += $payment->amount;
...@@ -147,20 +164,67 @@ public function processSuccessfulPayment(array $callbackData): ?Payment ...@@ -147,20 +164,67 @@ public function processSuccessfulPayment(array $callbackData): ?Payment
$invoice->save(); $invoice->save();
} }
PaymentReceived::dispatch($payment, $payment->creator ?? User::find($payment->created_by)); $actor = $payment->created_by ? User::find($payment->created_by) : null;
if ($actor) {
PaymentReceived::dispatch($payment, $actor);
}
return $payment; return $payment;
}); });
} }
private function resolveCredentials(?int $academyId): void
{
// Priority 1: Academy-specific system settings (if academy context exists)
$this->apiKey = $this->resolveSetting('paymob_api_key', 'PAYMOB_API_KEY', $academyId);
$this->integrationId = $this->resolveSetting('paymob_integration_id', 'PAYMOB_INTEGRATION_ID', $academyId);
$this->iframeId = $this->resolveSetting('paymob_iframe_id', 'PAYMOB_IFRAME_ID', $academyId);
$this->hmacSecret = $this->resolveSetting('paymob_hmac_secret', 'PAYMOB_HMAC_SECRET', $academyId);
}
private function resolveSetting(string $settingKey, string $envKey, ?int $academyId): ?string
{
// Try 1: Explicit academy ID lookup (bypasses global scope)
if ($academyId) {
$setting = SystemSetting::withoutGlobalScope('academy')
->where('academy_id', $academyId)
->where('key', $settingKey)
->first();
if ($setting && !empty($setting->value)) {
return $setting->castValue();
}
}
// Try 2: Current academy via SystemSetting::get() (works in authenticated requests)
$value = SystemSetting::get($settingKey);
if (!empty($value)) {
return $value;
}
// Try 3: config/services.php
$configValue = config("services.paymob.{$settingKey}");
if (!empty($configValue)) {
return $configValue;
}
// Try 4: Environment variable
$envValue = env($envKey);
if (!empty($envValue)) {
return $envValue;
}
return null;
}
private function authenticate(): string private function authenticate(): string
{ {
$response = Http::post('https://accept.paymob.com/api/auth/tokens', [ $response = Http::timeout(15)->post('https://accept.paymob.com/api/auth/tokens', [
'api_key' => $this->apiKey, 'api_key' => $this->apiKey,
]); ]);
if (!$response->successful()) { if (!$response->successful()) {
throw new \RuntimeException('Paymob authentication failed: ' . $response->body()); Log::error('[Paymob] Auth failed', ['status' => $response->status(), 'body' => $response->body()]);
throw new \RuntimeException('Paymob authentication failed');
} }
return $response->json('token'); return $response->json('token');
...@@ -168,17 +232,18 @@ private function authenticate(): string ...@@ -168,17 +232,18 @@ private function authenticate(): string
private function createOrder(string $authToken, Invoice $invoice): int private function createOrder(string $authToken, Invoice $invoice): int
{ {
$response = Http::post('https://accept.paymob.com/api/ecommerce/orders', [ $response = Http::timeout(15)->post('https://accept.paymob.com/api/ecommerce/orders', [
'auth_token' => $authToken, 'auth_token' => $authToken,
'delivery_needed' => false, 'delivery_needed' => false,
'amount_cents' => $invoice->balance_due, 'amount_cents' => $invoice->balance_due,
'currency' => 'EGP', 'currency' => 'EGP',
'merchant_order_id' => $invoice->uuid, 'merchant_order_id' => $invoice->uuid . '-' . time(),
'items' => [], 'items' => [],
]); ]);
if (!$response->successful()) { if (!$response->successful()) {
throw new \RuntimeException('Paymob order creation failed: ' . $response->body()); Log::error('[Paymob] Order creation failed', ['status' => $response->status(), 'body' => $response->body()]);
throw new \RuntimeException('Paymob order creation failed');
} }
return $response->json('id'); return $response->json('id');
...@@ -186,16 +251,19 @@ private function createOrder(string $authToken, Invoice $invoice): int ...@@ -186,16 +251,19 @@ private function createOrder(string $authToken, Invoice $invoice): int
private function getPaymentKey(string $authToken, int $orderId, Invoice $invoice, User $payer): string private function getPaymentKey(string $authToken, int $orderId, Invoice $invoice, User $payer): string
{ {
$response = Http::post('https://accept.paymob.com/api/acceptance/payment_keys', [ $phone = $payer->phone ?? '+201000000000';
$name = $payer->name_ar ?? $payer->name ?? 'Customer';
$response = Http::timeout(15)->post('https://accept.paymob.com/api/acceptance/payment_keys', [
'auth_token' => $authToken, 'auth_token' => $authToken,
'amount_cents' => $invoice->balance_due, 'amount_cents' => $invoice->balance_due,
'expiration' => 3600, 'expiration' => 3600,
'order_id' => $orderId, 'order_id' => $orderId,
'billing_data' => [ 'billing_data' => [
'first_name' => $payer->name_ar ?? $payer->name ?? 'Customer', 'first_name' => $name,
'last_name' => 'N/A', 'last_name' => 'N/A',
'email' => $payer->email ?? 'no-email@example.com', 'email' => $payer->email ?? 'customer@elcaptain.app',
'phone_number' => $payer->phone ?? '+201000000000', 'phone_number' => $phone,
'apartment' => 'N/A', 'apartment' => 'N/A',
'floor' => 'N/A', 'floor' => 'N/A',
'street' => 'N/A', 'street' => 'N/A',
...@@ -211,7 +279,8 @@ private function getPaymentKey(string $authToken, int $orderId, Invoice $invoice ...@@ -211,7 +279,8 @@ private function getPaymentKey(string $authToken, int $orderId, Invoice $invoice
]); ]);
if (!$response->successful()) { if (!$response->successful()) {
throw new \RuntimeException('Paymob payment key failed: ' . $response->body()); Log::error('[Paymob] Payment key failed', ['status' => $response->status(), 'body' => $response->body()]);
throw new \RuntimeException('Paymob payment key generation failed');
} }
return $response->json('token'); return $response->json('token');
......
...@@ -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