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
# This value is NOT editable from the admin panel — only via this env variable
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}"
......@@ -2,7 +2,6 @@
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Events\PaymentReceived;
use App\Domain\Financial\Models\Invoice;
......@@ -20,35 +19,34 @@ class PaymobService
private ?string $iframeId;
private ?string $hmacSecret;
public function __construct()
public function __construct(?int $academyId = null)
{
$this->apiKey = SystemSetting::get('paymob_api_key');
$this->integrationId = SystemSetting::get('paymob_integration_id');
$this->iframeId = SystemSetting::get('paymob_iframe_id');
$this->hmacSecret = SystemSetting::get('paymob_hmac_secret');
$this->resolveCredentials($academyId);
}
public static function forAcademy(int $academyId): static
{
return new static($academyId);
}
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
{
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();
// Step 2: Create order
$orderId = $this->createOrder($authToken, $invoice);
// Step 3: Get payment key
$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 [
'payment_key' => $paymentKey,
......@@ -60,11 +58,14 @@ public function createPaymentIntention(Invoice $invoice, User $payer): array
public function verifyCallback(array $data): bool
{
if (!$this->hmacSecret) {
Log::warning('[Paymob] Cannot verify callback — HMAC secret not configured');
return false;
}
$hmac = $data['hmac'] ?? '';
unset($data['hmac']);
if (empty($hmac)) {
return false;
}
$concatenated = implode('', [
$data['amount_cents'] ?? '',
......@@ -94,44 +95,60 @@ public function verifyCallback(array $data): bool
return hash_equals($calculatedHmac, $hmac);
}
public function processSuccessfulPayment(array $callbackData): ?Payment
public function processCallback(array $callbackData): ?Payment
{
$transactionId = $callbackData['id'] ?? null;
$orderId = $callbackData['order'] ?? null;
$amountCents = (int) ($callbackData['amount_cents'] ?? 0);
$success = filter_var($callbackData['success'] ?? false, FILTER_VALIDATE_BOOLEAN);
$orderId = $callbackData['order'] ?? ($callbackData['obj']['order']['id'] ?? null);
$success = filter_var($callbackData['success'] ?? ($callbackData['obj']['success'] ?? false), FILTER_VALIDATE_BOOLEAN);
$amountCents = (int) ($callbackData['amount_cents'] ?? ($callbackData['obj']['amount_cents'] ?? 0));
$transactionId = $callbackData['id'] ?? ($callbackData['obj']['id'] ?? null);
if (!$success || !$orderId || $amountCents <= 0) {
Log::warning('[Paymob] Callback with failure or missing data', $callbackData);
if (!$orderId) {
Log::warning('[Paymob] Callback missing order_id', ['data' => array_keys($callbackData)]);
return null;
}
// Find the pending payment by gateway order ID
$payment = Payment::where('status', PaymentStatus::Pending)
$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 found for order', ['order_id' => $orderId]);
Log::warning('[Paymob] No pending payment for order', ['order_id' => $orderId]);
return null;
}
// Prevent double-processing
if ($payment->status === PaymentStatus::Confirmed) {
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) {
$payment->update([
'status' => PaymentStatus::Confirmed,
'confirmed_at' => now(),
'gateway_data' => array_merge($payment->gateway_data ?? [], [
'paymob_transaction_id' => $transactionId,
'callback_data' => $callbackData,
'confirmed_via' => 'webhook',
]),
]);
// Update invoice
$invoice = $payment->invoice;
if ($invoice) {
$invoice->paid_amount += $payment->amount;
......@@ -147,20 +164,67 @@ public function processSuccessfulPayment(array $callbackData): ?Payment
$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;
});
}
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
{
$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,
]);
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');
......@@ -168,17 +232,18 @@ private function authenticate(): string
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,
'delivery_needed' => false,
'amount_cents' => $invoice->balance_due,
'currency' => 'EGP',
'merchant_order_id' => $invoice->uuid,
'merchant_order_id' => $invoice->uuid . '-' . time(),
'items' => [],
]);
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');
......@@ -186,16 +251,19 @@ private function createOrder(string $authToken, Invoice $invoice): int
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,
'amount_cents' => $invoice->balance_due,
'expiration' => 3600,
'order_id' => $orderId,
'billing_data' => [
'first_name' => $payer->name_ar ?? $payer->name ?? 'Customer',
'first_name' => $name,
'last_name' => 'N/A',
'email' => $payer->email ?? 'no-email@example.com',
'phone_number' => $payer->phone ?? '+201000000000',
'email' => $payer->email ?? 'customer@elcaptain.app',
'phone_number' => $phone,
'apartment' => 'N/A',
'floor' => 'N/A',
'street' => 'N/A',
......@@ -211,7 +279,8 @@ private function getPaymentKey(string $authToken, int $orderId, Invoice $invoice
]);
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');
......
......@@ -14,21 +14,22 @@
class PaymentController extends Controller
{
public function initiate(Request $request, PaymobService $paymobService): JsonResponse
public function initiate(Request $request): JsonResponse
{
$request->validate([
'invoice_uuid' => 'required|string',
]);
$user = $request->user();
$paymobService = new PaymobService($user->academy_id);
if (!$paymobService->isConfigured()) {
return response()->json([
'error' => 'payment_not_configured',
'message' => 'الدفع الإلكتروني غير مفعل حالياً',
'message' => 'الدفع الإلكتروني غير مفعل حالياً، يرجى التواصل مع الإدارة',
], 503);
}
$user = $request->user();
$invoice = Invoice::where('uuid', $request->invoice_uuid)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->where('balance_due', '>', 0)
......@@ -44,7 +45,6 @@ public function initiate(Request $request, PaymobService $paymobService): JsonRe
try {
$result = $paymobService->createPaymentIntention($invoice, $user);
// Create pending payment record
Payment::create([
'academy_id' => $invoice->academy_id,
'branch_id' => $invoice->branch_id ?? null,
......@@ -61,16 +61,16 @@ public function initiate(Request $request, PaymobService $paymobService): JsonRe
'gateway_data' => [
'provider' => 'paymob',
'paymob_order_id' => $result['order_id'],
'payment_key' => substr($result['payment_key'], 0, 20) . '...',
],
'created_by' => $user->id,
]);
return response()->json([
'payment_url' => $result['iframe_url'],
'payment_key' => $result['payment_key'],
'iframe_url' => $result['iframe_url'],
'order_id' => $result['order_id'],
'amount' => $invoice->balance_due,
'amount_display' => number_format($invoice->balance_due / 100, 2) . ' ج.م',
]);
} catch (\Throwable $e) {
Log::error('[Paymob] Payment initiation failed', [
......@@ -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();
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;
$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)) {
Log::warning('[Paymob] HMAC verification failed', $data);
Log::warning('[Paymob] HMAC verification failed', ['order_id' => $orderId]);
return response()->json(['status' => 'invalid_hmac'], 400);
}
$payment = $paymobService->processSuccessfulPayment($transactionData);
$processed = $paymobService->processCallback($transactionData);
if ($payment) {
return response()->json(['status' => 'processed', 'payment_id' => $payment->id]);
if ($processed) {
return response()->json(['status' => 'processed', 'payment_id' => $processed->id]);
}
return response()->json(['status' => 'ignored']);
return response()->json(['status' => 'not_processed']);
}
}
......@@ -47,4 +47,11 @@
'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