From d883d334ae7874fbe8099fc7033d16b1a2d22fec Mon Sep 17 00:00:00 2001 From: semkasanga Date: Tue, 15 Sep 2026 14:11:13 +0200 Subject: [PATCH] api added --- .gitignore | 2 + API_ENDPOINTS.md | 21 ++++++++ .../Api/V1/Finance/PaymentController.php | 50 +++++++++++++++++ .../Api/V1/Finance/RevenueController.php | 54 +++++++++++++++++++ .../Api/V1/Scolarite/EleveController.php | 30 ++++++++--- .../Api/V1/Finance/PaymentStoreRequest.php | 21 ++++++++ .../Api/V1/Finance/RevenueIndexRequest.php | 19 +++++++ app/Http/Resources/EleveResource.php | 12 ++++- phpunit.xml | 7 +-- routes/api.php | 8 +++ tests/Feature/ApiEndpointsTest.php | 27 ++++++++++ 11 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 API_ENDPOINTS.md create mode 100644 app/Http/Controllers/Api/V1/Finance/PaymentController.php create mode 100644 app/Http/Controllers/Api/V1/Finance/RevenueController.php create mode 100644 app/Http/Requests/Api/V1/Finance/PaymentStoreRequest.php create mode 100644 app/Http/Requests/Api/V1/Finance/RevenueIndexRequest.php create mode 100644 tests/Feature/ApiEndpointsTest.php diff --git a/.gitignore b/.gitignore index 298b79f3..893f0906 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ .env .env.backup .phpunit.result.cache +.phpunit.cache/ +DOCUMENTATION_API_V1_PRESENTATION.md Homestead.json Homestead.yaml auth.json diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md new file mode 100644 index 00000000..d3b4a487 --- /dev/null +++ b/API_ENDPOINTS.md @@ -0,0 +1,21 @@ +# API endpoints ajoutés + +Tous les endpoints utilisent le préfixe `/api/v1` et Sanctum. + +## Élèves inscrits + +`GET /students?page=1&per_page=20` + +Retourne les élèves avec une inscription approuvée pour l'année courante. La réponse contient `data` et `pagination`. `limit` est accepté comme alias de `per_page`. + +## Recettes + +`GET /revenues?period=day|month|year` + +Les recettes sont les perceptions dont `paid_at` est renseigné. Les totaux sont séparés dans `data.USD` et `data.CDF`. Une période explicite est possible avec `startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`. Les éléments détaillés sont dans `revenues`. + +## Paiement + +`POST /payments` + +Le paiement s'appuie sur une perception existante. Utiliser `{ "perception_id": "01..." }`, ou `{ "student_id": "01...", "amount": 100, "currency": "USD" }` pour sélectionner la perception impayée correspondante. Une réussite retourne `201` avec `data.transactionId` et `data.status=success`; une perception déjà payée retourne `409`. diff --git a/app/Http/Controllers/Api/V1/Finance/PaymentController.php b/app/Http/Controllers/Api/V1/Finance/PaymentController.php new file mode 100644 index 00000000..0ad6839f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Finance/PaymentController.php @@ -0,0 +1,50 @@ +filled('perception_id') + ? Perception::query()->lockForUpdate()->findOrFail($request->string('perception_id')->toString()) + : Perception::query() + ->whereHas('inscription', fn ($query) => $query->where('eleve_id', $request->string('student_id')->toString())) + ->where('devise', $request->string('currency')->toString()) + ->where('frais_montant', (float) $request->input('amount')) + ->whereNull('paid_at') + ->when(Annee::encours(), fn ($query, $annee) => $query->where('annee_id', $annee->id)) + ->lockForUpdate()->firstOrFail(); + + if ($perception->paid_at) { + abort(409, 'Cette perception a déjà été payée.'); + } + + $perception->forceFill([ + 'montant' => $request->filled('amount') ? (float) $request->input('amount') : $perception->montant, + 'paid_by' => $request->input('paid_by', auth()->id()), + 'paid_at' => now(), + ])->save(); + + return $perception->fresh(); + }); + + return response()->json([ + 'data' => [ + 'transactionId' => $perception->id, + 'status' => 'success', + 'currency' => $perception->devise instanceof Devise ? $perception->devise->value : $perception->devise, + 'amount' => (float) $perception->montant, + ], + ], 201); + } +} diff --git a/app/Http/Controllers/Api/V1/Finance/RevenueController.php b/app/Http/Controllers/Api/V1/Finance/RevenueController.php new file mode 100644 index 00000000..44a5b5bf --- /dev/null +++ b/app/Http/Controllers/Api/V1/Finance/RevenueController.php @@ -0,0 +1,54 @@ +whereNotNull('paid_at'); + $annee = Annee::encours(); + $query->when($annee, fn ($query) => $query->where('annee_id', $annee->id)); + + if ($request->filled('startDate')) { + $query->whereBetween('paid_at', [ + Carbon::parse($request->string('startDate'))->startOfDay(), + Carbon::parse($request->string('endDate'))->endOfDay(), + ]); + } elseif ($request->filled('period')) { + $query->whereBetween('paid_at', match ($request->string('period')->toString()) { + 'day' => [now()->startOfDay(), now()->endOfDay()], + 'month' => [now()->startOfMonth(), now()->endOfMonth()], + 'year' => [now()->startOfYear(), now()->endOfYear()], + }); + } + + $totals = (clone $query)->selectRaw('devise, COALESCE(SUM(montant), 0) as total') + ->groupBy('devise')->pluck('total', 'devise'); + + $revenues = $query->latest('paid_at') + ->paginate((int) $request->get('per_page', 20)); + + return response()->json([ + 'data' => [ + Devise::USD->value => (float) ($totals[Devise::USD->value] ?? 0), + Devise::CDF->value => (float) ($totals[Devise::CDF->value] ?? 0), + ], + 'revenues' => $revenues->items(), + 'pagination' => [ + 'current_page' => $revenues->currentPage(), + 'per_page' => $revenues->perPage(), + 'total' => $revenues->total(), + 'last_page' => $revenues->lastPage(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Scolarite/EleveController.php b/app/Http/Controllers/Api/V1/Scolarite/EleveController.php index cc992039..ac97e738 100644 --- a/app/Http/Controllers/Api/V1/Scolarite/EleveController.php +++ b/app/Http/Controllers/Api/V1/Scolarite/EleveController.php @@ -4,6 +4,9 @@ use App\Http\Controllers\Controller; use App\Models\Eleve; +use App\Models\Annee; +use App\Enums\InscriptionStatus; +use App\Http\Resources\EleveResource; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -11,15 +14,30 @@ class EleveController extends Controller { public function index(Request $request): JsonResponse { - $eleves = Eleve::query() + $perPage = (int) $request->get('per_page', $request->get('limit', 20)); + abort_if($perPage < 1 || $perPage > 100, 422, 'Le nombre d\'éléments par page doit être compris entre 1 et 100.'); + + $annee = Annee::encours(); - ->with([ - 'inscriptions.classe', - ]) - ->paginate($request->get('per_page', 20)); + $eleves = Eleve::query() + ->with(['inscriptions' => fn ($query) => $query + ->with('classe') + ->when($annee, fn ($query) => $query->where('annee_id', $annee->id)) + ->where('status', InscriptionStatus::approved)]) + ->whereHas('inscriptions', fn ($query) => $query + ->when($annee, fn ($query) => $query->where('annee_id', $annee->id)) + ->where('status', InscriptionStatus::approved)) + ->latest() + ->paginate($perPage); return response()->json([ - 'data' => $eleves, + 'data' => EleveResource::collection($eleves->getCollection()), + 'pagination' => [ + 'current_page' => $eleves->currentPage(), + 'per_page' => $eleves->perPage(), + 'total' => $eleves->total(), + 'last_page' => $eleves->lastPage(), + ], ]); } } diff --git a/app/Http/Requests/Api/V1/Finance/PaymentStoreRequest.php b/app/Http/Requests/Api/V1/Finance/PaymentStoreRequest.php new file mode 100644 index 00000000..5c96ba91 --- /dev/null +++ b/app/Http/Requests/Api/V1/Finance/PaymentStoreRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'string', 'exists:perceptions,id', 'required_without:student_id'], + 'student_id' => ['nullable', 'string', 'exists:eleves,id', 'required_without:perception_id'], + 'amount' => ['required_with:student_id', 'numeric', 'gt:0'], + 'currency' => ['required_with:student_id', Rule::enum(Devise::class)], + 'paid_by' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Api/V1/Finance/RevenueIndexRequest.php b/app/Http/Requests/Api/V1/Finance/RevenueIndexRequest.php new file mode 100644 index 00000000..e5ee9ec4 --- /dev/null +++ b/app/Http/Requests/Api/V1/Finance/RevenueIndexRequest.php @@ -0,0 +1,19 @@ + ['nullable', 'in:day,month,year'], + 'startDate' => ['nullable', 'date_format:Y-m-d', 'required_with:endDate'], + 'endDate' => ['nullable', 'date_format:Y-m-d', 'required_with:startDate', 'after_or_equal:startDate'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Resources/EleveResource.php b/app/Http/Resources/EleveResource.php index 3d63b755..01945ae6 100644 --- a/app/Http/Resources/EleveResource.php +++ b/app/Http/Resources/EleveResource.php @@ -8,6 +8,16 @@ class EleveResource extends JsonResource { public function toArray($request) { - return parent::toArray($request); + return [ + 'id' => $this->id, + 'matricule' => $this->matricule, + 'nom' => $this->nom, + 'sexe' => $this->sexe?->value ?? $this->sexe, + 'date_naissance' => $this->date_naissance, + 'adresse' => $this->adresse, + 'email' => $this->email, + 'telephone' => $this->telephone, + 'inscriptions' => InscriptionResource::collection($this->whenLoaded('inscriptions')), + ]; } } diff --git a/phpunit.xml b/phpunit.xml index 28ae990a..2d2c26d9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,9 +1,6 @@ - - ./tests/Unit - ./tests/Feature @@ -13,8 +10,8 @@ - - + + diff --git a/routes/api.php b/routes/api.php index 862acf6b..c23bbd56 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,8 @@ use App\Http\Controllers\Api\V1\Finance\PerceptionClasseController; use App\Http\Controllers\Api\V1\Finance\PerceptionController; use App\Http\Controllers\Api\V1\Finance\StudentController; +use App\Http\Controllers\Api\V1\Finance\RevenueController; +use App\Http\Controllers\Api\V1\Finance\PaymentController; use App\Http\Controllers\Api\V1\Scolarite\EleveController; use Illuminate\Support\Facades\Route; @@ -25,6 +27,12 @@ Route::get('/scolarite/eleves', [EleveController::class, 'index']) ->name('api.v1.scolarite.eleves.index'); + Route::get('/students', [EleveController::class, 'index']) + ->name('api.v1.students.index'); + Route::get('/revenues', [RevenueController::class, 'index']) + ->name('api.v1.revenues.index'); + Route::post('/payments', [PaymentController::class, 'store']) + ->name('api.v1.payments.store'); Route::post( '/finance/perceptions/classe', diff --git a/tests/Feature/ApiEndpointsTest.php b/tests/Feature/ApiEndpointsTest.php new file mode 100644 index 00000000..9639e179 --- /dev/null +++ b/tests/Feature/ApiEndpointsTest.php @@ -0,0 +1,27 @@ +getJson('/api/v1/students')->assertUnauthorized(); + $this->getJson('/api/v1/revenues')->assertUnauthorized(); + $this->postJson('/api/v1/payments')->assertUnauthorized(); +}); + +it('rejects invalid students pagination', function () { + $this->withoutMiddleware() + ->getJson('/api/v1/students?per_page=101') + ->assertStatus(422); +}); + +it('rejects invalid revenue filters', function () { + $this->withoutMiddleware() + ->getJson('/api/v1/revenues?period=week') + ->assertStatus(422) + ->assertJsonValidationErrors(['period']); +}); + +it('rejects incomplete payment data', function () { + $this->withoutMiddleware() + ->postJson('/api/v1/payments', ['student_id' => 'not-a-student']) + ->assertStatus(422) + ->assertJsonValidationErrors(['student_id', 'amount', 'currency']); +});