Commit 30e19414 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Mobile API Phase 6: Wallet, installments, documents, events, orders + rate limiting

- Wallet balance + transaction history per participant
- Installment/payment plan tracking with progress percent
- Document listing with expiry status summary
- Event detail + registration from mobile (duplicate check)
- Product order flow with Paymob fallback (graceful when unconfigured)
- API rate limiting: 60/min general, 10/min auth, 3/min OTP, 5/min payments
- Production-safe error responses (no stack traces when APP_DEBUG=false)
- 40 total API endpoints
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 0aea2391
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Document\Models\Document;
use App\Domain\Participant\Models\Participant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DocumentController extends Controller
{
public function index(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$documents = Document::where('documentable_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('documentable_id', $participant->id)
->orderByDesc('created_at')
->get();
return response()->json([
'data' => $documents->map(fn ($doc) => [
'id' => $doc->uuid,
'type' => $doc->document_type->value,
'type_label' => $doc->document_type->label(),
'status' => $doc->status->value,
'original_filename' => $doc->original_filename,
'file_url' => $doc->file_path ? asset('storage/' . $doc->file_path) : null,
'expires_at' => $doc->expires_at?->toDateString(),
'is_expired' => $doc->isExpired(),
'rejection_reason' => $doc->rejection_reason,
'notes' => $doc->notes,
'uploaded_at' => $doc->created_at?->toIso8601String(),
]),
'summary' => [
'total' => $documents->count(),
'pending' => $documents->filter(fn ($d) => $d->isPending())->count(),
'approved' => $documents->filter(fn ($d) => $d->isApproved())->count(),
'expired' => $documents->filter(fn ($d) => $d->isExpired())->count(),
],
]);
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
if ($participant->person_id === $personId) {
return $participant;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
return $participant;
}
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Event\Models\Event;
use App\Domain\Event\Models\EventRegistration;
use App\Domain\Event\Services\EventRegistrationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EventController extends Controller
{
public function show(string $uuid): JsonResponse
{
$event = Event::where('uuid', $uuid)
->published()
->with(['cover', 'gallery'])
->firstOrFail();
return response()->json([
'data' => [
'uuid' => $event->uuid,
'title' => $event->title,
'title_en' => $event->title_en,
'description' => $event->description,
'type' => $event->type->value,
'status' => $event->status->value,
'location_type' => $event->location_type->value,
'location_name' => $event->location_name,
'location_address' => $event->location_address,
'starts_at' => $event->starts_at?->toIso8601String(),
'ends_at' => $event->ends_at?->toIso8601String(),
'is_registration_open' => $event->isRegistrationOpen(),
'spots_remaining' => $event->spotsRemaining(),
'max_capacity' => $event->max_capacity,
'registrations_count' => $event->registrations_count,
'cover_url' => $event->cover?->url ?? null,
'gallery' => $event->gallery->map(fn ($m) => [
'url' => $m->url,
'caption' => $m->caption ?? null,
]),
'form_fields' => $event->form_fields ?? [],
'registration_opens_at' => $event->registration_opens_at?->toIso8601String(),
'registration_closes_at' => $event->registration_closes_at?->toIso8601String(),
],
]);
}
public function register(string $uuid, Request $request, EventRegistrationService $service): JsonResponse
{
$event = Event::where('uuid', $uuid)->published()->firstOrFail();
if (!$event->isRegistrationOpen()) {
return response()->json([
'error' => 'registration_closed',
'message' => 'التسجيل في هذه الفعالية مغلق حالياً',
], 422);
}
$user = $request->user();
$existing = EventRegistration::where('event_id', $event->id)
->where('person_id', $user->person_id)
->whereNotIn('status', ['cancelled'])
->first();
if ($existing) {
return response()->json([
'error' => 'already_registered',
'message' => 'أنت مسجل بالفعل في هذه الفعالية',
'registration_number' => $existing->registration_number,
], 422);
}
$request->validate([
'form_data' => 'nullable|array',
]);
try {
$registration = $service->register($event, $request->form_data ?? [], [
'person_id' => $user->person_id,
'registrant_name' => $user->name_ar ?? $user->name,
'registrant_phone' => $user->phone,
'registrant_email' => $user->email,
'ip_address' => $request->ip(),
'source' => 'mobile_app',
]);
return response()->json([
'success' => true,
'message' => 'تم التسجيل بنجاح',
'data' => [
'registration_number' => $registration->registration_number,
'status' => $registration->status->value,
],
]);
} catch (\Throwable $e) {
return response()->json([
'error' => 'registration_failed',
'message' => $e->getMessage() ?: 'فشل في التسجيل، يرجى المحاولة لاحقاً',
], 422);
}
}
public function myRegistrations(Request $request): JsonResponse
{
$user = $request->user();
if (!$user->person_id) {
return response()->json(['data' => []]);
}
$registrations = EventRegistration::where('person_id', $user->person_id)
->with(['event:id,uuid,title,title_en,starts_at,ends_at,status'])
->orderByDesc('created_at')
->paginate(15);
return response()->json([
'data' => $registrations->map(fn ($r) => [
'registration_number' => $r->registration_number,
'status' => $r->status->value,
'event' => $r->event ? [
'uuid' => $r->event->uuid,
'title' => $r->event->title,
'title_en' => $r->event->title_en,
'starts_at' => $r->event->starts_at?->toIso8601String(),
'ends_at' => $r->event->ends_at?->toIso8601String(),
] : null,
'registered_at' => $r->created_at?->toIso8601String(),
'confirmed_at' => $r->confirmed_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $registrations->currentPage(),
'last_page' => $registrations->lastPage(),
'per_page' => $registrations->perPage(),
'total' => $registrations->total(),
],
]);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Participant\Models\Participant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InstallmentController extends Controller
{
public function plans(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$invoiceIds = Invoice::where('billable_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('billable_id', $participant->id)
->whereNotIn('status', ['cancelled', 'draft'])
->pluck('id');
$plans = PaymentPlan::whereIn('invoice_id', $invoiceIds)
->with(['invoice:id,uuid,total_amount,paid_amount,balance_due,status', 'installments'])
->orderByDesc('created_at')
->get();
return response()->json([
'data' => $plans->map(fn ($plan) => [
'id' => $plan->uuid,
'invoice_uuid' => $plan->invoice?->uuid,
'status' => $plan->status,
'total_installments' => $plan->total_installments,
'paid_installments' => $plan->paid_installments,
'installment_amount' => $plan->installment_amount,
'installment_amount_display' => number_format($plan->installment_amount / 100, 2) . ' ج.م',
'frequency' => $plan->frequency,
'next_due_date' => $plan->next_due_date?->toDateString(),
'is_complete' => $plan->isComplete(),
'progress_percent' => $plan->total_installments > 0
? round($plan->paid_installments / $plan->total_installments * 100)
: 0,
'installments' => $plan->installments->map(fn ($inst) => [
'sequence' => $inst->sequence,
'amount' => $inst->amount,
'amount_display' => number_format($inst->amount / 100, 2) . ' ج.م',
'due_date' => $inst->due_date?->toDateString(),
'status' => $inst->status,
'is_overdue' => $inst->isOverdue(),
'paid_at' => $inst->paid_at?->toIso8601String(),
]),
]),
]);
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
if ($participant->person_id === $personId) {
return $participant;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
return $participant;
}
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
}
This diff is collapsed.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domain\Financial\Models\Wallet;
use App\Http\Controllers\Controller;
use App\Domain\Participant\Models\Participant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WalletController extends Controller
{
public function balance(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$wallet = Wallet::where('owner_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('owner_id', $participant->id)
->first();
if (!$wallet) {
return response()->json([
'data' => [
'balance' => 0,
'balance_display' => '0.00 ج.م',
'currency' => 'EGP',
'is_active' => false,
'has_wallet' => false,
],
]);
}
return response()->json([
'data' => [
'balance' => $wallet->balance,
'balance_display' => number_format($wallet->balance / 100, 2) . ' ج.م',
'currency' => $wallet->currency ?? 'EGP',
'is_active' => $wallet->is_active,
'has_wallet' => true,
],
]);
}
public function transactions(string $participantUuid, Request $request): JsonResponse
{
$participant = $this->findAuthorized($participantUuid, $request->user());
$wallet = Wallet::where('owner_type', 'App\\Domain\\Participant\\Models\\Participant')
->where('owner_id', $participant->id)
->first();
if (!$wallet) {
return response()->json([
'data' => [],
'meta' => ['current_page' => 1, 'last_page' => 1, 'per_page' => 20, 'total' => 0],
]);
}
$transactions = $wallet->walletTransactions()
->orderByDesc('created_at')
->paginate(20);
return response()->json([
'data' => $transactions->map(fn ($t) => [
'id' => $t->uuid,
'type' => $t->type,
'direction' => $t->direction,
'amount' => $t->amount,
'amount_display' => number_format($t->amount / 100, 2) . ' ج.م',
'balance_after' => $t->balance_after,
'balance_after_display' => number_format($t->balance_after / 100, 2) . ' ج.م',
'description' => $t->description,
'created_at' => $t->created_at?->toIso8601String(),
]),
'meta' => [
'current_page' => $transactions->currentPage(),
'last_page' => $transactions->lastPage(),
'per_page' => $transactions->perPage(),
'total' => $transactions->total(),
],
]);
}
private function findAuthorized(string $uuid, $user): Participant
{
$participant = Participant::where('uuid', $uuid)->firstOrFail();
$personId = $user->person_id;
if (!$personId) {
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
if ($participant->person_id === $personId) {
return $participant;
}
$guardian = \App\Domain\Identity\Models\Guardian::where('person_id', $personId)->first();
if ($guardian && $guardian->participants()->where('participants.id', $participant->id)->exists()) {
return $participant;
}
abort(403, 'غير مصرح لك بالوصول لهذا المشترك');
}
}
...@@ -3,9 +3,12 @@ ...@@ -3,9 +3,12 @@
namespace App\Providers; namespace App\Providers;
use App\Domain\Identity\Services\PermissionService; use App\Domain\Identity\Services\PermissionService;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
...@@ -36,5 +39,26 @@ public function boot(): void ...@@ -36,5 +39,26 @@ public function boot(): void
} }
return app(PermissionService::class)->can($user, $permission); return app(PermissionService::class)->can($user, $permission);
}); });
$this->configureRateLimiting();
}
private function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('api-auth', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
RateLimiter::for('api-otp', function (Request $request) {
return Limit::perMinute(3)->by($request->input('phone', $request->ip()));
});
RateLimiter::for('api-payments', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
} }
} }
...@@ -54,24 +54,29 @@ ...@@ -54,24 +54,29 @@
'user_id' => $request->user()?->id, 'user_id' => $request->user()?->id,
]); ]);
return response()->json([ $code = $statusCode >= 400 ? $statusCode : 500;
$body = [
'error' => true, 'error' => true,
'error_id' => $errorId, 'error_id' => $errorId,
'message' => $e->getMessage(), 'message' => $code >= 500 && !config('app.debug')
'exception' => get_class($e), ? 'حدث خطأ في النظام، يرجى المحاولة لاحقاً'
'file' => $e->getFile(), : $e->getMessage(),
'line' => $e->getLine(), 'timestamp' => now()->toIso8601String(),
'trace' => collect($e->getTrace())->take(20)->map(fn ($frame) => [ ];
if (config('app.debug')) {
$body['exception'] = get_class($e);
$body['file'] = $e->getFile();
$body['line'] = $e->getLine();
$body['trace'] = collect($e->getTrace())->take(20)->map(fn ($frame) => [
'file' => $frame['file'] ?? null, 'file' => $frame['file'] ?? null,
'line' => $frame['line'] ?? null, 'line' => $frame['line'] ?? null,
'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''), 'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''),
])->toArray(), ])->toArray();
'url' => $request->fullUrl(), }
'method' => $request->method(),
'input' => $request->except(['password', 'password_confirmation', '_token']), return response()->json($body, $code);
'user_id' => $request->user()?->id,
'timestamp' => now()->toIso8601String(),
], $statusCode >= 400 ? $statusCode : 500);
} }
if ($statusCode === 500 || (!$e instanceof HttpExceptionInterface && $statusCode >= 500)) { if ($statusCode === 500 || (!$e instanceof HttpExceptionInterface && $statusCode >= 500)) {
......
...@@ -5,20 +5,25 @@ ...@@ -5,20 +5,25 @@
use App\Http\Controllers\Api\V1\AppConfigController; use App\Http\Controllers\Api\V1\AppConfigController;
use App\Http\Controllers\Api\V1\AuthOtpController; use App\Http\Controllers\Api\V1\AuthOtpController;
use App\Http\Controllers\Api\V1\DeviceController; use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\DocumentController;
use App\Http\Controllers\Api\V1\EventController;
use App\Http\Controllers\Api\V1\InstallmentController;
use App\Http\Controllers\Api\V1\MessageController; use App\Http\Controllers\Api\V1\MessageController;
use App\Http\Controllers\Api\V1\NotificationController; use App\Http\Controllers\Api\V1\NotificationController;
use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\ParticipantController; use App\Http\Controllers\Api\V1\ParticipantController;
use App\Http\Controllers\Api\V1\PaymentController; use App\Http\Controllers\Api\V1\PaymentController;
use App\Http\Controllers\Api\V1\ShopController; use App\Http\Controllers\Api\V1\ShopController;
use App\Http\Controllers\Api\V1\WalletController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () { Route::prefix('v1')->middleware('throttle:api')->group(function () {
// Public (no auth required) // Public (no auth required)
Route::get('app/config', [AppConfigController::class, 'index']); Route::get('app/config', [AppConfigController::class, 'index']);
// Auth // Auth (stricter rate limit)
Route::prefix('auth')->group(function () { Route::prefix('auth')->middleware('throttle:api-auth')->group(function () {
Route::post('otp/request', [AuthOtpController::class, 'requestOtp']); Route::post('otp/request', [AuthOtpController::class, 'requestOtp'])->middleware('throttle:api-otp');
Route::post('otp/verify', [AuthOtpController::class, 'verify']); Route::post('otp/verify', [AuthOtpController::class, 'verify']);
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
Route::post('logout', [AuthOtpController::class, 'logout']); Route::post('logout', [AuthOtpController::class, 'logout']);
...@@ -32,6 +37,7 @@ ...@@ -32,6 +37,7 @@
Route::get('news/{uuid}', [AcademyController::class, 'newsShow']); Route::get('news/{uuid}', [AcademyController::class, 'newsShow']);
Route::get('programs', [AcademyController::class, 'programs']); Route::get('programs', [AcademyController::class, 'programs']);
Route::get('events', [AcademyController::class, 'events']); Route::get('events', [AcademyController::class, 'events']);
Route::get('events/{uuid}', [EventController::class, 'show']);
Route::get('gallery', [AcademyController::class, 'gallery']); Route::get('gallery', [AcademyController::class, 'gallery']);
}); });
...@@ -53,6 +59,10 @@ ...@@ -53,6 +59,10 @@
Route::get('attendance', [ParticipantController::class, 'attendance']); Route::get('attendance', [ParticipantController::class, 'attendance']);
Route::get('invoices', [ParticipantController::class, 'invoices']); Route::get('invoices', [ParticipantController::class, 'invoices']);
Route::get('enrollments', [ParticipantController::class, 'enrollments']); Route::get('enrollments', [ParticipantController::class, 'enrollments']);
Route::get('documents', [DocumentController::class, 'index']);
Route::get('wallet', [WalletController::class, 'balance']);
Route::get('wallet/transactions', [WalletController::class, 'transactions']);
Route::get('installments', [InstallmentController::class, 'plans']);
}); });
// Notifications // Notifications
...@@ -65,8 +75,16 @@ ...@@ -65,8 +75,16 @@
// Shop (essential products) // Shop (essential products)
Route::get('products', [ShopController::class, 'products']); Route::get('products', [ShopController::class, 'products']);
// Orders (product purchases from mobile)
Route::post('orders/create', [OrderController::class, 'create']);
Route::get('orders', [OrderController::class, 'index']);
// Payments (online via Paymob) // Payments (online via Paymob)
Route::post('payments/initiate', [PaymentController::class, 'initiate']); Route::post('payments/initiate', [PaymentController::class, 'initiate'])->middleware('throttle:api-payments');
// Events (authenticated actions)
Route::post('events/{uuid}/register', [EventController::class, 'register']);
Route::get('events/my-registrations', [EventController::class, 'myRegistrations']);
// Absence reporting // Absence reporting
Route::post('absences/report', [AbsenceController::class, 'report']); Route::post('absences/report', [AbsenceController::class, 'report']);
......
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