Commit ef15aa1b authored by Mahmoud Aglan's avatar Mahmoud Aglan

Mobile API Phase 4+5: Paymob payments, absence reporting, messaging

- Paymob gateway integration (auth→order→payment key→HMAC callback)
- Payment initiation endpoint with pending record + iframe URL
- Webhook callback with double-processing prevention
- Guardian absence reporting (excused status for future sessions)
- Contact messages (send + list with reply support)
- System settings migration seeding all mobile/payment config keys
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 40734561
<?php
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;
use App\Domain\Financial\Models\Payment;
use App\Domain\Shared\Models\SystemSetting;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PaymobService
{
private ?string $apiKey;
private ?string $integrationId;
private ?string $iframeId;
private ?string $hmacSecret;
public function __construct()
{
$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');
}
public function isConfigured(): bool
{
return $this->apiKey && $this->integrationId && $this->hmacSecret;
}
public function createPaymentIntention(Invoice $invoice, User $payer): array
{
if (!$this->isConfigured()) {
throw new \RuntimeException('Paymob is not configured');
}
// 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}";
return [
'payment_key' => $paymentKey,
'iframe_url' => $iframeUrl,
'order_id' => (string) $orderId,
];
}
public function verifyCallback(array $data): bool
{
if (!$this->hmacSecret) {
return false;
}
$hmac = $data['hmac'] ?? '';
unset($data['hmac']);
$concatenated = implode('', [
$data['amount_cents'] ?? '',
$data['created_at'] ?? '',
$data['currency'] ?? '',
$data['error_occured'] ?? '',
$data['has_parent_transaction'] ?? '',
$data['id'] ?? '',
$data['integration_id'] ?? '',
$data['is_3d_secure'] ?? '',
$data['is_auth'] ?? '',
$data['is_capture'] ?? '',
$data['is_refunded'] ?? '',
$data['is_standalone_payment'] ?? '',
$data['is_voided'] ?? '',
$data['order'] ?? '',
$data['owner'] ?? '',
$data['pending'] ?? '',
$data['source_data_pan'] ?? '',
$data['source_data_sub_type'] ?? '',
$data['source_data_type'] ?? '',
$data['success'] ?? '',
]);
$calculatedHmac = hash_hmac('sha512', $concatenated, $this->hmacSecret);
return hash_equals($calculatedHmac, $hmac);
}
public function processSuccessfulPayment(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);
if (!$success || !$orderId || $amountCents <= 0) {
Log::warning('[Paymob] Callback with failure or missing data', $callbackData);
return null;
}
// Find the pending payment by gateway order ID
$payment = Payment::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]);
return null;
}
// Prevent double-processing
if ($payment->status === PaymentStatus::Confirmed) {
return $payment;
}
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,
]),
]);
// Update invoice
$invoice = $payment->invoice;
if ($invoice) {
$invoice->paid_amount += $payment->amount;
$invoice->balance_due = $invoice->total_amount - $invoice->paid_amount;
if ($invoice->balance_due <= 0) {
$invoice->status = 'paid';
$invoice->balance_due = 0;
} elseif ($invoice->paid_amount > 0) {
$invoice->status = 'partially_paid';
}
$invoice->save();
}
PaymentReceived::dispatch($payment, $payment->creator ?? User::find($payment->created_by));
return $payment;
});
}
private function authenticate(): string
{
$response = Http::post('https://accept.paymob.com/api/auth/tokens', [
'api_key' => $this->apiKey,
]);
if (!$response->successful()) {
throw new \RuntimeException('Paymob authentication failed: ' . $response->body());
}
return $response->json('token');
}
private function createOrder(string $authToken, Invoice $invoice): int
{
$response = Http::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,
'items' => [],
]);
if (!$response->successful()) {
throw new \RuntimeException('Paymob order creation failed: ' . $response->body());
}
return $response->json('id');
}
private function getPaymentKey(string $authToken, int $orderId, Invoice $invoice, User $payer): string
{
$response = Http::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',
'last_name' => 'N/A',
'email' => $payer->email ?? 'no-email@example.com',
'phone_number' => $payer->phone ?? '+201000000000',
'apartment' => 'N/A',
'floor' => 'N/A',
'street' => 'N/A',
'building' => 'N/A',
'shipping_method' => 'N/A',
'postal_code' => 'N/A',
'city' => 'N/A',
'country' => 'EG',
'state' => 'N/A',
],
'currency' => 'EGP',
'integration_id' => (int) $this->integrationId,
]);
if (!$response->successful()) {
throw new \RuntimeException('Paymob payment key failed: ' . $response->body());
}
return $response->json('token');
}
}
<?php
namespace App\Domain\Shared\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContactMessage extends Model
{
use BelongsToAcademy;
protected $fillable = [
'academy_id',
'user_id',
'participant_uuid',
'subject',
'body',
'source',
'reply',
'replied_by',
'replied_at',
'is_read',
];
protected $casts = [
'is_read' => 'boolean',
'replied_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function repliedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'replied_by');
}
public function scopeUnread($query)
{
return $query->where('is_read', false);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\TrainingSession;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AbsenceController extends Controller
{
public function report(Request $request): JsonResponse
{
$request->validate([
'participant_uuid' => 'required|string',
'session_id' => 'required|integer',
'reason' => 'required|string|max:500',
]);
$user = $request->user();
$participant = Participant::where('uuid', $request->participant_uuid)->first();
if (!$participant) {
return response()->json([
'error' => 'participant_not_found',
'message' => 'المشترك غير موجود',
], 404);
}
// Verify user is authorized for this participant
if (!$this->isAuthorized($user, $participant)) {
return response()->json([
'error' => 'unauthorized',
'message' => 'غير مصرح لك بتقديم اعتذار لهذا المشترك',
], 403);
}
$session = TrainingSession::where('id', $request->session_id)
->where('session_date', '>=', now()->toDateString())
->whereIn('status', ['scheduled'])
->first();
if (!$session) {
return response()->json([
'error' => 'session_not_found',
'message' => 'الجلسة غير موجودة أو قد انتهت',
], 404);
}
// Check if attendance record already exists
$existing = AttendanceRecord::where('training_session_id', $session->id)
->where('subject_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('subject_id', $participant->id)
->first();
if ($existing && in_array($existing->status->value ?? $existing->status, ['present', 'late', 'absent'])) {
return response()->json([
'error' => 'already_marked',
'message' => 'تم تسجيل الحضور بالفعل لهذه الجلسة',
], 422);
}
if ($existing) {
$existing->update([
'status' => 'excused',
'notes' => $request->reason,
'metadata' => array_merge($existing->metadata ?? [], [
'excused_by' => 'guardian_app',
'excused_at' => now()->toIso8601String(),
'user_id' => $user->id,
]),
]);
} else {
AttendanceRecord::create([
'academy_id' => $participant->academy_id,
'training_session_id' => $session->id,
'subject_type' => 'App\\Domain\\Participant\\Models\\Participant',
'subject_id' => $participant->id,
'date' => $session->session_date,
'expected_start' => $session->start_time,
'expected_end' => $session->end_time,
'status' => 'excused',
'notes' => $request->reason,
'metadata' => [
'excused_by' => 'guardian_app',
'excused_at' => now()->toIso8601String(),
'user_id' => $user->id,
],
]);
}
return response()->json([
'success' => true,
'message' => 'تم تسجيل الاعتذار بنجاح',
]);
}
private function isAuthorized($user, Participant $participant): bool
{
$personId = $user->person_id;
if (!$personId) {
return false;
}
// Direct: user IS the participant
if ($participant->person_id === $personId) {
return true;
}
// Guardian: user's person is a guardian of this participant
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian) {
return $guardian->participants()->where('participants.id', $participant->id)->exists();
}
return false;
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Shared\Models\ContactMessage;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MessageController extends Controller
{
public function send(Request $request): JsonResponse
{
$request->validate([
'subject' => 'required|string|max:200',
'body' => 'required|string|max:2000',
'participant_uuid' => 'nullable|string',
]);
$user = $request->user();
ContactMessage::create([
'academy_id' => $user->academy_id,
'user_id' => $user->id,
'participant_uuid' => $request->participant_uuid,
'subject' => $request->subject,
'body' => $request->body,
'source' => 'mobile_app',
]);
return response()->json([
'success' => true,
'message' => 'تم إرسال الرسالة بنجاح',
]);
}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$messages = ContactMessage::where('user_id', $user->id)
->orderByDesc('created_at')
->paginate(15);
return response()->json([
'data' => $messages->map(fn ($m) => [
'id' => $m->id,
'subject' => $m->subject,
'body' => $m->body,
'reply' => $m->reply,
'replied_at' => $m->replied_at?->toIso8601String(),
'created_at' => $m->created_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $messages->currentPage(),
'last_page' => $messages->lastPage(),
'per_page' => $messages->perPage(),
'total' => $messages->total(),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Services\PaymobService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class PaymentController extends Controller
{
public function initiate(Request $request, PaymobService $paymobService): JsonResponse
{
$request->validate([
'invoice_uuid' => 'required|string',
]);
if (!$paymobService->isConfigured()) {
return response()->json([
'error' => 'payment_not_configured',
'message' => 'الدفع الإلكتروني غير مفعل حالياً',
], 503);
}
$user = $request->user();
$invoice = Invoice::where('uuid', $request->invoice_uuid)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->where('balance_due', '>', 0)
->first();
if (!$invoice) {
return response()->json([
'error' => 'invoice_not_found',
'message' => 'الفاتورة غير موجودة أو مدفوعة بالكامل',
], 404);
}
try {
$result = $paymobService->createPaymentIntention($invoice, $user);
// Create pending payment record
Payment::create([
'academy_id' => $invoice->academy_id,
'branch_id' => $invoice->branch_id ?? null,
'invoice_id' => $invoice->id,
'reference' => 'PMB-' . $result['order_id'],
'direction' => 'inbound',
'method' => PaymentMethod::Online,
'status' => PaymentStatus::Pending,
'payer_type' => $invoice->billable_type,
'payer_id' => $invoice->billable_id,
'amount' => $invoice->balance_due,
'currency' => 'EGP',
'payment_date' => now()->toDateString(),
'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_key' => $result['payment_key'],
'iframe_url' => $result['iframe_url'],
'order_id' => $result['order_id'],
'amount' => $invoice->balance_due,
]);
} catch (\Throwable $e) {
Log::error('[Paymob] Payment initiation failed', [
'invoice_uuid' => $invoice->uuid,
'error' => $e->getMessage(),
]);
return response()->json([
'error' => 'payment_failed',
'message' => 'فشل في بدء عملية الدفع، يرجى المحاولة لاحقاً',
], 500);
}
}
public function callback(Request $request, PaymobService $paymobService): JsonResponse
{
$data = $request->all();
Log::info('[Paymob] Callback received', ['data' => $data]);
// Extract transaction data from nested structure
$transactionData = $data['obj'] ?? $data;
if (!$paymobService->verifyCallback($transactionData)) {
Log::warning('[Paymob] HMAC verification failed', $data);
return response()->json(['status' => 'invalid_hmac'], 400);
}
$payment = $paymobService->processSuccessfulPayment($transactionData);
if ($payment) {
return response()->json(['status' => 'processed', 'payment_id' => $payment->id]);
}
return response()->json(['status' => 'ignored']);
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('contact_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('user_id')->constrained('users');
$table->string('participant_uuid', 36)->nullable();
$table->string('subject', 200);
$table->text('body');
$table->string('source', 30)->default('mobile_app');
$table->text('reply')->nullable();
$table->foreignId('replied_by')->nullable()->constrained('users');
$table->timestamp('replied_at')->nullable();
$table->boolean('is_read')->default(false);
$table->timestamps();
$table->index(['academy_id', 'is_read']);
$table->index(['user_id', 'created_at']);
});
DB::statement("ALTER TABLE contact_messages ADD CONSTRAINT contact_messages_source_check CHECK (source IN ('mobile_app', 'web', 'admin'))");
}
public function down(): void
{
Schema::dropIfExists('contact_messages');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$settings = [
['group' => 'mobile_app', 'key' => 'auth_otp_mode', 'value' => 'demo', 'type' => 'string', 'label_ar' => 'وضع رمز التحقق', 'description_ar' => 'demo = الرمز دائماً 123456، sms = إرسال رسالة فعلية', 'is_public' => false],
['group' => 'mobile_app', 'key' => 'app_primary_color', 'value' => '#1e40af', 'type' => 'string', 'label_ar' => 'اللون الأساسي للتطبيق', 'description_ar' => 'اللون الأساسي في واجهة التطبيق', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'app_accent_color', 'value' => '#f59e0b', 'type' => 'string', 'label_ar' => 'اللون الثانوي للتطبيق', 'description_ar' => 'اللون الثانوي في واجهة التطبيق', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'app_features_shop', 'value' => '1', 'type' => 'boolean', 'label_ar' => 'تفعيل المتجر', 'description_ar' => 'إظهار قسم المنتجات في التطبيق', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'app_features_events', 'value' => '1', 'type' => 'boolean', 'label_ar' => 'تفعيل الفعاليات', 'description_ar' => 'إظهار قسم الفعاليات في التطبيق', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'app_features_chat', 'value' => '0', 'type' => 'boolean', 'label_ar' => 'تفعيل المحادثات', 'description_ar' => 'إظهار قسم المحادثات في التطبيق', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'app_features_online_payment', 'value' => '0', 'type' => 'boolean', 'label_ar' => 'تفعيل الدفع الإلكتروني', 'description_ar' => 'السماح بالدفع عبر بطاقات الائتمان', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'app_min_version', 'value' => '1.0.0', 'type' => 'string', 'label_ar' => 'الحد الأدنى لإصدار التطبيق', 'description_ar' => 'يجب على المستخدم تحديث التطبيق إذا كان أقل من هذا الإصدار', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'app_maintenance_mode', 'value' => '0', 'type' => 'boolean', 'label_ar' => 'وضع الصيانة', 'description_ar' => 'إيقاف التطبيق مؤقتاً مع رسالة للمستخدمين', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'app_maintenance_message', 'value' => 'التطبيق تحت الصيانة، يرجى المحاولة لاحقاً', 'type' => 'string', 'label_ar' => 'رسالة الصيانة', 'description_ar' => 'الرسالة المعروضة عند تفعيل وضع الصيانة', 'is_public' => true],
['group' => 'mobile_app', 'key' => 'firebase_service_account_json', 'value' => '', 'type' => 'json', 'label_ar' => 'ملف Firebase Service Account', 'description_ar' => 'محتوى ملف JSON لحساب خدمة Firebase', 'is_public' => false],
['group' => 'payment_gateway', 'key' => 'paymob_api_key', 'value' => '', 'type' => 'string', 'label_ar' => 'مفتاح API لـ Paymob', 'description_ar' => 'مفتاح API من لوحة تحكم Paymob', 'is_public' => false],
['group' => 'payment_gateway', 'key' => 'paymob_integration_id', 'value' => '', 'type' => 'string', 'label_ar' => 'رقم التكامل Paymob', 'description_ar' => 'Integration ID من إعدادات البطاقات في Paymob', 'is_public' => false],
['group' => 'payment_gateway', 'key' => 'paymob_iframe_id', 'value' => '', 'type' => 'string', 'label_ar' => 'رقم iFrame Paymob', 'description_ar' => 'iFrame ID لصفحة الدفع', 'is_public' => false],
['group' => 'payment_gateway', 'key' => 'paymob_hmac_secret', 'value' => '', 'type' => 'string', 'label_ar' => 'مفتاح HMAC Paymob', 'description_ar' => 'مفتاح التحقق من Callbacks', 'is_public' => false],
];
// Get all academy IDs
$academyIds = DB::table('academies')->pluck('id');
foreach ($academyIds as $academyId) {
foreach ($settings as $setting) {
DB::table('system_settings')->insertOrIgnore(array_merge($setting, [
'academy_id' => $academyId,
'created_at' => now(),
'updated_at' => now(),
]));
}
}
}
public function down(): void
{
DB::table('system_settings')
->where('group', 'mobile_app')
->orWhere('group', 'payment_gateway')
->delete();
}
};
<?php
use App\Http\Controllers\Api\V1\AbsenceController;
use App\Http\Controllers\Api\V1\AcademyController;
use App\Http\Controllers\Api\V1\AppConfigController;
use App\Http\Controllers\Api\V1\AuthOtpController;
use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\MessageController;
use App\Http\Controllers\Api\V1\NotificationController;
use App\Http\Controllers\Api\V1\ParticipantController;
use App\Http\Controllers\Api\V1\PaymentController;
use App\Http\Controllers\Api\V1\ShopController;
use Illuminate\Support\Facades\Route;
......@@ -61,5 +64,18 @@
// Shop (essential products)
Route::get('products', [ShopController::class, 'products']);
// Payments (online via Paymob)
Route::post('payments/initiate', [PaymentController::class, 'initiate']);
// Absence reporting
Route::post('absences/report', [AbsenceController::class, 'report']);
// Messages (guardian ↔ academy)
Route::post('messages/send', [MessageController::class, 'send']);
Route::get('messages', [MessageController::class, 'index']);
});
// Payment gateway webhook (NO auth — Paymob sends server-to-server)
Route::post('payments/callback', [PaymentController::class, 'callback']);
});
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