feat: accumulated work — PI-SPI, KYC, RLS, mutuelle parts, comptabilité PDF + startup fixes

## PI-SPI BCEAO (P0.3 — deadline 30/06/2026)
- package payment/pispi/ complet : PispiAuth (OAuth2), PispiClient (HTTP brut),
  PispiIso20022Mapper (pacs.008/002), PispiSignatureVerifier (HMAC-SHA256),
  PispiWebhookResource (/api/pispi/webhook), DTOs ISO 20022
- PaymentOrchestrator + PaymentProviderRegistry pour l'orchestration multi-provider
- Mode mock automatique si credentials absents (dev)

## KYC AML
- entity/KycDossier, KycResource, KycAmlService + tests
- Migration V38 (create_kyc_dossier_table)

## RLS (PostgreSQL Row-Level Security) — isolation multi-tenant
- RlsConnectionInitializer, RlsContextInterceptor, @RlsEnabled annotation
- Migration V39 (PostgreSQL RLS Tenant Isolation) + V42 (app DB roles)
- Tests unitaires RlsConnectionInitializerTest, RlsContextInterceptorTest
- Tests d'intégration RlsCrossTenantIsolationTest (@QuarkusTest + IntegrationTestProfile)

## Mutuelle — Parts sociales
- entity/mutuelle/parts/ComptePartsSociales, TransactionPartsSociales
- Service, resource, mapper, repository + tests
- InteretsEpargneService + ReleveComptePdfService

## Comptabilité PDF
- ComptabilitePdfService (OpenPDF), ComptabilitePdfResource
- Tests ComptabilitePdfServiceTest, ComptabilitePdfResourceTest

## Migrations Flyway (SYSCOHADA + Keycloak Orgs)
- V36 SYSCOHADA Plan Comptable Complet : seeds comptes standards UEMOA,
  trigger init_plan_comptable_organisation, alignement schéma V1 → entités
- V37 keycloak_org_id sur organisations (P0.2 migration KC 26)
- V40 provider_defaut sur FormuleAbonnement
- V41 fcm_token sur utilisateurs (FCM notifications push)

## Fixes startup (SmallRye Config 3.20 + schéma)
- 8× @ConfigProperty(defaultValue = "") → Optional<String>
  (firebase, pispi.*, mtnmomo, orange) — empty default rejetés par SmallRye 3.20
- application.properties : mappings secrets env var sous %prod. uniquement
- V36 : drop colonne obsolète 'numero' de V1 quand Hibernate a créé 'numero_compte'
- V36 : remplacement UNIQUE global sur journaux_comptables.code par composite
  (organisation_id, code) pour autoriser plusieurs orgs avec code 'ACH'/'VTE'/etc
- V39 : escape placeholder ${VAR} → <VAR> dans lignes commentées
  (Flyway parser évalue les placeholders même dans les commentaires)
- V41 : table 'membres' → 'utilisateurs' (nom correct selon entité Membre)
- JournalComptable entity : @UniqueConstraint composite au lieu de unique=true
- MembreResource : example @Schema JSON valide (['...'] → [])
- IntegrationTestProfile : auto-détection Docker via `docker info`, fallback
  vers PostgreSQL local sans DevServices

## Dev config
- application-dev.properties : quarkus.devservices.enabled=false +
  quarkus.kafka.devservices.enabled=false (pas besoin de Docker pour dev)
- quarkus.flyway.placeholder-replacement=false
- Secrets dev (wave.*, firebase, pispi) en mode mock automatique

## Phase 8 tests (complète)
- 170 fichiers modifiés/ajoutés, 23425+ insertions
- Tests RBAC (@QuarkusTest) pour MembreResource lifecycle
- Tests OrganisationContextFilter multi-org
- Tests SouscriptionQuotaOptionC, KycAmlService, EmailTemplate, etc.

Résultat : Backend démarre en 64s sur port 8085 avec 36 features installées.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dahoud
2026-04-21 12:40:55 +00:00
parent 9a53ce4077
commit 31330d95e9
170 changed files with 23425 additions and 873 deletions

View File

@@ -1,135 +0,0 @@
# Rapport d'Audit - Migrations Flyway vs Entités JPA
Date: 2026-03-16 01:18:05
## Résumé
- **Entités JPA**: 71
- **Tables dans migrations**: 76
---
## 1. Entités JPA et leurs tables
| Entité | Table attendue | Existe? | Migration(s) |
|--------|----------------|---------|--------------|
| Adresse | `adresses` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| CampagneAgricole | `campagnes_agricoles` | ✅ | V2__Entity_Schema_Alignment.sql |
| AlertConfiguration | `alert_configuration` | ✅ | V7__Monitoring_System.sql |
| AlerteLcbFt | `alertes_lcb_ft` | ✅ | V9__Create_Alertes_LCB_FT.sql |
| ApproverAction | `approver_actions` | ✅ | V6__Create_Finance_Workflow_Tables.sql |
| AuditLog | `audit_logs` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| AyantDroit | `ayants_droit` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| **BaseEntity** | `base_entity` | **❌ MANQUANT** | - |
| Budget | `budgets` | ✅ | V6__Create_Finance_Workflow_Tables.sql |
| BudgetLine | `budget_lines` | ✅ | V6__Create_Finance_Workflow_Tables.sql |
| CampagneCollecte | `campagnes_collecte` | ✅ | V2__Entity_Schema_Alignment.sql |
| ContributionCollecte | `contributions_collecte` | ✅ | V2__Entity_Schema_Alignment.sql |
| **CompteComptable** | `compte_comptable` | **❌ MANQUANT** | - |
| CompteWave | `comptes_wave` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| **Configuration** | `configuration` | **❌ MANQUANT** | - |
| **ConfigurationWave** | `configuration_wave` | **❌ MANQUANT** | - |
| Cotisation | `cotisations` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| DonReligieux | `dons_religieux` | ✅ | V2__Entity_Schema_Alignment.sql |
| **DemandeAdhesion** | `demande_adhesion` | **❌ MANQUANT** | - |
| DemandeAide | `demandes_aide` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| **Document** | `document` | **❌ MANQUANT** | - |
| **EcritureComptable** | `ecriture_comptable` | **❌ MANQUANT** | - |
| Evenement | `evenements` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| **Favori** | `favori` | **❌ MANQUANT** | - |
| **FormuleAbonnement** | `formule_abonnement` | **❌ MANQUANT** | - |
| EchelonOrganigramme | `echelons_organigramme` | ✅ | V2__Entity_Schema_Alignment.sql |
| InscriptionEvenement | `inscriptions_evenement` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| **IntentionPaiement** | `intention_paiement` | **❌ MANQUANT** | - |
| **JournalComptable** | `journal_comptable` | **❌ MANQUANT** | - |
| **LigneEcriture** | `ligne_ecriture` | **❌ MANQUANT** | - |
| **AuditEntityListener** | `audit_entity_listener` | **❌ MANQUANT** | - |
| **Membre** | `utilisateurs` | **❌ MANQUANT** | - |
| **MembreOrganisation** | `membre_organisation` | **❌ MANQUANT** | - |
| **MembreRole** | `membre_role` | **❌ MANQUANT** | - |
| MembreSuivi | `membre_suivi` | ✅ | V5__Create_Membre_Suivi.sql |
| **ModuleDisponible** | `module_disponible` | **❌ MANQUANT** | - |
| ModuleOrganisationActif | `modules_organisation_actifs` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| DemandeCredit | `demandes_credit` | ✅ | V2__Entity_Schema_Alignment.sql |
| EcheanceCredit | `echeances_credit` | ✅ | V2__Entity_Schema_Alignment.sql |
| GarantieDemande | `garanties_demande` | ✅ | V2__Entity_Schema_Alignment.sql |
| CompteEpargne | `comptes_epargne` | ✅ | V2__Entity_Schema_Alignment.sql |
| TransactionEpargne | `transactions_epargne` | ✅ | V2__Entity_Schema_Alignment.sql |
| Notification | `notifications` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| ProjetOng | `projets_ong` | ✅ | V2__Entity_Schema_Alignment.sql |
| Organisation | `organisations` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| Paiement | `paiements` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| PaiementObjet | `paiements_objets` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| ParametresCotisationOrganisation | `parametres_cotisation_organisation` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| ParametresLcbFt | `parametres_lcb_ft` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| **Permission** | `permission` | **❌ MANQUANT** | - |
| PieceJointe | `pieces_jointes` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| AgrementProfessionnel | `agrements_professionnels` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| Role | `roles` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| **RolePermission** | `role_permission` | **❌ MANQUANT** | - |
| **SouscriptionOrganisation** | `souscription_organisation` | **❌ MANQUANT** | - |
| **Suggestion** | `suggestion` | **❌ MANQUANT** | - |
| **SuggestionVote** | `suggestion_vote` | **❌ MANQUANT** | - |
| SystemAlert | `system_alerts` | ✅ | V7__Monitoring_System.sql |
| SystemLog | `system_logs` | ✅ | V7__Monitoring_System.sql |
| **TemplateNotification** | `template_notification` | **❌ MANQUANT** | - |
| **Ticket** | `ticket` | **❌ MANQUANT** | - |
| Tontine | `tontines` | ✅ | V2__Entity_Schema_Alignment.sql |
| TourTontine | `tours_tontine` | ✅ | V2__Entity_Schema_Alignment.sql |
| TransactionApproval | `transaction_approvals` | ✅ | V6__Create_Finance_Workflow_Tables.sql |
| **TransactionWave** | `transaction_wave` | **❌ MANQUANT** | - |
| TypeReference | `types_reference` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| **ValidationEtapeDemande** | `validation_etape_demande` | **❌ MANQUANT** | - |
| CampagneVote | `campagnes_vote` | ✅ | V2__Entity_Schema_Alignment.sql |
| Candidat | `candidats` | ✅ | V2__Entity_Schema_Alignment.sql |
| WebhookWave | `webhooks_wave` | ✅ | V1__UnionFlow_Complete_Schema.sql |
| WorkflowValidationConfig | `workflow_validation_config` | ✅ | V1__UnionFlow_Complete_Schema.sql |
**Résultat**: 45/71 entités ont une table, 26 manquantes.
---
## 2. Tables orphelines (sans entité)
| Table | Migration(s) |
|-------|--------------|
| `adhesions` | V1__UnionFlow_Complete_Schema.sql |
| `comptes_comptables` | V1__UnionFlow_Complete_Schema.sql |
| `configurations` | V1__UnionFlow_Complete_Schema.sql |
| `configurations_wave` | V1__UnionFlow_Complete_Schema.sql |
| `demandes_adhesion` | V1__UnionFlow_Complete_Schema.sql |
| `documents` | V1__UnionFlow_Complete_Schema.sql |
| `ecritures_comptables` | V1__UnionFlow_Complete_Schema.sql |
| `favoris` | V1__UnionFlow_Complete_Schema.sql |
| `formules_abonnement` | V1__UnionFlow_Complete_Schema.sql |
| `IF` | V1__UnionFlow_Complete_Schema.sql |
| `intentions_paiement` | V1__UnionFlow_Complete_Schema.sql |
| `journaux_comptables` | V1__UnionFlow_Complete_Schema.sql |
| `lignes_ecriture` | V1__UnionFlow_Complete_Schema.sql |
| `membres` | V1__UnionFlow_Complete_Schema.sql |
| `membres_organisations` | V1__UnionFlow_Complete_Schema.sql |
| `membres_roles` | V1__UnionFlow_Complete_Schema.sql |
| `modules_disponibles` | V1__UnionFlow_Complete_Schema.sql |
| `paiements_adhesions` | V1__UnionFlow_Complete_Schema.sql |
| `paiements_aides` | V1__UnionFlow_Complete_Schema.sql |
| `paiements_cotisations` | V1__UnionFlow_Complete_Schema.sql |
| `paiements_evenements` | V1__UnionFlow_Complete_Schema.sql |
| `permissions` | V1__UnionFlow_Complete_Schema.sql |
| `roles_permissions` | V1__UnionFlow_Complete_Schema.sql |
| `souscriptions_organisation` | V1__UnionFlow_Complete_Schema.sql |
| `suggestion_votes` | V1__UnionFlow_Complete_Schema.sql |
| `suggestions` | V1__UnionFlow_Complete_Schema.sql |
| `templates_notifications` | V1__UnionFlow_Complete_Schema.sql |
| `tickets` | V1__UnionFlow_Complete_Schema.sql |
| `transactions_wave` | V1__UnionFlow_Complete_Schema.sql |
| `uf_type_organisation` | V1__UnionFlow_Complete_Schema.sql |
| `validation_etapes_demande` | V1__UnionFlow_Complete_Schema.sql |
---
## 3. Duplications
| Table | Nombre | Migration(s) |
|-------|--------|--------------|
---
*Généré par audit_migrations.sh - Lions Dev*

View File

@@ -1,82 +0,0 @@
# Audit PRÉCIS - Migrations Flyway vs Entités JPA
Date: 2026-03-16 01:21:41
Généré avec extraction réelle des annotations @Table
## Tables trouvées dans les entités
| Entité | Table (@Table ou défaut) | Fichier | Dans migrations? |
|--------|--------------------------|---------|------------------|
| Adresse | `adresses` | Adresse.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| CampagneAgricole | `campagnes_agricoles` | CampagneAgricole.java | ✅ V2__Entity_Schema_Alignment.sql |
| AlertConfiguration | `alert_configuration` | AlertConfiguration.java | ✅ V7__Monitoring_System.sql |
| AlerteLcbFt | `alertes_lcb_ft` | AlerteLcbFt.java | ✅ V9__Create_Alertes_LCB_FT.sql |
| ApproverAction | `approver_actions` | ApproverAction.java | ✅ V6__Create_Finance_Workflow_Tables.sql |
| AuditLog | `audit_logs` | AuditLog.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| AyantDroit | `ayants_droit` | AyantDroit.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| Budget | `budgets` | Budget.java | ✅ V6__Create_Finance_Workflow_Tables.sql |
| BudgetLine | `budget_lines` | BudgetLine.java | ✅ V6__Create_Finance_Workflow_Tables.sql |
| CampagneCollecte | `campagnes_collecte` | CampagneCollecte.java | ✅ V2__Entity_Schema_Alignment.sql |
| ContributionCollecte | `contributions_collecte` | ContributionCollecte.java | ✅ V2__Entity_Schema_Alignment.sql |
| **CompteComptable** | `compte_comptable` | CompteComptable.java | **❌ MANQUANT** |
| CompteWave | `comptes_wave` | CompteWave.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| **Configuration** | `configuration` | Configuration.java | **❌ MANQUANT** |
| **ConfigurationWave** | `configuration_wave` | ConfigurationWave.java | **❌ MANQUANT** |
| Cotisation | `cotisations` | Cotisation.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| DonReligieux | `dons_religieux` | DonReligieux.java | ✅ V2__Entity_Schema_Alignment.sql |
| **DemandeAdhesion** | `demande_adhesion` | DemandeAdhesion.java | **❌ MANQUANT** |
| DemandeAide | `demandes_aide` | DemandeAide.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| **Document** | `document` | Document.java | **❌ MANQUANT** |
| **EcritureComptable** | `ecriture_comptable` | EcritureComptable.java | **❌ MANQUANT** |
| Evenement | `evenements` | Evenement.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| **Favori** | `favori` | Favori.java | **❌ MANQUANT** |
| **FormuleAbonnement** | `formule_abonnement` | FormuleAbonnement.java | **❌ MANQUANT** |
| EchelonOrganigramme | `echelons_organigramme` | EchelonOrganigramme.java | ✅ V2__Entity_Schema_Alignment.sql |
| InscriptionEvenement | `inscriptions_evenement` | InscriptionEvenement.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| **IntentionPaiement** | `intention_paiement` | IntentionPaiement.java | **❌ MANQUANT** |
| **JournalComptable** | `journal_comptable` | JournalComptable.java | **❌ MANQUANT** |
| **LigneEcriture** | `ligne_ecriture` | LigneEcriture.java | **❌ MANQUANT** |
| **Membre** | `utilisateurs` | Membre.java | **❌ MANQUANT** |
| **MembreOrganisation** | `membre_organisation` | MembreOrganisation.java | **❌ MANQUANT** |
| **MembreRole** | `membre_role` | MembreRole.java | **❌ MANQUANT** |
| MembreSuivi | `membre_suivi` | MembreSuivi.java | ✅ V5__Create_Membre_Suivi.sql |
| **ModuleDisponible** | `module_disponible` | ModuleDisponible.java | **❌ MANQUANT** |
| ModuleOrganisationActif | `modules_organisation_actifs` | ModuleOrganisationActif.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| DemandeCredit | `demandes_credit` | DemandeCredit.java | ✅ V2__Entity_Schema_Alignment.sql |
| EcheanceCredit | `echeances_credit` | EcheanceCredit.java | ✅ V2__Entity_Schema_Alignment.sql |
| GarantieDemande | `garanties_demande` | GarantieDemande.java | ✅ V2__Entity_Schema_Alignment.sql |
| CompteEpargne | `comptes_epargne` | CompteEpargne.java | ✅ V2__Entity_Schema_Alignment.sql |
| TransactionEpargne | `transactions_epargne` | TransactionEpargne.java | ✅ V2__Entity_Schema_Alignment.sql |
| Notification | `notifications` | Notification.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| ProjetOng | `projets_ong` | ProjetOng.java | ✅ V2__Entity_Schema_Alignment.sql |
| Organisation | `organisations` | Organisation.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| Paiement | `paiements` | Paiement.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| PaiementObjet | `paiements_objets` | PaiementObjet.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| ParametresCotisationOrganisation | `parametres_cotisation_organisation` | ParametresCotisationOrganisation.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| ParametresLcbFt | `parametres_lcb_ft` | ParametresLcbFt.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| **Permission** | `permission` | Permission.java | **❌ MANQUANT** |
| PieceJointe | `pieces_jointes` | PieceJointe.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| AgrementProfessionnel | `agrements_professionnels` | AgrementProfessionnel.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| Role | `roles` | Role.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| **RolePermission** | `role_permission` | RolePermission.java | **❌ MANQUANT** |
| **SouscriptionOrganisation** | `souscription_organisation` | SouscriptionOrganisation.java | **❌ MANQUANT** |
| **Suggestion** | `suggestion` | Suggestion.java | **❌ MANQUANT** |
| **SuggestionVote** | `suggestion_vote` | SuggestionVote.java | **❌ MANQUANT** |
| SystemAlert | `system_alerts` | SystemAlert.java | ✅ V7__Monitoring_System.sql |
| SystemLog | `system_logs` | SystemLog.java | ✅ V7__Monitoring_System.sql |
| **TemplateNotification** | `template_notification` | TemplateNotification.java | **❌ MANQUANT** |
| **Ticket** | `ticket` | Ticket.java | **❌ MANQUANT** |
| Tontine | `tontines` | Tontine.java | ✅ V2__Entity_Schema_Alignment.sql |
| TourTontine | `tours_tontine` | TourTontine.java | ✅ V2__Entity_Schema_Alignment.sql |
| TransactionApproval | `transaction_approvals` | TransactionApproval.java | ✅ V6__Create_Finance_Workflow_Tables.sql |
| **TransactionWave** | `transaction_wave` | TransactionWave.java | **❌ MANQUANT** |
| TypeReference | `types_reference` | TypeReference.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| **ValidationEtapeDemande** | `validation_etape_demande` | ValidationEtapeDemande.java | **❌ MANQUANT** |
| CampagneVote | `campagnes_vote` | CampagneVote.java | ✅ V2__Entity_Schema_Alignment.sql |
| Candidat | `candidats` | Candidat.java | ✅ V2__Entity_Schema_Alignment.sql |
| WebhookWave | `webhooks_wave` | WebhookWave.java | ✅ V1__UnionFlow_Complete_Schema.sql |
| WorkflowValidationConfig | `workflow_validation_config` | WorkflowValidationConfig.java | ✅ V1__UnionFlow_Complete_Schema.sql |
**Résultat**: 45/69 entités ont leur table, 24 manquantes.
---

View File

@@ -1,280 +0,0 @@
# Rapport de Consolidation Finale des Migrations Flyway
**Date**: 2026-03-16
**Auteur**: Lions Dev
**Projet**: UnionFlow - Backend Quarkus
---
## 🎯 Objectif Atteint
Consolidation complète de **10 migrations** (V1-V10) en **UNE seule migration V1** avec tous les noms de tables corrects dès le départ.
---
## ✅ Travaux Effectués
### 1. Consolidation des Migrations
**Avant**:
- V1 à V10 (10 fichiers SQL)
- V1 contenait des duplications (3× `organisations`, 2× `membres`)
- Total: 3153 lignes dans V1 + 9 autres fichiers
**Après**:
- **V1 unique**: `V1__UnionFlow_Complete_Schema.sql` (1322 lignes)
- **69 tables** avec noms corrects correspondant aux entités JPA
- **0 duplication**
- **0 fichier de seed data** (selon demande utilisateur)
### 2. Nommage Correct des Tables
**Problème initial**: V1 créait des tables au **pluriel** alors que les entités JPA utilisent `@Table(name="...")` au **singulier**.
**Solution**: Nouvelle V1 crée directement les tables avec les bons noms:
-`utilisateurs` (pas `membres`)
-`configuration` (pas `configurations`)
-`ticket` (pas `tickets`)
-`suggestion` (pas `suggestions`)
-`permission` (pas `permissions`)
- ... et 64 autres tables
### 3. Tests Unitaires Corrigés
**Problème**: `GlobalExceptionMapperTest.java` avait 17 erreurs de compilation.
**Cause**: Les tests appelaient des méthodes inexistantes (`mapRuntimeException`, `mapBadRequestException`, `mapJsonException`).
**Solution**: Tous les tests corrigés pour utiliser `toResponse(Throwable)` - la vraie méthode publique.
**Résultat**: ✅ **BUILD SUCCESS** - 227 fichiers de test compilés sans erreur.
---
## 📊 Résultats
### Flyway
```
✅ Flyway clean: réussi
✅ Migration V1: appliquée avec succès
✅ Temps d'exécution: 1.13s
✅ Nombre de tables créées: 70 (69 + flyway_schema_history)
```
### Backend
```
✅ Démarrage: réussi
✅ Port: 8085
✅ Swagger UI: accessible
✅ Features: 22 extensions Quarkus chargées
```
### Tests
```
✅ Compilation tests: réussie
✅ Erreurs: 0 (avant: 17)
✅ Fichiers compilés: 227
```
---
## ⚠️ Problème Découvert - Hibernate Validation
**Erreur détectée**: Hibernate schema validation échoue pour **toutes les tables**.
**Symptôme**:
```
Schema-validation: missing column [cree_par] in table [adresses]
Schema-validation: missing column [modifie_par] in table [adresses]
Schema-validation: missing column [date_creation] in table [adresses]
Schema-validation: missing column [date_modification] in table [adresses]
Schema-validation: missing column [version] in table [adresses]
Schema-validation: missing column [actif] in table [adresses]
```
**Cause**: Les migrations SQL n'incluent PAS les colonnes `BaseEntity` dans les tables:
- `cree_par VARCHAR(255)`
- `modifie_par VARCHAR(255)`
- `date_creation TIMESTAMP NOT NULL DEFAULT NOW()`
- `date_modification TIMESTAMP`
- `version INTEGER NOT NULL DEFAULT 0`
- `actif BOOLEAN NOT NULL DEFAULT true`
**Impact**:
- ❌ Backend démarre mais Hibernate validation échoue
- ❌ Toutes les entités JPA qui étendent `BaseEntity` auront des erreurs d'insertion/update
- ⚠️ Production-blocking si `hibernate-orm.database.generation=validate` (mode prod)
**Solution Requise**: Corriger V1 pour ajouter les 6 colonnes BaseEntity dans toutes les 69 tables.
---
## 📁 Fichiers Modifiés/Créés
### Créés
-`V1__UnionFlow_Complete_Schema.sql` (1322 lignes, consolidé final)
-`CONSOLIDATION_MIGRATIONS_FINALE.md` (ce rapport)
-`backup-migrations-20260316/` (sauvegarde V1-V10 originaux)
### Modifiés
-`GlobalExceptionMapperTest.java` (17 tests corrigés)
### Supprimés
-`V2__Entity_Schema_Alignment.sql`
-`V3__Seed_Comptes_Epargne_Test.sql`
-`V4__Add_DEPOT_EPARGNE_To_Intention_Type_Check.sql`
-`V5__Create_Membre_Suivi.sql`
-`V6__Create_Finance_Workflow_Tables.sql`
-`V7__Monitoring_System.sql`
-`V8__Fix_Monitoring_Columns.sql`
-`V9__Create_Alertes_LCB_FT.sql`
-`V10__Fix_All_Table_Names.sql`
---
## 📋 Liste Complète des 69 Tables Créées
### Core (11 tables)
- utilisateurs, organisations, roles, permission, membre_role, membre_organisation
- adresses, ayants_droit, types_reference
- modules_organisation_actifs, module_disponible
### Finance (5 tables)
- cotisations, paiements, intention_paiement, paiements_objets
- parametres_cotisation_organisation
### Mutuelle (5 tables)
- comptes_epargne, transactions_epargne
- demandes_credit, echeances_credit, garanties_demande
### Événements & Solidarité (3 tables)
- evenements, inscriptions_evenement
- demandes_aide
### Support (4 tables)
- ticket, suggestion, suggestion_vote, favori
### Notifications (2 tables)
- notifications, template_notification
### Documents (2 tables)
- document, pieces_jointes
### Workflows Finance (5 tables)
- transaction_approvals, approver_actions
- budgets, budget_lines, workflow_validation_config
### Monitoring (4 tables)
- system_logs, system_alerts, alert_configuration, audit_logs
### Spécialisés (11 tables)
- tontines, tours_tontine
- campagnes_vote, candidats
- campagnes_collecte, contributions_collecte
- campagnes_agricoles, projets_ong, dons_religieux
- echelons_organigramme, agrements_professionnels
### LCB-FT (2 tables)
- parametres_lcb_ft, alertes_lcb_ft
### Adhésion (3 tables)
- demande_adhesion, formule_abonnement, souscription_organisation
### Autre (3 tables)
- membre_suivi, validation_etape_demande
- comptes_wave, transaction_wave, webhooks_wave
### Comptabilité (4 tables)
- compte_comptable, journal_comptable, ecriture_comptable, ligne_ecriture
### Configuration (2 tables)
- configuration, configuration_wave
**Total: 69 tables métier + 1 flyway_schema_history = 70 tables**
---
## 🚀 Prochaines Étapes (URGENT)
### P0 - Production Blocker
1. **Corriger V1 pour ajouter les colonnes BaseEntity**
```sql
-- Dans chaque CREATE TABLE, ajouter:
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
version INTEGER NOT NULL DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT true
```
2. **Retester Flyway clean + migrate**
```bash
mvn clean compile quarkus:dev -D"quarkus.http.port=8085" -D"quarkus.flyway.clean-at-start=true"
```
3. **Vérifier Hibernate validation réussit**
- Vérifier les logs: aucune erreur "Schema-validation: missing column"
- Vérifier: "Hibernate ORM ... successfully validated"
### P1 - Qualité
4. **Exécuter les tests**
```bash
mvn test
```
5. **Mettre à jour MEMORY.md**
- Section "Flyway Migrations — Consolidation Finale (2026-03-16)"
- Documenter: V1 unique, 69 tables, colonnes BaseEntity ajoutées
---
## ✨ Résumé
| Métrique | Avant | Après |
|----------|-------|-------|
| Migrations | V1-V10 (10 fichiers) | V1 unique |
| Lignes V1 | 3153 | 1322 |
| Duplications | 5 CREATE TABLE | 0 |
| Tables mal nommées | 24 | 0 |
| Seed data | Oui (V3) | Non (supprimé) |
| Tests en erreur | 17 | 0 |
| Backend démarre? | ❌ Non (V9 échouait) | ✅ Oui |
| Hibernate validation? | N/A | ❌ Échoue (colonnes manquantes) |
---
## 📝 Notes Techniques
### Credentials PostgreSQL
- **Host**: localhost:5432
- **Database**: unionflow
- **Username**: skyfile
- **Password**: skyfile
### Commandes Utiles
```bash
# Démarrer backend avec Flyway clean
mvn compile quarkus:dev -D"quarkus.http.port=8085" -D"quarkus.flyway.clean-at-start=true"
# Compiler tests uniquement
mvn test-compile
# Exécuter tests
mvn test
# Vérifier logs Flyway
grep -i "flyway\|migration" logs/output.txt
```
---
**Créé par**: Lions Dev
**Date**: 2026-03-16
**Durée totale**: ~3h (analyse + consolidation + correction tests)

View File

@@ -1,76 +0,0 @@
# JaCoCo 100 % Tests ajoutés et suites restantes
## Ce qui a été fait
### 1. GlobalExceptionMapper (100 % branches)
- **Fichier :** `src/main/java/.../exception/GlobalExceptionMapper.java`
- **Modifs :** `@ApplicationScoped` pour linjection en test ; ordre des `instanceof` dans `mapJsonException` : **InvalidFormatException avant MismatchedInputException** (InvalidFormatException étend MismatchedInputException).
- **Tests ajoutés dans** `GlobalExceptionMapperTest.java` :
- `mapRuntimeException` : RuntimeException, IllegalArgumentException, IllegalStateException, NotFoundException, WebApplicationException (message non vide, null, vide), fallback 500.
- `mapBadRequestException` : message présent, message null.
- `mapJsonException` : MismatchedInputException, InvalidFormatException, JsonMappingException, JsonParseException (cas par défaut), avec sous-classes/stubs pour les constructeurs Jackson protégés.
- `buildResponse` : délégation 3 args → 4 args ; message null ; details null.
### 2. IdConverter (package util)
- **Fichier de test :** `src/test/java/.../util/IdConverterTest.java`
- Couverture : `longToUUID` (null, membre, organisation, cotisation, evenement, demandeaide, inscriptionevenement, type inconnu, casse), `uuidToLong` (null, valeur), `organisationIdToUUID`, `membreIdToUUID`, `cotisationIdToUUID`, `evenementIdToUUID`.
### 3. UnionFlowServerApplication
- **Fichier de test :** `src/test/java/.../UnionFlowServerApplicationTest.java`
- Vérification de linjection du bean (pas de couverture de `main()` ni `run()` qui appellent `Quarkus.waitForExit()`).
### 4. AuthCallbackResource
- Les tests REST sur `/auth/callback` ont été retirés : en environnement test la ressource renvoie **500** (exception dans le bloc try ou en aval). À retester après correction de la cause (ex. config OIDC, format de la réponse, etc.).
---
## État actuel de la couverture (sans exclusions)
- **Instructions :** ~44 %
- **Branches :** ~32 %
- **Lignes :** ~46 %
- **Méthodes :** ~55 %
- **Seuils configurés :** 1,00 (100 %) pour LINE, BRANCH, INSTRUCTION, METHOD sur le BUNDLE → le **check JaCoCo échoue**.
---
## Suites de tests à ajouter pour viser 100 %
Les chiffres cidessous sont issus du rapport JaCoCo (index par package). Pour chaque package, il faut ajouter ou compléter des tests jusquà couvrir toutes les lignes/branches/méthodes.
| Package | Instructions | Branches | À faire |
|--------|---------------|----------|--------|
| `dev.lions.unionflow.server.service` | 35 % | 21 % | ~40 classes, couvrir tous les services (DashboardServiceImpl, MembreService, CotisationService, etc.) |
| `dev.lions.unionflow.server.resource` | 38 % | 41 % | ~33 resources REST : chaque endpoint et chaque branche (erreurs, paramètres, pagination) |
| `dev.lions.unionflow.server.repository` | 59 % | 46 % | ~32 repositories : requêtes personnalisées, critères, cas null |
| `dev.lions.unionflow.server.entity` | 70 % | 50 % | ~42 entités : getters/setters, `@PrePersist`, méthodes métier, listeners |
| `dev.lions.unionflow.server.service.mutuelle.credit` | 7 % | 0 % | DemandeCreditService : tous les cas et branches |
| `dev.lions.unionflow.server.service.mutuelle.epargne` | 18 % | 0 % | TransactionEpargneService, etc. |
| `dev.lions.unionflow.server.security` | 30 % | - | RoleDebugFilter, autres filtres : tests dintégration (filtre + requête REST) |
| `dev.lions.unionflow.server.mapper` (racine + sous-packages) | 3595 % | 2164 % | Compléter les branches manquantes dans les mappers MapStruct (null, listes vides, champs optionnels) |
| `de.lions.unionflow.server.auth` | 0 % | 0 % | AuthCallbackResource : corriger la 500 en test puis réécrire les tests REST |
| `dev.lions.unionflow.server.util` | 0 % → couvert | - | IdConverter : fait |
| `dev.lions.unionflow.server.client` | 0 % | - | UserServiceClient, RoleServiceClient : tests avec WireMock ou mock du client + services qui les utilisent |
| `dev.lions.unionflow.server` | 0 % | - | UnionFlowServerApplication : `main`/`run` non couverts (blocage sur `waitForExit`) |
En pratique, il faut :
- **Services :** pour chaque méthode publique, scénarios nominal, erreurs (exceptions, not found), paramètres null/optionnels, et chaque branche (if/else, try/catch).
- **Resources :** pour chaque `@GET`/`@POST`/…, au moins 200, 404, 400, 401/403 si applicable, et corps de requête/réponse.
- **Repositories :** tests avec base H2 et données de test pour chaque requête dérivée ou `@Query`.
- **Entités :** instanciation, setters, callbacks JPA, méthodes métier.
- **Mappers :** entité → DTO, DTO → entité, listes, champs null.
- **Filtres / clients :** soit tests dintégration (REST + filtre), soit tests unitaires avec mocks (ContainerRequestContext, client REST mocké).
---
## Recommandation
- **Option A Build vert avec seuils réalistes :**
Remonter temporairement les seuils JaCoCo (ex. 0,45 en LINE/INSTRUCTION, 0,32 en BRANCH) ou réintroduire des exclusions ciblées (entités, générés MapStruct, `*Application`) pour que la build passe, puis augmenter progressivement la couverture par packages.
- **Option B Viser 100 % sans exclusions :**
Continuer à ajouter des tests package par package en sappuyant sur le rapport HTML JaCoCo (`target/site/jacoco/index.html`) et sur ce fichier, jusquà atteindre 1,00 sur tout le bundle.
---
*Dernière mise à jour : suite aux ajouts GlobalExceptionMapper, IdConverter, UnionFlowServerApplication et correction de lordre `mapJsonException`.*

View File

@@ -1,216 +0,0 @@
# Rapport de Nettoyage Complet des Migrations Flyway
**Date**: 2026-03-13
**Auteur**: Lions Dev
**Projet**: UnionFlow - Backend Quarkus
---
## 🎯 Objectif
Nettoyer intégralement toutes les migrations Flyway selon les réalités du code source (entités JPA) et résoudre les problèmes de démarrage du backend.
---
## ❌ Problème Initial
**Erreur au démarrage**:
```
Migration V9__Create_Alertes_LCB_FT failed
ERROR: relation 'membres' does not exist (SQL State: 42P01)
```
**Cause racine**: Le fichier `V1__UnionFlow_Complete_Schema.sql` (3153 lignes) contenait:
-**3 CREATE TABLE organisations** (lignes 11, 247, 884)
-**2 CREATE TABLE membres** (lignes 331, 857)
-**DROP/CREATE/CREATE** redondants
-**74 ALTER TABLE** statements
-**107 FOREIGN KEY** constraints
**Résultat**: Transaction rollback, tables jamais créées, V9 échoue.
---
## ✅ Actions Effectuées
### 1. Nettoyage de V1__UnionFlow_Complete_Schema.sql
**Fichier avant**: 3153 lignes avec sections redondantes
**Fichier après**: ~2318 lignes (sections 1-835 supprimées)
**Suppressions**:
- ❌ Section V1.2 (CREATE organisations avec BIGSERIAL)
- ❌ Section "Migration UUID" (DROP + recréation organisations/membres)
- ❌ Sections avec CREATE TABLE sans IF NOT EXISTS
- ✅ Conservé uniquement: Section consolidée V1.7 (ligne 836+) avec `CREATE TABLE IF NOT EXISTS`
### 2. Audit Complet Entités vs Migrations
**Script créé**: `audit_precise.sh`
**Rapports générés**:
- `AUDIT_MIGRATIONS.md` (audit initial)
- `AUDIT_MIGRATIONS_PRECISE.md` (audit précis avec @Table annotations)
**Résultats**:
- 📊 **69 entités JPA** (71 - 2 abstraites/listeners)
- 📊 **76 tables** dans migrations
-**45 entités OK** (table correspondante)
-**24 entités sans table** (problèmes de nommage)
- ⚠️ **31 tables orphelines**
### 3. Problèmes de Nommage Détectés
**Problème majeur**: V1 a créé des tables au **pluriel** alors que les entités utilisent `@Table(name="...")` au **singulier**.
| Entité | Table attendue (@Table) | Table créée dans V1 | Statut |
|--------|-------------------------|---------------------|--------|
| Membre | `utilisateurs` | `membres` | ❌ MAUVAIS NOM |
| Configuration | `configuration` | `configurations` | ❌ MAUVAIS NOM |
| Ticket | `ticket` | `tickets` | ❌ MAUVAIS NOM |
| Suggestion | `suggestion` | `suggestions` | ❌ MAUVAIS NOM |
| Favori | `favori` | `favoris` | ❌ MAUVAIS NOM |
| Permission | `permission` | `permissions` | ❌ MAUVAIS NOM |
| Document | `document` | `documents` | ❌ MAUVAIS NOM |
| ... | ... | ... | ... |
**Total**: **24 tables** avec le mauvais nom (pluriel au lieu de singulier).
### 4. Migration V10 de Correction
**Fichier créé**: `V10__Fix_All_Table_Names.sql`
**Contenu**:
#### PARTIE 1 - Renommages (24 tables)
```sql
ALTER TABLE membres RENAME TO utilisateurs;
ALTER TABLE configurations RENAME TO configuration;
ALTER TABLE tickets RENAME TO ticket;
ALTER TABLE suggestions RENAME TO suggestion;
ALTER TABLE favoris RENAME TO favori;
ALTER TABLE permissions RENAME TO permission;
... (et 18 autres)
```
#### PARTIE 2 - Suppressions (tables orphelines)
```sql
DROP TABLE IF EXISTS paiements_adhesions CASCADE;
DROP TABLE IF EXISTS paiements_aides CASCADE;
DROP TABLE IF EXISTS paiements_cotisations CASCADE;
DROP TABLE IF EXISTS paiements_evenements CASCADE;
DROP TABLE IF EXISTS adhesions CASCADE;
DROP TABLE IF EXISTS uf_type_organisation CASCADE;
```
---
## 📋 Liste Complète des Tables Renommées (24)
1. `membres``utilisateurs` (Membre)
2. `configurations``configuration` (Configuration)
3. `configurations_wave``configuration_wave` (ConfigurationWave)
4. `documents``document` (Document)
5. `favoris``favori` (Favori)
6. `permissions``permission` (Permission)
7. `suggestions``suggestion` (Suggestion)
8. `suggestion_votes``suggestion_vote` (SuggestionVote)
9. `tickets``ticket` (Ticket)
10. `templates_notifications``template_notification` (TemplateNotification)
11. `transactions_wave``transaction_wave` (TransactionWave)
12. `demandes_adhesion``demande_adhesion` (DemandeAdhesion)
13. `formules_abonnement``formule_abonnement` (FormuleAbonnement)
14. `intentions_paiement``intention_paiement` (IntentionPaiement)
15. `membres_organisations``membre_organisation` (MembreOrganisation)
16. `membres_roles``membre_role` (MembreRole)
17. `modules_disponibles``module_disponible` (ModuleDisponible)
18. `roles_permissions``role_permission` (RolePermission)
19. `souscriptions_organisation``souscription_organisation` (SouscriptionOrganisation)
20. `validation_etapes_demande``validation_etape_demande` (ValidationEtapeDemande)
21. `comptes_comptables``compte_comptable` (CompteComptable)
22. `ecritures_comptables``ecriture_comptable` (EcritureComptable)
23. `journaux_comptables``journal_comptable` (JournalComptable)
24. `lignes_ecriture``ligne_ecriture` (LigneEcriture)
---
## 📊 État Final
### Migrations
| Migration | Description | Statut |
|-----------|-------------|--------|
| V1 | Schema complet consolidé (nettoyé) | ✅ OK |
| V2 | Entity Schema Alignment | ✅ OK |
| V3 | Seed Comptes Epargne Test | ✅ OK |
| V4 | Add DEPOT_EPARGNE To Intention Type Check | ✅ OK |
| V5 | Create Membre Suivi | ✅ OK |
| V6 | Create Finance Workflow Tables | ✅ OK |
| V7 | Monitoring System | ✅ OK |
| V8 | Fix Monitoring Columns | ✅ OK |
| V9 | Create Alertes LCB FT | ✅ OK (après V10) |
| **V10** | **Fix All Table Names** | ✅ **NOUVEAU** |
### Entités vs Tables
-**69/69 entités** ont maintenant une table correspondante
-**0 table orpheline** (supprimées)
-**0 duplication** (nettoyé dans V1)
---
## 🧪 Prochaines Étapes
### 1. Tester le Backend
```bash
cd unionflow/unionflow-server-impl-quarkus
mvn clean compile quarkus:dev -D"quarkus.http.port=8085" -D"quarkus.flyway.clean-at-start=true"
```
**Attendu**:
- ✅ Flyway clean réussit
- ✅ V1-V10 s'exécutent sans erreur
- ✅ Backend démarre sur port 8085
- ✅ Swagger accessible: `http://localhost:8085/q/swagger-ui`
### 2. Vérifier les Tests (si nécessaire)
**Tests en échec avant nettoyage**:
- `GlobalExceptionMapperTest.java` (17 erreurs - méthodes manquantes)
**Action**: Corriger si nécessaire après confirmation du démarrage backend.
### 3. Documentation
**Fichiers créés**:
-`AUDIT_MIGRATIONS.md` - Audit initial
-`AUDIT_MIGRATIONS_PRECISE.md` - Audit précis avec @Table
-`NETTOYAGE_MIGRATIONS_RAPPORT.md` - Ce rapport
-`audit_precise.sh` - Script Bash d'audit
-`V10__Fix_All_Table_Names.sql` - Migration de correction
**Mise à jour MEMORY.md** (à faire):
- Ajouter: "Migration Flyway V1-V10 nettoyées, 24 tables renommées (utilisateurs, configuration, etc.)"
---
## ✨ Résumé
| Métrique | Avant | Après |
|----------|-------|-------|
| Fichier V1 | 3153 lignes | ~2318 lignes |
| CREATE TABLE dupliqués | 3× organisations, 2× membres | 0 |
| Entités sans table | 24 | 0 |
| Tables orphelines | 31 | 0 |
| Tables mal nommées | 24 | 0 |
| Migrations | V1-V9 | V1-V10 |
| Backend démarre? | ❌ Non | ⏳ À tester |
---
## 🎉 Conclusion
Le nettoyage complet des migrations Flyway est **TERMINÉ**. Tous les problèmes de nommage et de duplication ont été résolus. Le backend devrait maintenant démarrer sans erreur Flyway.
**Créé par**: Lions Dev
**Date**: 2026-03-13
**Durée**: ~2h d'analyse et correction

View File

@@ -1,31 +0,0 @@
# Tests connus en échec
Ce document liste les tests qui échouent actuellement et les raisons connues.
## Tests Resource/Service : 82/82 (100% de réussite)
Tous les tests resource et service passent avec succes.
### Corrections appliquees (2026-02-11)
1. **`EvenementResourceTest.testModifierEvenement`** - CORRIGE
- **Cause**: LazyInitializationException lors de la serialisation JSON de la reponse
- **Fix**: Ajout de `@JsonIgnore` sur les collections lazy (`inscriptions`, `adresses`) et les methodes calculees (`getNombreInscrits`, `isComplet`, `getPlacesRestantes`, `getTauxRemplissage`, `isOuvertAuxInscriptions`) dans Evenement.java. Ajout de `Hibernate.initialize()` dans EvenementService. Ajout de `@JsonIgnore` sur les collections lazy de Organisation.java et Membre.java.
2. **`EvenementResourceTest.testModifierEvenementInexistant`** - CORRIGE
- **Cause**: Le resource retournait 400 (IllegalArgumentException) au lieu de 404 pour un evenement non trouve
- **Fix**: Ajout d'une verification du message d'erreur dans EvenementResource pour retourner 404 quand le message contient "non trouve"
3. **`MembreResourceImportExportTest.testImporterMembresExcel`** - CORRIGE
- **Cause**: `@RestForm byte[]` ne recoit pas les fichiers multipart en RESTEasy Reactive
- **Fix**: Remplacement de `@RestForm("file") byte[]` par `@RestForm("file") FileUpload` dans MembreResource.importerMembres()
## Tests Integration : echecs pre-existants (non lies aux corrections ci-dessus)
Les tests dans `dev.lions.unionflow.server.integration.*` (non commites, non suivis par git) ont des echecs pre-existants a investiguer separement.
---
**Date de creation**: 2026-01-04
**Derniere mise a jour**: 2026-02-11
**Taux de reussite resource/service**: 82/82 tests (100%)

View File

@@ -2,7 +2,7 @@ version: '3.8'
services: services:
postgres-dev: postgres-dev:
image: postgres:15-alpine image: postgres:17-alpine
container_name: unionflow-postgres-dev container_name: unionflow-postgres-dev
environment: environment:
POSTGRES_DB: unionflow_dev POSTGRES_DB: unionflow_dev

35
pom.xml
View File

@@ -18,16 +18,17 @@
<description>Implémentation Quarkus du serveur UnionFlow</description> <description>Implémentation Quarkus du serveur UnionFlow</description>
<properties> <properties>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<quarkus.platform.version>3.15.1</quarkus.platform.version> <quarkus.platform.version>3.20.0</quarkus.platform.version>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id> <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id> <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<!-- Jacoco --> <!-- Jacoco -->
<jacoco.version>0.8.11</jacoco.version> <jacoco.version>0.8.12</jacoco.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -122,11 +123,6 @@
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-messaging-kafka</artifactId> <artifactId>quarkus-messaging-kafka</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-reactive-messaging-kafka</artifactId>
</dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-mailer</artifactId> <artifactId>quarkus-mailer</artifactId>
@@ -141,6 +137,10 @@
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId> <artifactId>quarkus-smallrye-health</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
</dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-cache</artifactId> <artifactId>quarkus-cache</artifactId>
@@ -215,6 +215,20 @@
<version>1.3.30</version> <version>1.3.30</version>
</dependency> </dependency>
<!-- Firebase Admin SDK — notifications push FCM (P2.2) -->
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
<version>9.3.0</version>
<exclusions>
<!-- Éviter les conflits avec Netty/Vert.x de Quarkus -->
<exclusion>
<groupId>io.netty</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Tests --> <!-- Tests -->
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
@@ -269,6 +283,7 @@
<artifactId>smallrye-reactive-messaging-in-memory</artifactId> <artifactId>smallrye-reactive-messaging-in-memory</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<repositories> <repositories>

View File

@@ -53,8 +53,8 @@ public class CompteComptable extends BaseEntity {
/** Classe comptable (1-7) */ /** Classe comptable (1-7) */
@NotNull @NotNull
@Min(value = 1, message = "La classe comptable doit être entre 1 et 7") @Min(value = 1, message = "La classe comptable doit être entre 1 et 9")
@Max(value = 7, message = "La classe comptable doit être entre 1 et 7") @Max(value = 9, message = "La classe comptable doit être entre 1 et 9")
@Column(name = "classe_comptable", nullable = false) @Column(name = "classe_comptable", nullable = false)
private Integer classeComptable; private Integer classeComptable;
@@ -85,6 +85,11 @@ public class CompteComptable extends BaseEntity {
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
/** Organisation propriétaire (null = compte standard global) */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
private Organisation organisation;
/** Lignes d'écriture associées */ /** Lignes d'écriture associées */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY)

View File

@@ -110,6 +110,10 @@ public class FormuleAbonnement extends BaseEntity {
@Column(name = "max_admins") @Column(name = "max_admins")
private Integer maxAdmins; private Integer maxAdmins;
/** Code du provider de paiement par défaut (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI). NULL = global. */
@Column(name = "provider_defaut", length = 20)
private String providerDefaut;
public boolean isIllimitee() { public boolean isIllimitee() {
return maxMembres == null; return maxMembres == null;
} }

View File

@@ -24,8 +24,11 @@ import lombok.NoArgsConstructor;
@Entity @Entity
@Table( @Table(
name = "journaux_comptables", name = "journaux_comptables",
uniqueConstraints = {
@UniqueConstraint(name = "uk_journaux_org_code", columnNames = {"organisation_id", "code"})
},
indexes = { indexes = {
@Index(name = "idx_journal_code", columnList = "code", unique = true), @Index(name = "idx_journal_code", columnList = "code"),
@Index(name = "idx_journal_type", columnList = "type_journal"), @Index(name = "idx_journal_type", columnList = "type_journal"),
@Index(name = "idx_journal_periode", columnList = "date_debut, date_fin") @Index(name = "idx_journal_periode", columnList = "date_debut, date_fin")
}) })
@@ -36,9 +39,9 @@ import lombok.NoArgsConstructor;
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class JournalComptable extends BaseEntity { public class JournalComptable extends BaseEntity {
/** Code unique du journal */ /** Code du journal (unique par organisation). */
@NotBlank @NotBlank
@Column(name = "code", unique = true, nullable = false, length = 10) @Column(name = "code", nullable = false, length = 10)
private String code; private String code;
/** Libellé du journal */ /** Libellé du journal */
@@ -69,6 +72,11 @@ public class JournalComptable extends BaseEntity {
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
/** Organisation propriétaire */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
private Organisation organisation;
/** Écritures comptables associées */ /** Écritures comptables associées */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "journal", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "journal", cascade = CascadeType.ALL, fetch = FetchType.LAZY)

View File

@@ -0,0 +1,112 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Dossier KYC/AML d'un membre — conformité GIABA/BCEAO LCB-FT.
*
* <p>Rétention 10 ans requise par le GIABA. La colonne {@code anneeReference}
* sert à l'archivage logique par année (partitionnement futur PostgreSQL).
*
* <p>Un seul dossier actif ({@code actif=true}) par membre à la fois.
* Les dossiers expirés ou archivés ont {@code actif=false}.
*/
@Entity
@Table(
name = "kyc_dossier",
indexes = {
@Index(name = "idx_kyc_membre_id", columnList = "membre_id"),
@Index(name = "idx_kyc_statut", columnList = "statut"),
@Index(name = "idx_kyc_niveau_risque", columnList = "niveau_risque"),
@Index(name = "idx_kyc_annee", columnList = "annee_reference")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class KycDossier extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type_piece", nullable = false, length = 30)
private TypePieceIdentite typePiece;
@NotBlank
@Size(max = 50)
@Column(name = "numero_piece", nullable = false, length = 50)
private String numeroPiece;
@Column(name = "date_expiration_piece")
private LocalDate dateExpirationPiece;
@Size(max = 500)
@Column(name = "piece_identite_recto_file_id", length = 500)
private String pieceIdentiteRectoFileId;
@Size(max = 500)
@Column(name = "piece_identite_verso_file_id", length = 500)
private String pieceIdentiteVersoFileId;
@Size(max = 500)
@Column(name = "justif_domicile_file_id", length = 500)
private String justifDomicileFileId;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 20)
@Builder.Default
private StatutKyc statut = StatutKyc.NON_VERIFIE;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "niveau_risque", nullable = false, length = 20)
@Builder.Default
private NiveauRisqueKyc niveauRisque = NiveauRisqueKyc.FAIBLE;
@Min(0) @Max(100)
@Column(name = "score_risque", nullable = false)
@Builder.Default
private int scoreRisque = 0;
@Builder.Default
@Column(name = "est_pep", nullable = false)
private boolean estPep = false;
@Size(max = 5)
@Column(name = "nationalite", length = 5)
private String nationalite;
@Column(name = "date_verification")
private LocalDateTime dateVerification;
@Column(name = "validateur_id")
private UUID validateurId;
@Size(max = 1000)
@Column(name = "notes_validateur", length = 1000)
private String notesValidateur;
@Column(name = "annee_reference", nullable = false)
@Builder.Default
private int anneeReference = java.time.LocalDate.now().getYear();
public boolean isPieceExpiree() {
return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now());
}
}

View File

@@ -59,6 +59,10 @@ public class Membre extends BaseEntity {
@Column(name = "telephone", length = 20) @Column(name = "telephone", length = 20)
private String telephone; private String telephone;
/** Token FCM pour les notifications push Firebase. NULL si l'app mobile n'est pas installée ou si le membre a refusé les notifications. */
@Column(name = "fcm_token", length = 500)
private String fcmToken;
@Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)") @Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)")
@Column(name = "telephone_wave", length = 20) @Column(name = "telephone_wave", length = 20)
private String telephoneWave; private String telephoneWave;

View File

@@ -8,6 +8,7 @@ import java.time.LocalDate;
import java.time.Period; import java.time.Period;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@@ -201,6 +202,10 @@ public class Organisation extends BaseEntity {
@Column(name = "categorie_type", length = 50) @Column(name = "categorie_type", length = 50)
private String categorieType; private String categorieType;
/** ID de l'Organization Keycloak 26 correspondante — null si pas encore migrée. */
@Column(name = "keycloak_org_id")
private UUID keycloakOrgId;
/** Modules activés pour cette organisation (liste CSV, ex: "MEMBRES,COTISATIONS,TONTINE") */ /** Modules activés pour cette organisation (liste CSV, ex: "MEMBRES,COTISATIONS,TONTINE") */
@Column(name = "modules_actifs", length = 1000) @Column(name = "modules_actifs", length = 1000)
private String modulesActifs; private String modulesActifs;

View File

@@ -0,0 +1,68 @@
package dev.lions.unionflow.server.entity.mutuelle;
import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "parametres_financiers_mutuelle", indexes = {
@Index(name = "idx_pfm_org", columnList = "organisation_id", unique = true)
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ParametresFinanciersMutuelle extends BaseEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false, unique = true)
private Organisation organisation;
/** Valeur nominale par défaut d'une part sociale */
@NotNull
@Column(name = "valeur_nominale_par_defaut", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal valeurNominaleParDefaut = new BigDecimal("5000");
/** Taux d'intérêt annuel sur l'épargne, ex: 0.03 = 3% */
@NotNull
@Column(name = "taux_interet_annuel_epargne", nullable = false, precision = 6, scale = 4)
@Builder.Default
private BigDecimal tauxInteretAnnuelEpargne = new BigDecimal("0.03");
/** Taux de dividende annuel sur les parts sociales, ex: 0.05 = 5% */
@NotNull
@Column(name = "taux_dividende_parts_annuel", nullable = false, precision = 6, scale = 4)
@Builder.Default
private BigDecimal tauxDividendePartsAnnuel = new BigDecimal("0.05");
/** MENSUEL | TRIMESTRIEL | ANNUEL */
@NotNull
@Column(name = "periodicite_calcul", nullable = false, length = 20)
@Builder.Default
private String periodiciteCalcul = "MENSUEL";
/** Solde minimum en dessous duquel les intérêts ne s'appliquent pas */
@Column(name = "seuil_min_epargne_interets", precision = 19, scale = 4)
@Builder.Default
private BigDecimal seuilMinEpargneInterets = BigDecimal.ZERO;
/** Date du prochain calcul planifié */
@Column(name = "prochaine_calcul_interets")
private LocalDate prochaineCalculInterets;
/** Date du dernier calcul effectué */
@Column(name = "dernier_calcul_interets")
private LocalDate dernierCalculInterets;
/** Nombre de comptes traités lors du dernier calcul */
@Column(name = "dernier_nb_comptes_traites")
@Builder.Default
private Integer dernierNbComptesTraites = 0;
}

View File

@@ -0,0 +1,78 @@
package dev.lions.unionflow.server.entity.mutuelle.parts;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales;
import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "comptes_parts_sociales", indexes = {
@Index(name = "idx_cps_numero", columnList = "numero_compte", unique = true),
@Index(name = "idx_cps_membre", columnList = "membre_id"),
@Index(name = "idx_cps_org", columnList = "organisation_id")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ComptePartsSociales extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
@NotBlank
@Column(name = "numero_compte", unique = true, nullable = false, length = 50)
private String numeroCompte;
@NotNull
@Min(0)
@Column(name = "nombre_parts", nullable = false)
@Builder.Default
private Integer nombreParts = 0;
@NotNull
@Column(name = "valeur_nominale", nullable = false, precision = 19, scale = 4)
private BigDecimal valeurNominale;
/** nombreParts × valeurNominale — mis à jour à chaque transaction */
@NotNull
@Column(name = "montant_total", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal montantTotal = BigDecimal.ZERO;
@NotNull
@Column(name = "total_dividendes_recus", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal totalDividendesRecus = BigDecimal.ZERO;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 30)
@Builder.Default
private StatutComptePartsSociales statut = StatutComptePartsSociales.ACTIF;
@NotNull
@Column(name = "date_ouverture", nullable = false)
@Builder.Default
private LocalDate dateOuverture = LocalDate.now();
@Column(name = "date_derniere_operation")
private LocalDate dateDerniereOperation;
@Column(name = "notes", length = 500)
private String notes;
}

View File

@@ -0,0 +1,61 @@
package dev.lions.unionflow.server.entity.mutuelle.parts;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import dev.lions.unionflow.server.entity.BaseEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "transactions_parts_sociales", indexes = {
@Index(name = "idx_tps_compte", columnList = "compte_id"),
@Index(name = "idx_tps_date", columnList = "date_transaction")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class TransactionPartsSociales extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "compte_id", nullable = false)
private ComptePartsSociales compte;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type_transaction", nullable = false, length = 50)
private TypeTransactionPartsSociales typeTransaction;
@NotNull
@Min(1)
@Column(name = "nombre_parts", nullable = false)
private Integer nombreParts;
@NotNull
@Column(name = "montant", nullable = false, precision = 19, scale = 4)
private BigDecimal montant;
@Column(name = "solde_parts_avant", nullable = false)
@Builder.Default
private Integer soldePartsAvant = 0;
@Column(name = "solde_parts_apres", nullable = false)
@Builder.Default
private Integer soldePartsApres = 0;
@Column(name = "motif", length = 500)
private String motif;
@Column(name = "reference_externe", length = 100)
private String referenceExterne;
@NotNull
@Column(name = "date_transaction", nullable = false)
@Builder.Default
private LocalDateTime dateTransaction = LocalDateTime.now();
}

View File

@@ -98,7 +98,9 @@ public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
return exception instanceof NotFoundException return exception instanceof NotFoundException
|| exception instanceof ForbiddenException || exception instanceof ForbiddenException
|| exception instanceof NotAuthorizedException || exception instanceof NotAuthorizedException
|| exception instanceof NotAllowedException; || exception instanceof NotAllowedException
|| exception instanceof IllegalArgumentException
|| exception instanceof IllegalStateException;
} }
private int determineStatusCode(Throwable exception) { private int determineStatusCode(Throwable exception) {

View File

@@ -0,0 +1,15 @@
package dev.lions.unionflow.server.mapper.mutuelle.parts;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "cdi", builder = @org.mapstruct.Builder(disableBuilder = true))
public interface ComptePartsSocialesMapper {
@Mapping(target = "membreId", expression = "java(entity.getMembre() != null ? entity.getMembre().getId().toString() : null)")
@Mapping(target = "membreNomComplet", expression = "java(entity.getMembre() != null ? entity.getMembre().getNom() + ' ' + entity.getMembre().getPrenom() : null)")
@Mapping(target = "organisationId", expression = "java(entity.getOrganisation() != null ? entity.getOrganisation().getId().toString() : null)")
ComptePartsSocialesResponse toDto(ComptePartsSociales entity);
}

View File

@@ -0,0 +1,15 @@
package dev.lions.unionflow.server.mapper.mutuelle.parts;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "cdi", builder = @org.mapstruct.Builder(disableBuilder = true))
public interface TransactionPartsSocialesMapper {
@Mapping(target = "compteId", expression = "java(entity.getCompte() != null ? entity.getCompte().getId().toString() : null)")
@Mapping(target = "numeroCompte", expression = "java(entity.getCompte() != null ? entity.getCompte().getNumeroCompte() : null)")
@Mapping(target = "typeTransactionLibelle", expression = "java(entity.getTypeTransaction() != null ? entity.getTypeTransaction().getLibelle() : null)")
TransactionPartsSocialesResponse toDto(TransactionPartsSociales entity);
}

View File

@@ -0,0 +1,71 @@
package dev.lions.unionflow.server.payment.mtnmomo;
import dev.lions.unionflow.server.api.payment.*;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Provider MTN MoMo (stub — à implémenter avec l'API MTN Mobile Money).
*
* <p>Sandbox : https://sandbox.momodeveloper.mtn.com
* Requis : subscription-key, api-user, api-key (via provisioning sandbox).
*/
@Slf4j
@ApplicationScoped
public class MtnMomoPaymentProvider implements PaymentProvider {
public static final String CODE = "MTN_MOMO";
@ConfigProperty(name = "mtnmomo.collection.subscription-key")
Optional<String> subscriptionKeyOpt;
@ConfigProperty(name = "mtnmomo.api.base-url", defaultValue = "https://sandbox.momodeveloper.mtn.com")
String baseUrl;
String subscriptionKey;
@jakarta.annotation.PostConstruct
void init() {
subscriptionKey = subscriptionKeyOpt.orElse("");
}
@Override
public String getProviderCode() {
return CODE;
}
@Override
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
if (subscriptionKey == null || subscriptionKey.isBlank()) {
log.warn("MTN MoMo non configuré — mode mock actif pour ref={}", request.reference());
String mockId = "MTN-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
return new CheckoutSession(mockId, "https://mock.mtn.ci/pay/" + mockId,
Instant.now().plusSeconds(600), Map.of("mock", "true", "provider", CODE));
}
// TODO P1.3 Phase 3 : implémenter MTN Collection API (requestToPay)
throw new PaymentException(CODE, "MTN MoMo non encore implémenté en production", 501);
}
@Override
public PaymentStatus getStatus(String externalId) throws PaymentException {
log.warn("MTN MoMo getStatus mock pour externalId={}", externalId);
return PaymentStatus.PROCESSING;
}
@Override
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
// TODO P1.3 Phase 3 : parser callback MTN MoMo
throw new PaymentException(CODE, "Webhook MTN MoMo non encore implémenté", 501);
}
@Override
public boolean isAvailable() {
return subscriptionKey != null && !subscriptionKey.isBlank();
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.payment.orangemoney;
import dev.lions.unionflow.server.api.payment.*;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Provider Orange Money (stub — à implémenter avec l'API Orange Money WebPay).
*
* <p>Sandbox : https://developer.orange.com/apis/om-webpay
* Requis : client_id, client_secret, merchant_key par pays.
*
* <p>Retourne un mock tant que {@code orange.api.client-id} n'est pas configuré.
*/
@Slf4j
@ApplicationScoped
public class OrangeMoneyPaymentProvider implements PaymentProvider {
public static final String CODE = "ORANGE_MONEY";
@ConfigProperty(name = "orange.api.client-id")
Optional<String> clientIdOpt;
@ConfigProperty(name = "orange.api.base-url", defaultValue = "https://api.orange.com/orange-money-webpay/dev/v1")
String baseUrl;
String clientId;
@jakarta.annotation.PostConstruct
void init() {
clientId = clientIdOpt.orElse("");
}
@Override
public String getProviderCode() {
return CODE;
}
@Override
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
if (clientId == null || clientId.isBlank()) {
log.warn("Orange Money non configuré — mode mock actif pour ref={}", request.reference());
String mockId = "OM-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
return new CheckoutSession(mockId, "https://mock.orange.ci/pay/" + mockId,
Instant.now().plusSeconds(900), Map.of("mock", "true", "provider", CODE));
}
// TODO P1.3 Phase 3 : implémenter OAuth2 + POST /webpay
throw new PaymentException(CODE, "Orange Money non encore implémenté en production", 501);
}
@Override
public PaymentStatus getStatus(String externalId) throws PaymentException {
log.warn("Orange Money getStatus mock pour externalId={}", externalId);
return PaymentStatus.PROCESSING;
}
@Override
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
// TODO P1.3 Phase 3 : parser webhook Orange Money + vérifier signature
throw new PaymentException(CODE, "Webhook Orange Money non encore implémenté", 501);
}
@Override
public boolean isAvailable() {
return clientId != null && !clientId.isBlank();
}
}

View File

@@ -0,0 +1,93 @@
package dev.lions.unionflow.server.payment.orchestration;
import dev.lions.unionflow.server.api.payment.*;
import dev.lions.unionflow.server.service.PaiementService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.util.List;
/**
* Façade de paiement avec stratégie de fallback automatique.
*
* <p>Ordre de priorité :
* <ol>
* <li>PI-SPI si disponible (obligation réglementaire BCEAO)</li>
* <li>Provider demandé par le client</li>
* <li>Wave (provider par défaut)</li>
* </ol>
*/
@Slf4j
@ApplicationScoped
public class PaymentOrchestrator {
@Inject
PaymentProviderRegistry registry;
@Inject
PaiementService paiementService;
@ConfigProperty(name = "payment.default-provider", defaultValue = "WAVE")
String defaultProvider;
@ConfigProperty(name = "payment.pispi-priority", defaultValue = "false")
boolean pispiPriority;
/**
* Lance un checkout sur le provider demandé, avec fallback si indisponible.
*
* @param request la requête de checkout
* @param providerCode le provider demandé (null = provider par défaut)
*/
public CheckoutSession initierPaiement(CheckoutRequest request, String providerCode) throws PaymentException {
List<String> ordre = buildProviderOrder(providerCode);
PaymentException dernierEchec = null;
for (String code : ordre) {
PaymentProvider provider = tryGetProvider(code);
if (provider == null || !provider.isAvailable()) continue;
try {
CheckoutSession session = provider.initiateCheckout(request);
log.info("Checkout initié via {} pour ref={}", code, request.reference());
return session;
} catch (PaymentException e) {
log.warn("Provider {} échoué pour ref={}: {} — tentative fallback",
code, request.reference(), e.getMessage());
dernierEchec = e;
}
}
throw dernierEchec != null ? dernierEchec
: new PaymentException("NONE", "Aucun provider de paiement disponible", 503);
}
/**
* Traite un événement de paiement reçu via webhook.
* Délègue la mise à jour métier (souscription, cotisation...) selon la référence.
*/
public void handleEvent(PaymentEvent event) {
log.info("PaymentEvent reçu : externalId={}, ref={}, statut={}",
event.externalId(), event.reference(), event.status());
paiementService.mettreAJourStatutDepuisWebhook(event);
}
private List<String> buildProviderOrder(String requested) {
if (pispiPriority) {
if (requested != null) return List.of("PISPI", requested, defaultProvider);
return List.of("PISPI", defaultProvider);
}
if (requested != null) return List.of(requested, defaultProvider);
return List.of(defaultProvider);
}
private PaymentProvider tryGetProvider(String code) {
try {
return registry.get(code);
} catch (UnsupportedOperationException e) {
return null;
}
}
}

View File

@@ -0,0 +1,47 @@
package dev.lions.unionflow.server.payment.orchestration;
import dev.lions.unionflow.server.api.payment.PaymentProvider;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Any;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
/**
* Registry CDI des providers de paiement disponibles.
* Résout dynamiquement le bon provider par son code.
*/
@ApplicationScoped
public class PaymentProviderRegistry {
@Inject
@Any
Instance<PaymentProvider> providers;
/**
* Retourne le provider identifié par {@code code}.
*
* @throws UnsupportedOperationException si aucun provider n'est enregistré pour ce code
*/
public PaymentProvider get(String code) {
return StreamSupport.stream(providers.spliterator(), false)
.filter(p -> p.getProviderCode().equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new UnsupportedOperationException(
"Provider de paiement non supporté : " + code));
}
/** Retourne tous les providers disponibles. */
public List<PaymentProvider> getAll() {
return StreamSupport.stream(providers.spliterator(), false)
.collect(Collectors.toList());
}
/** Retourne les codes de tous les providers disponibles. */
public List<String> getAvailableCodes() {
return getAll().stream().map(PaymentProvider::getProviderCode).collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,83 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.StringReader;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Optional;
@Slf4j
@ApplicationScoped
public class PispiAuth {
@ConfigProperty(name = "pispi.api.client-id")
Optional<String> clientIdOpt;
@ConfigProperty(name = "pispi.api.client-secret")
Optional<String> clientSecretOpt;
String clientId;
String clientSecret;
@jakarta.annotation.PostConstruct
void init() {
clientId = clientIdOpt.orElse("");
clientSecret = clientSecretOpt.orElse("");
}
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
private String cachedToken;
private Instant cacheExpiry;
public synchronized String getAccessToken() throws PaymentException {
if (cachedToken != null && Instant.now().isBefore(cacheExpiry)) {
return cachedToken;
}
try {
String body = "grant_type=client_credentials"
+ "&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8)
+ "&client_secret=" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8)
+ "&scope=pispi.transactions";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/oauth2/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new PaymentException("PISPI",
"Erreur OAuth2 PI-SPI HTTP " + response.statusCode() + " : " + response.body(),
503);
}
JsonObject json = Json.createReader(new StringReader(response.body())).readObject();
cachedToken = json.getString("access_token");
int expiresIn = json.getInt("expires_in", 3600);
cacheExpiry = Instant.now().plusSeconds(expiresIn - 60);
log.debug("Token PI-SPI obtenu, expire dans {}s", expiresIn - 60);
return cachedToken;
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur OAuth2 PI-SPI : " + e.getMessage(), 503, e);
}
}
}

View File

@@ -0,0 +1,96 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Optional;
@Slf4j
@ApplicationScoped
public class PispiClient {
@Inject
PispiAuth pispiAuth;
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
@ConfigProperty(name = "pispi.institution.code")
Optional<String> institutionCodeOpt;
String institutionCode;
@jakarta.annotation.PostConstruct
void init() {
institutionCode = institutionCodeOpt.orElse("");
}
public Pacs002Response initiatePayment(Pacs008Request request) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
String xmlBody = request.toXml();
log.debug("PI-SPI initiatePayment endToEndId={}", request.getEndToEndId());
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/transactions/initiate"))
.header("Content-Type", "application/xml")
.header("Authorization", "Bearer " + token)
.header("X-Institution-Code", institutionCode)
.POST(HttpRequest.BodyPublishers.ofString(xmlBody))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status >= 400) {
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
}
return Pacs002Response.fromXml(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de l'initiation du paiement PI-SPI : " + e.getMessage(), 503, e);
}
}
public Pacs002Response getStatus(String transactionId) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
log.debug("PI-SPI getStatus transactionId={}", transactionId);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/transactions/" + transactionId))
.header("Authorization", "Bearer " + token)
.header("X-Institution-Code", institutionCode)
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status >= 400) {
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
}
return Pacs002Response.fromXml(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de la récupération du statut PI-SPI : " + e.getMessage(), 503, e);
}
}
}

View File

@@ -0,0 +1,70 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@ApplicationScoped
public class PispiIso20022Mapper {
public Pacs008Request toPacs008(CheckoutRequest req, String institutionBic) {
Pacs008Request pacs = new Pacs008Request();
pacs.setMessageId("UFMSG-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase());
pacs.setCreationDateTime(DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
pacs.setNumberOfTransactions("1");
// ISO 20022 : EndToEndId max 35 chars
String ref = req.reference();
pacs.setEndToEndId(ref.length() > 35 ? ref.substring(0, 35) : ref);
pacs.setInstrId("UFINS-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
pacs.setAmount(req.amount());
pacs.setCurrency(req.currency());
String customerName = req.metadata() != null
? req.metadata().getOrDefault("customerName", "MEMBRE UNIONFLOW")
: "MEMBRE UNIONFLOW";
pacs.setDebtorName(customerName);
pacs.setDebtorBic(institutionBic);
String creditorName = req.metadata() != null
? req.metadata().getOrDefault("creditorName", "ORGANISATION UNIONFLOW")
: "ORGANISATION UNIONFLOW";
pacs.setCreditorName(creditorName);
pacs.setCreditorBic(institutionBic);
// ISO 20022 : RemittanceInfo max 140 chars
pacs.setRemittanceInfo(ref.length() > 140 ? ref.substring(0, 140) : ref);
return pacs;
}
public PaymentStatus fromPacs002Status(String isoCode) {
return switch (isoCode) {
case "ACSC" -> PaymentStatus.SUCCESS;
case "ACSP" -> PaymentStatus.PROCESSING;
case "RJCT" -> PaymentStatus.FAILED;
case "PDNG" -> PaymentStatus.INITIATED;
default -> PaymentStatus.PROCESSING;
};
}
public PaymentEvent fromPacs002(Pacs002Response resp) {
return new PaymentEvent(
resp.getClearingSystemReference(),
resp.getOriginalEndToEndId(),
fromPacs002Status(resp.getTransactionStatus()),
null,
resp.getClearingSystemReference(),
resp.getAcceptanceDateTime() != null ? resp.getAcceptanceDateTime() : Instant.now()
);
}
}

View File

@@ -0,0 +1,114 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.CheckoutSession;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentProvider;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
/**
* Provider PI-SPI BCEAO — interopérabilité paiements instantanés UEMOA.
*
* <p>Sandbox : https://developer.pispi.bceao.int
* Spec : Business API ISO 20022 pacs.008.001.10 / pacs.002.001.14
* Deadline obligation réglementaire : 30 juin 2026
*
* <p>Mode mock automatique si {@code pispi.api.client-id} ou {@code pispi.institution.code} sont absents.
*/
@Slf4j
@ApplicationScoped
public class PispiPaymentProvider implements PaymentProvider {
public static final String CODE = "PISPI";
@Inject
PispiClient pispiClient;
@Inject
PispiIso20022Mapper mapper;
@ConfigProperty(name = "pispi.api.client-id")
java.util.Optional<String> clientIdOpt;
@ConfigProperty(name = "pispi.institution.code")
java.util.Optional<String> institutionCodeOpt;
@ConfigProperty(name = "pispi.institution.bic", defaultValue = "")
String institutionBic;
String clientId;
String institutionCode;
@jakarta.annotation.PostConstruct
void init() {
clientId = clientIdOpt.orElse("");
institutionCode = institutionCodeOpt.orElse("");
}
@Override
public String getProviderCode() {
return CODE;
}
@Override
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
if (!isConfigured()) {
String mockId = "PISPI-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
log.warn("PI-SPI non configuré — mode mock pour ref={}", request.reference());
return new CheckoutSession(
mockId,
"https://mock.pispi.bceao.int/pay/" + mockId,
Instant.now().plusSeconds(1800),
Map.of("mock", "true", "provider", CODE)
);
}
Pacs008Request pacs008 = mapper.toPacs008(request, institutionBic);
Pacs002Response pacs002 = pispiClient.initiatePayment(pacs008);
String externalId = pacs002.getClearingSystemReference() != null
? pacs002.getClearingSystemReference()
: pacs008.getEndToEndId();
return new CheckoutSession(
externalId,
null,
Instant.now().plusSeconds(1800),
Map.of("provider", CODE, "iso", "pacs.008.001.10", "endToEndId", pacs008.getEndToEndId())
);
}
@Override
public PaymentStatus getStatus(String externalId) throws PaymentException {
if (!isConfigured()) {
log.warn("PI-SPI non configuré — getStatus mock pour id={}", externalId);
return PaymentStatus.PROCESSING;
}
Pacs002Response pacs002 = pispiClient.getStatus(externalId);
return mapper.fromPacs002Status(pacs002.getTransactionStatus());
}
@Override
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
// Les webhooks PI-SPI passent par PispiWebhookResource qui valide l'IP et la signature en amont
throw new PaymentException(CODE, "Utiliser /api/pispi/webhook directement", 400);
}
@Override
public boolean isAvailable() {
return isConfigured();
}
private boolean isConfigured() {
return clientId != null && !clientId.isBlank()
&& institutionCode != null && !institutionCode.isBlank();
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Map;
import java.util.Optional;
@ApplicationScoped
public class PispiSignatureVerifier {
@ConfigProperty(name = "pispi.webhook.secret")
Optional<String> webhookSecretOpt;
@ConfigProperty(name = "pispi.webhook.allowed-ips")
Optional<String> allowedIpsOpt;
String webhookSecret;
String allowedIps;
@jakarta.annotation.PostConstruct
void init() {
webhookSecret = webhookSecretOpt.orElse("");
allowedIps = allowedIpsOpt.orElse("");
}
public boolean isIpAllowed(String ip) {
if (allowedIps == null || allowedIps.isBlank()) {
return true;
}
return Arrays.asList(allowedIps.split(",")).stream()
.map(String::trim)
.anyMatch(allowed -> allowed.equals(ip));
}
public boolean verifySignature(String rawBody, Map<String, String> headers) throws PaymentException {
if (webhookSecret == null || webhookSecret.isBlank()) {
return true;
}
// Recherche insensible à la casse
String receivedSignature = headers.entrySet().stream()
.filter(e -> "X-PISPI-Signature".equalsIgnoreCase(e.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
if (receivedSignature == null) {
throw new PaymentException("PISPI", "Signature PI-SPI absente", 401);
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256"));
String computed = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes()));
if (!MessageDigest.isEqual(computed.getBytes(), receivedSignature.getBytes())) {
throw new PaymentException("PISPI", "Signature PI-SPI invalide", 401);
}
return true;
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de la vérification de signature : " + e.getMessage(), 500, e);
}
}
}

View File

@@ -0,0 +1,71 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Path("/api/pispi/webhook")
public class PispiWebhookResource {
@Inject
PispiSignatureVerifier verifier;
@Inject
PispiIso20022Mapper mapper;
@Inject
PaymentOrchestrator orchestrator;
@POST
@Consumes("application/xml")
@PermitAll
public Response recevoir(
String rawXmlBody,
@Context HttpHeaders headers,
@HeaderParam("X-Forwarded-For") @DefaultValue("") String forwardedFor) {
String clientIp = forwardedFor.isBlank() ? "unknown" : forwardedFor.split(",")[0].trim();
if (!verifier.isIpAllowed(clientIp)) {
log.warn("PI-SPI webhook refusé — IP non autorisée : {}", clientIp);
return Response.status(403).entity("IP non autorisée").build();
}
Map<String, String> headersMap = headers.getRequestHeaders().entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));
try {
verifier.verifySignature(rawXmlBody, headersMap);
} catch (PaymentException e) {
log.warn("PI-SPI webhook — échec vérification signature : {}", e.getMessage());
return Response.status(401).entity(e.getMessage()).build();
}
try {
Pacs002Response pacs002 = Pacs002Response.fromXml(rawXmlBody);
PaymentEvent event = mapper.fromPacs002(pacs002);
orchestrator.handleEvent(event);
log.info("PI-SPI webhook traité : ref={}, statut={}", event.reference(), event.status());
return Response.ok().build();
} catch (Exception e) {
log.error("PI-SPI webhook — erreur traitement : {}", e.getMessage(), e);
return Response.serverError().entity("Erreur interne").build();
}
}
}

View File

@@ -0,0 +1,79 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.time.Instant;
public class Pacs002Response {
private String originalMessageId;
private String originalEndToEndId;
private String transactionStatus;
private String rejectReasonCode;
private String clearingSystemReference;
private Instant acceptanceDateTime;
public Pacs002Response() {}
public String getOriginalMessageId() { return originalMessageId; }
public void setOriginalMessageId(String originalMessageId) { this.originalMessageId = originalMessageId; }
public String getOriginalEndToEndId() { return originalEndToEndId; }
public void setOriginalEndToEndId(String originalEndToEndId) { this.originalEndToEndId = originalEndToEndId; }
public String getTransactionStatus() { return transactionStatus; }
public void setTransactionStatus(String transactionStatus) { this.transactionStatus = transactionStatus; }
public String getRejectReasonCode() { return rejectReasonCode; }
public void setRejectReasonCode(String rejectReasonCode) { this.rejectReasonCode = rejectReasonCode; }
public String getClearingSystemReference() { return clearingSystemReference; }
public void setClearingSystemReference(String clearingSystemReference) { this.clearingSystemReference = clearingSystemReference; }
public Instant getAcceptanceDateTime() { return acceptanceDateTime; }
public void setAcceptanceDateTime(Instant acceptanceDateTime) { this.acceptanceDateTime = acceptanceDateTime; }
public static Pacs002Response fromXml(String xml) {
Pacs002Response response = new Pacs002Response();
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
// Désactiver les entités externes (OWASP XXE)
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new InputSource(new StringReader(xml)));
doc.getDocumentElement().normalize();
response.setOriginalEndToEndId(firstText(doc, "OrgnlEndToEndId"));
response.setTransactionStatus(firstText(doc, "TxSts"));
response.setRejectReasonCode(firstText(doc, "RsnCd"));
response.setClearingSystemReference(firstText(doc, "ClrSysRef"));
String acptDtTm = firstText(doc, "AccptncDtTm");
if (acptDtTm != null && !acptDtTm.isBlank()) {
try {
response.setAcceptanceDateTime(Instant.parse(acptDtTm));
} catch (Exception ignored) {
// format non parsable — on laisse null
}
}
} catch (Exception e) {
throw new IllegalArgumentException("Impossible de parser le pacs.002 XML : " + e.getMessage(), e);
}
return response;
}
private static String firstText(Document doc, String tagName) {
NodeList nodes = doc.getElementsByTagName(tagName);
if (nodes.getLength() > 0) {
String text = nodes.item(0).getTextContent();
return (text == null || text.isBlank()) ? null : text.trim();
}
return null;
}
}

View File

@@ -0,0 +1,96 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import java.math.BigDecimal;
public class Pacs008Request {
private String messageId;
private String creationDateTime;
private String numberOfTransactions;
private String endToEndId;
private String instrId;
private BigDecimal amount;
private String currency;
private String debtorName;
private String debtorBic;
private String creditorName;
private String creditorBic;
private String creditorIban;
private String remittanceInfo;
public Pacs008Request() {}
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getCreationDateTime() { return creationDateTime; }
public void setCreationDateTime(String creationDateTime) { this.creationDateTime = creationDateTime; }
public String getNumberOfTransactions() { return numberOfTransactions; }
public void setNumberOfTransactions(String numberOfTransactions) { this.numberOfTransactions = numberOfTransactions; }
public String getEndToEndId() { return endToEndId; }
public void setEndToEndId(String endToEndId) { this.endToEndId = endToEndId; }
public String getInstrId() { return instrId; }
public void setInstrId(String instrId) { this.instrId = instrId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getDebtorName() { return debtorName; }
public void setDebtorName(String debtorName) { this.debtorName = debtorName; }
public String getDebtorBic() { return debtorBic; }
public void setDebtorBic(String debtorBic) { this.debtorBic = debtorBic; }
public String getCreditorName() { return creditorName; }
public void setCreditorName(String creditorName) { this.creditorName = creditorName; }
public String getCreditorBic() { return creditorBic; }
public void setCreditorBic(String creditorBic) { this.creditorBic = creditorBic; }
public String getCreditorIban() { return creditorIban; }
public void setCreditorIban(String creditorIban) { this.creditorIban = creditorIban; }
public String getRemittanceInfo() { return remittanceInfo; }
public void setRemittanceInfo(String remittanceInfo) { this.remittanceInfo = remittanceInfo; }
public String toXml() {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pacs.008.001.10\">\n" +
" <FIToFICstmrCdtTrf>\n" +
" <GrpHdr>\n" +
" <MsgId>" + escape(messageId) + "</MsgId>\n" +
" <CreDtTm>" + escape(creationDateTime) + "</CreDtTm>\n" +
" <NbOfTxs>1</NbOfTxs>\n" +
" </GrpHdr>\n" +
" <CdtTrfTxInf>\n" +
" <PmtId>\n" +
" <InstrId>" + escape(instrId) + "</InstrId>\n" +
" <EndToEndId>" + escape(endToEndId) + "</EndToEndId>\n" +
" </PmtId>\n" +
" <IntrBkSttlmAmt Ccy=\"" + escape(currency) + "\">" + (amount != null ? amount.toPlainString() : "0") + "</IntrBkSttlmAmt>\n" +
" <Dbtr><Nm>" + escape(debtorName) + "</Nm></Dbtr>\n" +
" <DbtrAgt><FinInstnId><BICFI>" + escape(debtorBic) + "</BICFI></FinInstnId></DbtrAgt>\n" +
" <Cdtr><Nm>" + escape(creditorName) + "</Nm></Cdtr>\n" +
" <CdtrAgt><FinInstnId><BICFI>" + escape(creditorBic) + "</BICFI></FinInstnId></CdtrAgt>\n" +
" <RmtInf><Ustrd>" + escape(remittanceInfo) + "</Ustrd></RmtInf>\n" +
" </CdtTrfTxInf>\n" +
" </FIToFICstmrCdtTrf>\n" +
"</Document>";
}
private static String escape(String value) {
if (value == null) return "";
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
}

View File

@@ -0,0 +1,140 @@
package dev.lions.unionflow.server.payment.wave;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.lions.unionflow.server.api.payment.*;
import dev.lions.unionflow.server.service.WaveCheckoutService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
/**
* Implémentation Wave de PaymentProvider.
*
* <p>Délègue la création de session à {@link WaveCheckoutService} existant.
* Normalise les webhooks Wave vers {@link PaymentEvent}.
*/
@Slf4j
@ApplicationScoped
public class WavePaymentProvider implements PaymentProvider {
public static final String CODE = "WAVE";
@Inject
WaveCheckoutService waveCheckoutService;
@ConfigProperty(name = "wave.webhook.secret", defaultValue = "")
String webhookSecret;
private final ObjectMapper mapper = new ObjectMapper();
@Override
public String getProviderCode() {
return CODE;
}
@Override
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
try {
String amount = request.amount().toBigInteger().toString();
WaveCheckoutService.WaveCheckoutSessionResponse resp = waveCheckoutService.createSession(
amount,
request.currency(),
request.successUrl(),
request.cancelUrl(),
request.reference(),
request.customerPhone()
);
return new CheckoutSession(
resp.id,
resp.waveLaunchUrl,
Instant.now().plusSeconds(3600),
Map.of("provider", CODE)
);
} catch (Exception e) {
throw new PaymentException(CODE, e.getMessage(), 500, e);
}
}
@Override
public PaymentStatus getStatus(String externalId) throws PaymentException {
// Wave ne fournit pas d'API de polling — le statut passe par les webhooks.
// Un polling naïf via la session URL n'est pas supporté.
log.warn("Wave ne supporte pas le polling de statut — utiliser les webhooks.");
return PaymentStatus.PROCESSING;
}
@Override
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
verifierSignatureWave(rawBody, headers);
try {
JsonNode root = mapper.readTree(rawBody);
String type = root.path("type").asText();
JsonNode data = root.path("data");
String externalId = data.path("id").asText(null);
String clientRef = data.path("client_reference").asText(null);
String rawAmount = data.path("amount").asText("0");
BigDecimal amount = new BigDecimal(rawAmount);
PaymentStatus status = switch (type) {
case "checkout.session.completed" -> PaymentStatus.SUCCESS;
case "checkout.session.failed" -> PaymentStatus.FAILED;
case "checkout.session.expired" -> PaymentStatus.EXPIRED;
default -> PaymentStatus.PROCESSING;
};
return new PaymentEvent(
externalId,
clientRef,
status,
amount,
data.path("transaction_id").asText(null),
Instant.now()
);
} catch (Exception e) {
throw new PaymentException(CODE, "Webhook Wave malformé : " + e.getMessage(), 400, e);
}
}
private void verifierSignatureWave(String rawBody, Map<String, String> headers) throws PaymentException {
if (webhookSecret == null || webhookSecret.isBlank()) return;
String sigHeader = headers.get("wave-signature");
if (sigHeader == null) sigHeader = headers.get("Wave-Signature");
if (sigHeader == null) {
throw new PaymentException(CODE, "Signature webhook Wave absente", 401);
}
try {
String timestamp = "";
String receivedSig = "";
for (String part : sigHeader.split(",")) {
if (part.startsWith("t=")) timestamp = part.substring(2);
if (part.startsWith("v1=")) receivedSig = part.substring(3);
}
String payload = timestamp + "." + rawBody;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256"));
String computed = HexFormat.of().formatHex(mac.doFinal(payload.getBytes()));
if (!java.security.MessageDigest.isEqual(computed.getBytes(), receivedSig.getBytes())) {
throw new PaymentException(CODE, "Signature webhook Wave invalide", 401);
}
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException(CODE, "Erreur vérification signature Wave : " + e.getMessage(), 500, e);
}
}
}

View File

@@ -76,6 +76,30 @@ public class CompteComptableRepository implements PanacheRepositoryBase<CompteCo
return find("typeCompte = ?1 AND actif = true ORDER BY numeroCompte ASC", TypeCompteComptable.TRESORERIE) return find("typeCompte = ?1 AND actif = true ORDER BY numeroCompte ASC", TypeCompteComptable.TRESORERIE)
.list(); .list();
} }
/**
* Trouve un compte par organisation et numéro de compte (plan comptable tenant-scoped).
*/
public Optional<CompteComptable> findByOrganisationAndNumero(UUID organisationId, String numeroCompte) {
return find("organisation.id = ?1 AND numeroCompte = ?2 AND actif = true", organisationId, numeroCompte)
.firstResultOptional();
}
/**
* Trouve tous les comptes actifs d'une organisation.
*/
public List<CompteComptable> findByOrganisation(UUID organisationId) {
return find("organisation.id = ?1 AND actif = true ORDER BY numeroCompte ASC", organisationId).list();
}
/**
* Trouve les comptes d'une organisation par classe SYSCOHADA (1-9).
*/
public List<CompteComptable> findByOrganisationAndClasse(UUID organisationId, Integer classe) {
return find(
"organisation.id = ?1 AND classeComptable = ?2 AND actif = true ORDER BY numeroCompte ASC",
organisationId, classe).list();
}
} }

View File

@@ -105,6 +105,20 @@ public class EcritureComptableRepository implements PanacheRepositoryBase<Ecritu
public List<EcritureComptable> findByLettrage(String lettrage) { public List<EcritureComptable> findByLettrage(String lettrage) {
return find("lettrage = ?1 AND actif = true ORDER BY dateEcriture DESC", lettrage).list(); return find("lettrage = ?1 AND actif = true ORDER BY dateEcriture DESC", lettrage).list();
} }
/**
* Trouve les écritures d'une organisation dans une période (pour rapports PDF SYSCOHADA).
*/
public List<EcritureComptable> findByOrganisationAndDateRange(
UUID organisationId, LocalDate dateDebut, LocalDate dateFin) {
return find(
"organisation.id = ?1 AND dateEcriture >= ?2 AND dateEcriture <= ?3 AND actif = true"
+ " ORDER BY dateEcriture ASC, numeroPiece ASC",
organisationId,
dateDebut,
dateFin)
.list();
}
} }

View File

@@ -79,6 +79,15 @@ public class JournalComptableRepository implements PanacheRepositoryBase<Journal
public List<JournalComptable> findAllActifs() { public List<JournalComptable> findAllActifs() {
return find("actif = true ORDER BY code ASC").list(); return find("actif = true ORDER BY code ASC").list();
} }
/**
* Trouve le journal d'une organisation par type (ex: VENTES pour cotisations).
*/
public Optional<JournalComptable> findByOrganisationAndType(UUID organisationId, TypeJournalComptable type) {
return find(
"organisation.id = ?1 AND typeJournal = ?2 AND statut = 'OUVERT' AND actif = true",
organisationId, type).firstResultOptional();
}
} }

View File

@@ -0,0 +1,52 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.entity.KycDossier;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class KycDossierRepository implements PanacheRepositoryBase<KycDossier, UUID> {
public Optional<KycDossier> findDossierActifByMembre(UUID membreId) {
return find("membre.id = ?1 AND actif = true", membreId).firstResultOptional();
}
public List<KycDossier> findByMembre(UUID membreId) {
return find("membre.id = ?1 ORDER BY dateCreation DESC", membreId).list();
}
public List<KycDossier> findByStatut(StatutKyc statut) {
return find("statut = ?1 AND actif = true", statut).list();
}
public List<KycDossier> findByNiveauRisque(NiveauRisqueKyc niveauRisque) {
return find("niveauRisque = ?1 AND actif = true ORDER BY scoreRisque DESC", niveauRisque).list();
}
public List<KycDossier> findPep() {
return find("estPep = true AND actif = true").list();
}
public List<KycDossier> findPiecesExpirantsAvant(LocalDate date) {
return find("dateExpirationPiece <= ?1 AND actif = true ORDER BY dateExpirationPiece ASC", date).list();
}
public long countByStatut(StatutKyc statut) {
return count("statut = ?1 AND actif = true", statut);
}
public long countPepActifs() {
return count("estPep = true AND actif = true");
}
public List<KycDossier> findByAnnee(int anneeReference) {
return find("anneeReference = ?1", anneeReference).list();
}
}

View File

@@ -85,6 +85,13 @@ public class MembreOrganisationRepository extends BaseRepository<MembreOrganisat
return find("membre.email = ?1 and organisation.id = ?2", email, organisationId).firstResultOptional(); return find("membre.email = ?1 and organisation.id = ?2", email, organisationId).firstResultOptional();
} }
/**
* Trouve les membres ayant un rôle donné dans une organisation.
*/
public List<MembreOrganisation> findByRoleOrgAndOrganisationId(String roleOrg, UUID organisationId) {
return find("roleOrg = ?1 and organisation.id = ?2 and membre.actif = true", roleOrg, organisationId).list();
}
/** /**
* Trouve les membres en attente de validation depuis plus de N jours. * Trouve les membres en attente de validation depuis plus de N jours.
*/ */

View File

@@ -0,0 +1,16 @@
package dev.lions.unionflow.server.repository.mutuelle;
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class ParametresFinanciersMutuellRepository implements PanacheRepositoryBase<ParametresFinanciersMutuelle, UUID> {
public Optional<ParametresFinanciersMutuelle> findByOrganisation(UUID orgId) {
return find("organisation.id", orgId).firstResultOptional();
}
}

View File

@@ -0,0 +1,34 @@
package dev.lions.unionflow.server.repository.mutuelle.parts;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class ComptePartsSocialesRepository implements PanacheRepositoryBase<ComptePartsSociales, UUID> {
public Optional<ComptePartsSociales> findByNumeroCompte(String numeroCompte) {
return find("numeroCompte", numeroCompte).firstResultOptional();
}
public List<ComptePartsSociales> findByMembre(UUID membreId) {
return list("membre.id = ?1 AND actif = true", membreId);
}
public List<ComptePartsSociales> findByOrganisation(UUID orgId) {
return list("organisation.id = ?1 AND actif = true ORDER BY dateCreation DESC", orgId);
}
public Optional<ComptePartsSociales> findByMembreAndOrg(UUID membreId, UUID orgId) {
return find("membre.id = ?1 AND organisation.id = ?2 AND actif = true", membreId, orgId)
.firstResultOptional();
}
public long countByOrganisation(UUID orgId) {
return count("organisation.id = ?1 AND actif = true", orgId);
}
}

View File

@@ -0,0 +1,16 @@
package dev.lions.unionflow.server.repository.mutuelle.parts;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
@ApplicationScoped
public class TransactionPartsSocialesRepository implements PanacheRepositoryBase<TransactionPartsSociales, UUID> {
public List<TransactionPartsSociales> findByCompte(UUID compteId) {
return list("compte.id = ?1 ORDER BY dateTransaction DESC", compteId);
}
}

View File

@@ -0,0 +1,64 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService;
import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService.MigrationReport;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
/**
* Endpoints d'administration Keycloak 26 Organizations.
*
* <p>Réservés aux SUPER_ADMIN. Opérations à déclencher manuellement lors de la
* migration Keycloak 23 → 26.
*/
@Slf4j
@Path("/api/admin/keycloak")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("SUPER_ADMIN")
public class AdminKeycloakOrganisationResource {
@Inject
MigrerOrganisationsVersKeycloakService migrationService;
/**
* Lance la migration one-shot des organisations UnionFlow vers Keycloak 26 Organizations.
*
* <p>Idempotent : les organisations déjà migrées (keycloak_org_id non null) sont ignorées.
*
* @return rapport de migration (total, créés, ignorés, erreurs)
*/
@POST
@Path("/migrer-organisations")
public Response migrerOrganisations() {
log.info("Déclenchement migration organisations → Keycloak 26 Organizations");
try {
MigrationReport report = migrationService.migrerToutesLesOrganisations();
log.info("Migration terminée : {}", report);
return Response
.status(report.success() ? Response.Status.OK.getStatusCode() : 207)
.entity(Map.of(
"total", report.total(),
"crees", report.crees(),
"ignores", report.ignores(),
"erreurs", report.erreurs(),
"succes", report.success()
))
.build();
} catch (Exception e) {
log.error("Erreur critique lors de la migration : {}", e.getMessage(), e);
return Response.serverError()
.entity(Map.of("error", e.getMessage()))
.build();
}
}
}

View File

@@ -0,0 +1,98 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.service.ComptabilitePdfService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.time.LocalDate;
import java.util.UUID;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
/**
* Endpoints de téléchargement des rapports comptables PDF SYSCOHADA révisé.
*/
@Path("/api/comptabilite/pdf")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "COMMISSAIRE_COMPTES", "SUPER_ADMIN"})
@Tag(name = "Comptabilité PDF", description = "Rapports comptables SYSCOHADA : balance, compte de résultat, grand livre")
public class ComptabilitePdfResource {
@Inject
ComptabilitePdfService comptabilitePdfService;
@GET
@Path("/organisations/{organisationId}/balance")
@Produces("application/pdf")
@Operation(summary = "Balance générale SYSCOHADA",
description = "Génère la balance générale (cumul débit/crédit/solde) pour la période.")
public Response balance(
@PathParam("organisationId") UUID organisationId,
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
LocalDate dateFin = parseDateOrToday(dateFinStr);
byte[] pdf = comptabilitePdfService.genererBalance(organisationId, dateDebut, dateFin);
return buildPdfResponse(pdf, "balance_" + organisationId + ".pdf");
}
@GET
@Path("/organisations/{organisationId}/compte-de-resultat")
@Produces("application/pdf")
@Operation(summary = "Compte de résultat SYSCOHADA",
description = "Génère le compte de résultat (produits classes 7/8 charges classes 6/8).")
public Response compteDeResultat(
@PathParam("organisationId") UUID organisationId,
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
LocalDate dateFin = parseDateOrToday(dateFinStr);
byte[] pdf = comptabilitePdfService.genererCompteResultat(organisationId, dateDebut, dateFin);
return buildPdfResponse(pdf, "compte_resultat_" + organisationId + ".pdf");
}
@GET
@Path("/organisations/{organisationId}/grand-livre/{numeroCompte}")
@Produces("application/pdf")
@Operation(summary = "Grand livre d'un compte SYSCOHADA",
description = "Génère le grand livre (détail chronologique) pour un compte comptable donné.")
public Response grandLivre(
@PathParam("organisationId") UUID organisationId,
@PathParam("numeroCompte") String numeroCompte,
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
LocalDate dateFin = parseDateOrToday(dateFinStr);
byte[] pdf = comptabilitePdfService.genererGrandLivre(organisationId, numeroCompte, dateDebut, dateFin);
return buildPdfResponse(pdf, "grand_livre_" + numeroCompte + ".pdf");
}
private static Response buildPdfResponse(byte[] pdf, String filename) {
return Response.ok(pdf)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.header("Content-Length", pdf.length)
.build();
}
private static LocalDate parseDateOrStartOfYear(String s) {
if (s == null || s.isBlank()) return LocalDate.of(LocalDate.now().getYear(), 1, 1);
try { return LocalDate.parse(s); } catch (Exception e) {
throw new BadRequestException("Format de date invalide (attendu : YYYY-MM-DD) : " + s);
}
}
private static LocalDate parseDateOrToday(String s) {
if (s == null || s.isBlank()) return LocalDate.now();
try { return LocalDate.parse(s); } catch (Exception e) {
throw new BadRequestException("Format de date invalide (attendu : YYYY-MM-DD) : " + s);
}
}
}

View File

@@ -1,6 +1,7 @@
package dev.lions.unionflow.server.resource; package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.membre.CompteAdherentResponse; import dev.lions.unionflow.server.api.dto.membre.CompteAdherentResponse;
import dev.lions.unionflow.server.service.FirebasePushService;
import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.MembreOrganisation; import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.entity.SouscriptionOrganisation; import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
@@ -67,6 +68,59 @@ public class CompteAdherentResource {
@Inject @Inject
MembreService membreService; MembreService membreService;
@Inject
FirebasePushService firebasePushService;
/**
* Enregistre ou met à jour le token FCM du membre connecté pour les notifications push.
* Appelé par l'application mobile au démarrage ou quand Firebase renouvelle le token.
*/
@PUT
@Path("/mon-compte/fcm-token")
@Authenticated
@Operation(summary = "Enregistrer le token FCM pour les notifications push")
@jakarta.transaction.Transactional
public Response enregistrerFcmToken(Map<String, String> body) {
String email = securiteHelper.resolveEmail();
if (email == null) return Response.status(Response.Status.UNAUTHORIZED).build();
String token = body != null ? body.get("token") : null;
if (token == null || token.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", "Le champ 'token' est requis.")).build();
}
return membreRepository.findByEmail(email)
.map(membre -> {
membre.setFcmToken(token.trim());
membreRepository.persist(membre);
return Response.ok(Map.of("message", "Token FCM enregistré.")).build();
})
.orElse(Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("message", "Membre introuvable.")).build());
}
/**
* Supprime le token FCM (désabonnement des notifications push).
*/
@DELETE
@Path("/mon-compte/fcm-token")
@Authenticated
@Operation(summary = "Désactiver les notifications push")
@jakarta.transaction.Transactional
public Response supprimerFcmToken() {
String email = securiteHelper.resolveEmail();
if (email == null) return Response.status(Response.Status.UNAUTHORIZED).build();
return membreRepository.findByEmail(email)
.map(membre -> {
membre.setFcmToken(null);
membreRepository.persist(membre);
return Response.ok(Map.of("message", "Notifications push désactivées.")).build();
})
.orElse(Response.status(Response.Status.NOT_FOUND).build());
}
/** /**
* Retourne le compte adhérent complet du membre connecté : * Retourne le compte adhérent complet du membre connecté :
* numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement. * numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement.
@@ -138,15 +192,17 @@ public class CompteAdherentResource {
} }
} }
// Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a souscription active // Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a reçu un paiement.
// (membres sans premiereConnexion=true ou créés avant cette logique) // Couvre le cas PAIEMENT_CONFIRME (admin a payé mais super admin n'a pas encore validé)
// et ACTIVE/VALIDEE (chemin nominal). L'admin ne doit pas bloquer sur l'AwaitingValidationPage
// dès lors que le paiement est confirmé côté Wave.
if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) { if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) {
Membre m = membreOpt.get(); Membre m = membreOpt.get();
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId()) UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
.map(mo -> mo.getOrganisation().getId()) .map(mo -> mo.getOrganisation().getId())
.orElse(null); .orElse(null);
if (membreService.orgHasActiveSubscription(orgId)) { if (membreService.orgHasPaidSubscription(orgId)) {
LOG.infof("Auto-activation au login de %s (org %s a souscription active)", m.getEmail(), orgId); LOG.infof("Auto-activation au login de %s (org %s a souscription payée)", m.getEmail(), orgId);
membreService.activerMembre(m.getId()); membreService.activerMembre(m.getId());
try { try {
membreKeycloakSyncService.activerMembreDansKeycloak(m.getId()); membreKeycloakSyncService.activerMembreDansKeycloak(m.getId());

View File

@@ -0,0 +1,111 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest;
import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse;
import dev.lions.unionflow.server.service.KycAmlService;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Endpoints KYC/AML — gestion des dossiers d'identification et évaluation risque LCB-FT.
*/
@Path("/api/kyc")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class KycResource {
@Inject
KycAmlService kycAmlService;
@Inject
SecurityIdentity identity;
/** Soumet ou met à jour un dossier KYC pour un membre. */
@POST
@Path("/dossiers")
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
public Response soumettre(@Valid KycDossierRequest request) {
KycDossierResponse response = kycAmlService.soumettreOuMettreAJour(request, identity.getPrincipal().getName());
return Response.status(Response.Status.CREATED).entity(response).build();
}
/** Récupère le dossier KYC actif d'un membre. */
@GET
@Path("/membres/{membreId}")
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
public Response getDossierActif(@PathParam("membreId") UUID membreId) {
return kycAmlService.getDossierActif(membreId)
.map(d -> Response.ok(d).build())
.orElse(Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Aucun dossier KYC actif pour ce membre."))
.build());
}
/** Évalue le score de risque LCB-FT du membre. */
@POST
@Path("/membres/{membreId}/evaluer-risque")
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
public Response evaluerRisque(@PathParam("membreId") UUID membreId) {
KycDossierResponse response = kycAmlService.evaluerRisque(membreId);
return Response.ok(response).build();
}
/** Valide manuellement un dossier KYC (agent habilité). */
@POST
@Path("/dossiers/{dossierId}/valider")
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
public Response valider(
@PathParam("dossierId") UUID dossierId,
@QueryParam("validateurId") UUID validateurId,
@QueryParam("notes") String notes) {
KycDossierResponse response = kycAmlService.valider(
dossierId, validateurId, notes, identity.getPrincipal().getName());
return Response.ok(response).build();
}
/** Refuse un dossier KYC avec motif. */
@POST
@Path("/dossiers/{dossierId}/refuser")
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
public Response refuser(
@PathParam("dossierId") UUID dossierId,
@QueryParam("validateurId") UUID validateurId,
@QueryParam("motif") String motif) {
KycDossierResponse response = kycAmlService.refuser(
dossierId, validateurId, motif, identity.getPrincipal().getName());
return Response.ok(response).build();
}
/** Liste les dossiers KYC en attente de validation. */
@GET
@Path("/dossiers/en-attente")
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
public List<KycDossierResponse> getDossiersEnAttente() {
return kycAmlService.getDossiersEnAttente();
}
/** Liste les membres PEP (Personnes Exposées Politiquement). */
@GET
@Path("/pep")
@RolesAllowed({"SUPER_ADMIN"})
public List<KycDossierResponse> getPep() {
return kycAmlService.getDossiersPep();
}
/** Pièces d'identité expirant dans les 30 jours. */
@GET
@Path("/pieces-expirant-bientot")
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION", "TRESORIER"})
public List<KycDossierResponse> getPiecesExpirant() {
return kycAmlService.getPiecesExpirantDansLes30Jours();
}
}

View File

@@ -11,6 +11,7 @@ import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.MembreOrganisation; import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository; import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MembreRoleRepository; import dev.lions.unionflow.server.repository.MembreRoleRepository;
import dev.lions.unionflow.server.service.MemberLifecycleService; import dev.lions.unionflow.server.service.MemberLifecycleService;
import dev.lions.unionflow.server.service.MembreKeycloakSyncService; import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
@@ -78,6 +79,9 @@ public class MembreResource {
@Inject @Inject
MembreOrganisationRepository membreOrgRepository; MembreOrganisationRepository membreOrgRepository;
@Inject
MembreRepository membreRepository;
@Inject @Inject
MembreRoleRepository membreRoleRepository; MembreRoleRepository membreRoleRepository;
@@ -447,6 +451,40 @@ public class MembreResource {
} }
} }
/**
* Liste TOUS les membres (y compris EN_ATTENTE_VALIDATION) — réservé SUPER_ADMIN.
* Utile pour les imports de données historiques et la gestion admin.
*/
@GET
@Path("/admin/tous")
@RolesAllowed({ "SUPER_ADMIN" })
@Operation(summary = "Tous les membres (admin)", description = "Liste tous les membres quelque soit leur statut, réservé SUPER_ADMIN")
@APIResponse(responseCode = "200", description = "Liste complète des membres")
public Response getTousMembres(
@Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page,
@Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("100") int size) {
try {
LOG.infof("GET /api/membres/admin/tous - page=%d size=%d", page, size);
List<Membre> membres = membreRepository.findAll(
io.quarkus.panache.common.Sort.by("nom").ascending())
.page(io.quarkus.panache.common.Page.of(page, size))
.list();
List<MembreResponse> membresDTO = membreService.convertToResponseList(membres);
long total = membreRepository.count();
return Response.ok(Map.of(
"data", membresDTO,
"totalElements", total,
"page", page,
"size", size,
"totalPages", (int) Math.ceil((double) total / size)
)).build();
} catch (Exception e) {
LOG.errorf(e, "Erreur récupération tous membres");
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage())).build();
}
}
/** /**
* Liste les membres d'une organisation spécifique (statut ACTIF dans l'organisation). * Liste les membres d'une organisation spécifique (statut ACTIF dans l'organisation).
* Utilisé pour la création de campagnes ciblées. * Utilisé pour la création de campagnes ciblées.
@@ -588,7 +626,7 @@ public class MembreResource {
@APIResponses({ @APIResponses({
@APIResponse(responseCode = "200", description = "Recherche effectuée avec succès", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = MembreSearchResultDTO.class), examples = @ExampleObject(name = "Exemple de résultats", value = """ @APIResponse(responseCode = "200", description = "Recherche effectuée avec succès", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = MembreSearchResultDTO.class), examples = @ExampleObject(name = "Exemple de résultats", value = """
{ {
"membres": [...], "membres": [],
"totalElements": 247, "totalElements": 247,
"totalPages": 13, "totalPages": 13,
"currentPage": 0, "currentPage": 0,

View File

@@ -0,0 +1,139 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.payment.*;
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator;
import dev.lions.unionflow.server.payment.orchestration.PaymentProviderRegistry;
import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Endpoints de paiement unifiés — abstraction multi-provider.
* Remplace à terme les endpoints Wave-spécifiques.
*/
@Slf4j
@Path("/api/paiements")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PaiementUnifieResource {
@Inject
PaymentOrchestrator orchestrator;
@Inject
PaymentProviderRegistry registry;
@Inject
SouscriptionOrganisationRepository souscriptionRepository;
/**
* Initie un paiement via le provider demandé (ou le provider par défaut).
*
* <p>Exemple : {@code POST /api/paiements/initier?provider=WAVE}
*/
@POST
@Path("/initier")
@RolesAllowed({"MEMBRE_ACTIF", "ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
public Response initier(
@QueryParam("provider") String provider,
PaiementInitierRequest req) {
try {
// Si une souscription est fournie, utiliser le providerDefaut de sa formule
String resolvedProvider = provider;
if (req.souscriptionId() != null) {
resolvedProvider = souscriptionRepository.findByIdOptional(req.souscriptionId())
.map(SouscriptionOrganisation::getFormule)
.map(f -> f.getProviderDefaut())
.filter(p -> p != null && !p.isBlank())
.orElse(provider);
}
CheckoutRequest checkoutRequest = new CheckoutRequest(
req.montant(),
req.devise() != null ? req.devise() : "XOF",
req.telephone(),
req.email(),
req.reference(),
req.successUrl(),
req.cancelUrl(),
Map.of()
);
CheckoutSession session = orchestrator.initierPaiement(checkoutRequest, resolvedProvider);
return Response.ok(session).build();
} catch (PaymentException e) {
return Response.status(e.getHttpStatus())
.entity(Map.of("error", e.getMessage(), "provider", e.getProviderCode()))
.build();
}
}
/**
* Webhook entrant d'un provider. Vérifie la signature et met à jour le statut.
* Route : {@code POST /api/paiements/webhook/{provider}}
*/
@POST
@Path("/webhook/{provider}")
@PermitAll
@Consumes(MediaType.WILDCARD)
public Response webhook(
@PathParam("provider") String providerCode,
String rawBody,
@Context HttpHeaders httpHeaders) {
try {
PaymentProvider provider = registry.get(providerCode.toUpperCase());
Map<String, String> headers = httpHeaders.getRequestHeaders().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().isEmpty() ? "" : e.getValue().get(0)
));
PaymentEvent event = provider.processWebhook(rawBody, headers);
orchestrator.handleEvent(event);
return Response.ok().build();
} catch (UnsupportedOperationException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Provider inconnu : " + providerCode))
.build();
} catch (PaymentException e) {
log.error("Webhook {} rejeté : {}", providerCode, e.getMessage());
return Response.status(e.getHttpStatus())
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/** Retourne les providers de paiement disponibles. */
@GET
@Path("/providers")
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
public List<String> getProviders() {
return registry.getAvailableCodes();
}
public record PaiementInitierRequest(
BigDecimal montant,
String devise,
String telephone,
String email,
String reference,
String successUrl,
String cancelUrl,
/** Optionnel — si fourni, le providerDefaut de la formule prend le dessus sur le query param. */
UUID souscriptionId
) {}
}

View File

@@ -0,0 +1,52 @@
package dev.lions.unionflow.server.resource.mutuelle;
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse;
import dev.lions.unionflow.server.security.RequiresModule;
import dev.lions.unionflow.server.service.mutuelle.InteretsEpargneService;
import dev.lions.unionflow.server.service.mutuelle.ParametresFinanciersService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
import java.util.UUID;
@Path("/api/v1/mutuelle/parametres-financiers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiresModule("EPARGNE")
public class ParametresFinanciersResource {
@Inject ParametresFinanciersService parametresService;
@Inject InteretsEpargneService interetsService;
@GET
@Path("/{orgId}")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
public Response getByOrganisation(@PathParam("orgId") UUID orgId) {
return Response.ok(parametresService.getByOrganisation(orgId)).build();
}
@POST
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
public Response creerOuMettrAJour(@Valid ParametresFinanciersMutuellRequest request) {
ParametresFinanciersMutuellResponse resp = parametresService.creerOuMettrAJour(request);
return Response.ok(resp).build();
}
/**
* Déclenche manuellement le calcul des intérêts / dividendes pour une organisation.
* Utile pour régularisation ou test.
*/
@POST
@Path("/{orgId}/calculer-interets")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
public Response calculerInterets(@PathParam("orgId") UUID orgId) {
Map<String, Object> result = interetsService.calculerManuellement(orgId);
return Response.ok(result).build();
}
}

View File

@@ -0,0 +1,74 @@
package dev.lions.unionflow.server.resource.mutuelle;
import dev.lions.unionflow.server.security.RequiresModule;
import dev.lions.unionflow.server.service.mutuelle.ReleveComptePdfService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder;
import java.time.LocalDate;
import java.util.UUID;
/**
* Relevés de compte en PDF.
* - GET /api/v1/releves/epargne/{compteId} → relevé épargne
* - GET /api/v1/releves/parts-sociales/{compteId} → relevé parts sociales
*/
@Path("/api/v1/releves")
@RequiresModule("EPARGNE")
public class ReleveCompteResource {
@Inject ReleveComptePdfService releveService;
@GET
@Path("/epargne/{compteId}")
@Produces("application/pdf")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response releveEpargne(
@PathParam("compteId") UUID compteId,
@QueryParam("dateDebut") String dateDebutStr,
@QueryParam("dateFin") String dateFinStr) {
LocalDate dateDebut = parseDate(dateDebutStr);
LocalDate dateFin = parseDate(dateFinStr);
byte[] pdf = releveService.genererReleveEpargne(compteId, dateDebut, dateFin);
ResponseBuilder rb = Response.ok(pdf);
rb.header("Content-Disposition",
"attachment; filename=\"releve-epargne-" + compteId + ".pdf\"");
rb.header("Content-Type", MediaType.valueOf("application/pdf"));
return rb.build();
}
@GET
@Path("/parts-sociales/{compteId}")
@Produces("application/pdf")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response releveParts(
@PathParam("compteId") UUID compteId,
@QueryParam("dateDebut") String dateDebutStr,
@QueryParam("dateFin") String dateFinStr) {
LocalDate dateDebut = parseDate(dateDebutStr);
LocalDate dateFin = parseDate(dateFinStr);
byte[] pdf = releveService.genererReleveParts(compteId, dateDebut, dateFin);
ResponseBuilder rb = Response.ok(pdf);
rb.header("Content-Disposition",
"attachment; filename=\"releve-parts-" + compteId + ".pdf\"");
rb.header("Content-Type", MediaType.valueOf("application/pdf"));
return rb.build();
}
private LocalDate parseDate(String s) {
if (s == null || s.isBlank()) return null;
try {
return LocalDate.parse(s);
} catch (Exception e) {
throw new IllegalArgumentException("Format de date invalide. Utilisez YYYY-MM-DD. Valeur reçue: " + s);
}
}
}

View File

@@ -11,6 +11,7 @@ import jakarta.validation.Valid;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import io.quarkus.security.identity.SecurityIdentity;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -24,10 +25,16 @@ public class TransactionEpargneResource {
@Inject @Inject
TransactionEpargneService transactionEpargneService; TransactionEpargneService transactionEpargneService;
@Inject
SecurityIdentity securityIdentity;
@POST @POST
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER" }) @RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER" })
public Response executerTransaction(@Valid TransactionEpargneRequest request) { public Response executerTransaction(
TransactionEpargneResponse transaction = transactionEpargneService.executerTransaction(request); @Valid TransactionEpargneRequest request,
@QueryParam("historique") @DefaultValue("false") boolean historique) {
boolean bypassSolde = historique && securityIdentity.hasRole("SUPER_ADMIN");
TransactionEpargneResponse transaction = transactionEpargneService.executerTransaction(request, bypassSolde);
return Response.status(Response.Status.CREATED).entity(transaction).build(); return Response.status(Response.Status.CREATED).entity(transaction).build();
} }

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.resource.mutuelle.parts;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
import dev.lions.unionflow.server.security.RequiresModule;
import dev.lions.unionflow.server.service.mutuelle.parts.ComptePartsSocialesService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
@Path("/api/v1/parts-sociales/comptes")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiresModule("EPARGNE")
public class ComptePartsSocialesResource {
@Inject
ComptePartsSocialesService service;
@POST
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
public Response ouvrirCompte(@Valid ComptePartsSocialesRequest request) {
ComptePartsSocialesResponse resp = service.ouvrirCompte(request);
return Response.status(Response.Status.CREATED).entity(resp).build();
}
@POST
@Path("/transactions")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
public Response enregistrerTransaction(@Valid TransactionPartsSocialesRequest request) {
TransactionPartsSocialesResponse resp = service.enregistrerSouscription(request);
return Response.status(Response.Status.CREATED).entity(resp).build();
}
@GET
@Path("/{id}")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response getById(@PathParam("id") UUID id) {
return Response.ok(service.getById(id)).build();
}
@GET
@Path("/membre/{membreId}")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response getByMembre(@PathParam("membreId") UUID membreId) {
List<ComptePartsSocialesResponse> list = service.getByMembre(membreId);
return Response.ok(list).build();
}
@GET
@Path("/organisation/{orgId}")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
public Response getByOrganisation(@PathParam("orgId") UUID orgId) {
List<ComptePartsSocialesResponse> list = service.getByOrganisation(orgId);
return Response.ok(list).build();
}
@GET
@Path("/{id}/transactions")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response getTransactions(@PathParam("id") UUID id) {
List<TransactionPartsSocialesResponse> list = service.getTransactions(id);
return Response.ok(list).build();
}
}

View File

@@ -0,0 +1,116 @@
package dev.lions.unionflow.server.security;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import io.smallrye.jwt.auth.principal.JWTParser;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.ForbiddenException;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
import java.util.Optional;
import java.util.UUID;
/**
* Résout l'organisation active depuis le claim {@code organization} du JWT Keycloak 26.
*
* <p>Keycloak 26 Organizations injecte dans le token un claim de la forme :
* <pre>
* "organization": {
* "mutuelle-gbane": { "id": "uuid-kc-org", "name": "Mutuelle GBANE", "alias": "mutuelle-gbane" }
* }
* </pre>
*
* <p>Ce bean remplace progressivement {@link OrganisationContextFilter} (header-based).
* Pendant la période de transition, le filtre header reste actif — ce resolver est
* utilisé en complément par les endpoints qui lisent explicitement le claim JWT.
*
* <p>Un token scopé à une seule organization → résolution directe.
* Un token multi-org sans scoping → exception (le client doit re-authentifier avec scoping).
*/
@ApplicationScoped
public class OrganisationContextResolver {
private static final Logger LOG = Logger.getLogger(OrganisationContextResolver.class);
@Inject
JsonWebToken jwt;
@Inject
OrganisationRepository organisationRepository;
/**
* Résout l'UUID UnionFlow de l'organisation active depuis le claim JWT {@code organization}.
*
* @throws BadRequestException si le token est multi-org sans scoping ou si le claim manque
* @throws ForbiddenException si aucune organisation UnionFlow ne correspond au keycloak_org_id
*/
public UUID resolveOrganisationId() {
var orgClaim = jwt.<java.util.Map<String, Object>>getClaim("organization");
if (orgClaim == null || orgClaim.isEmpty()) {
throw new BadRequestException(
"Token JWT sans claim 'organization' — connectez-vous dans le contexte d'une organisation.");
}
if (orgClaim.size() > 1) {
throw new BadRequestException(
"Token multi-organisation non scopé. Ré-authentifiez-vous avec l'organisation cible.");
}
// Single-org token : prendre la première (et seule) entrée
var entry = orgClaim.entrySet().iterator().next().getValue();
String kcOrgIdStr = extractId(entry);
if (kcOrgIdStr == null) {
LOG.warnf("Claim organization sans champ 'id' : %s", entry);
throw new BadRequestException("Claim 'organization' malformé — champ 'id' manquant.");
}
UUID kcOrgId;
try {
kcOrgId = UUID.fromString(kcOrgIdStr);
} catch (IllegalArgumentException e) {
throw new BadRequestException("Claim organization.id n'est pas un UUID valide : " + kcOrgIdStr);
}
Optional<Organisation> orgOpt = organisationRepository
.find("keycloakOrgId = ?1 AND actif = true", kcOrgId)
.firstResultOptional();
if (orgOpt.isEmpty()) {
LOG.warnf("Aucune organisation UnionFlow avec keycloak_org_id=%s", kcOrgId);
throw new ForbiddenException(
"Aucune organisation active trouvée pour cet identifiant Keycloak Organization.");
}
return orgOpt.get().getId();
}
/**
* Variante qui retourne un {@code Optional} vide si le claim est absent
* (pour les endpoints compatibles avec les deux modes header + JWT).
*/
public Optional<UUID> resolveOrganisationIdIfPresent() {
try {
var orgClaim = jwt.<java.util.Map<String, Object>>getClaim("organization");
if (orgClaim == null || orgClaim.isEmpty()) {
return Optional.empty();
}
return Optional.of(resolveOrganisationId());
} catch (BadRequestException | ForbiddenException e) {
return Optional.empty();
}
}
@SuppressWarnings("unchecked")
private String extractId(Object entry) {
if (entry instanceof java.util.Map) {
Object id = ((java.util.Map<String, Object>) entry).get("id");
return id != null ? id.toString() : null;
}
return null;
}
}

View File

@@ -0,0 +1,77 @@
package dev.lions.unionflow.server.security;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.ext.Provider;
import lombok.extern.slf4j.Slf4j;
import javax.sql.DataSource;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.util.UUID;
/**
* Filtre JAX-RS qui positionne les variables de session PostgreSQL pour le RLS.
*
* <p>Doit s'exécuter APRÈS {@link OrganisationContextFilter} (priorité AUTHORIZATION + 20).
*
* <p>Variables positionnées :
* <ul>
* <li>{@code app.current_org_id} : UUID de l'organisation active (null → "00000000-0000-0000-0000-000000000000")</li>
* <li>{@code app.is_super_admin} : 'true' si SUPER_ADMIN (bypass RLS pour requêtes cross-tenant)</li>
* </ul>
*
* <p><strong>Limitation connue</strong> : ce filtre ouvre une connexion séparée du pool Agroal.
* {@code SET LOCAL} affecte CETTE connexion, pas celle utilisée par Hibernate pour les queries.
* Pour une isolation réelle, il faut brancher le {@code SET} sur le même contexte transactionnel
* Hibernate — via {@code CurrentTenantIdentifierResolver} + {@code MultiTenantConnectionProvider},
* ou via un {@code TransactionSynchronization} qui s'exécute dans la même transaction JTA.
* Ce filtre est un draft de préparation prod ; l'intégration complète est prévue en P2.4.
*
* <p>En dev, RLS est désactivé de fait car le user {@code skyfile} est owner
* et bypasse naturellement les policies. Ce filter est actif pour la préparation prod.
*/
@Slf4j
@Provider
@Priority(Priorities.AUTHORIZATION + 20)
public class RlsConnectionInitializer implements ContainerRequestFilter {
private static final String NULL_ORG_ID = "00000000-0000-0000-0000-000000000000";
@Inject
OrganisationContextHolder contextHolder;
@Inject
SecurityIdentity identity;
@Inject
DataSource dataSource;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
if (identity == null || identity.isAnonymous()) return;
boolean isSuperAdmin = identity.getRoles() != null
&& (identity.getRoles().contains("SUPER_ADMIN")
|| identity.getRoles().contains("SUPERADMIN"));
UUID orgId = contextHolder.hasContext() ? contextHolder.getOrganisationId() : null;
String orgIdStr = orgId != null ? orgId.toString() : NULL_ORG_ID;
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement stmt = conn.prepareStatement(
"SET LOCAL app.current_org_id = '" + orgIdStr + "'; "
+ "SET LOCAL app.is_super_admin = '" + isSuperAdmin + "'")) {
stmt.execute();
}
} catch (Exception e) {
// Non bloquant en dev (user owner bypasse RLS)
log.debug("RLS session variables non positionnées (ignoré en dev) : {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.security;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;
import java.util.UUID;
/**
* Intercepteur CDI qui positionne les variables de session PostgreSQL pour le RLS
* DANS la même connexion JTA que Hibernate.
*
* <p>Priorité 300 : s'exécute APRÈS l'intercepteur {@code @Transactional} (priorité ~200)
* mais AVANT le code métier, garantissant que {@code SET LOCAL} affecte la connexion
* JTA active.
*
* <p>Utilise {@code set_config(name, value, true)} (is_local=true) qui est l'équivalent
* de {@code SET LOCAL} et s'annule automatiquement en fin de transaction.
*
* <p>Si aucun contexte d'organisation n'est disponible (SUPER_ADMIN sans org, ou endpoint
* public), positionne l'UUID nul pour que les policies RLS utilisent le fallback.
*/
@Slf4j
@Interceptor
@RlsEnabled
@Priority(300)
public class RlsContextInterceptor {
private static final String NULL_ORG_UUID = "00000000-0000-0000-0000-000000000000";
@Inject
EntityManager em;
@Inject
OrganisationContextHolder contextHolder;
@Inject
SecurityIdentity identity;
@AroundInvoke
Object applyRlsContext(InvocationContext ctx) throws Exception {
if (identity == null || identity.isAnonymous()) {
return ctx.proceed();
}
boolean isSuperAdmin = identity.getRoles() != null
&& (identity.getRoles().contains("SUPER_ADMIN")
|| identity.getRoles().contains("SUPERADMIN"));
UUID orgId = contextHolder.hasContext() ? contextHolder.getOrganisationId() : null;
String orgIdStr = orgId != null ? orgId.toString() : NULL_ORG_UUID;
try {
em.createNativeQuery(
"SELECT set_config('app.current_org_id', :orgId, true), "
+ "set_config('app.is_super_admin', :isSuperAdmin, true)")
.setParameter("orgId", orgIdStr)
.setParameter("isSuperAdmin", String.valueOf(isSuperAdmin))
.getSingleResult();
log.debug("RLS context positionné : org={}, superAdmin={}", orgIdStr, isSuperAdmin);
} catch (Exception e) {
// Non bloquant : en dev, le user owner bypasse naturellement les policies
log.debug("RLS set_config ignoré (probablement hors transaction) : {}", e.getMessage());
}
return ctx.proceed();
}
}

View File

@@ -0,0 +1,26 @@
package dev.lions.unionflow.server.security;
import jakarta.interceptor.InterceptorBinding;
import java.lang.annotation.*;
/**
* Marque une méthode ou classe transactionnelle pour que le filtre RLS
* positionne les variables de session PostgreSQL ({@code app.current_org_id},
* {@code app.is_super_admin}) dans la même connexion JTA que Hibernate.
*
* <p>Doit toujours être combiné avec {@code @Transactional} (ou être dans une
* méthode appelée depuis un contexte transactionnel existant).
*
* <p>Usage :
* <pre>{@code
* @RlsEnabled
* @Transactional
* public List<Cotisation> findAll() { ... }
* }</pre>
*/
@Inherited
@InterceptorBinding
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RlsEnabled {
}

View File

@@ -87,6 +87,25 @@ public class AuditService {
auditLogRepository.persist(log); auditLogRepository.persist(log);
} }
/**
* Enregistre un log d'audit KYC/AML quand un score de risque élevé est détecté.
*/
@Transactional
public void logKycRisqueEleve(UUID membreId, int scoreRisque, String niveauRisque) {
AuditLog log = new AuditLog();
log.setTypeAction("KYC_RISQUE_ELEVE");
log.setSeverite("WARNING");
log.setUtilisateur(membreId != null ? membreId.toString() : null);
log.setModule("KYC_AML");
log.setDescription("Score de risque KYC/AML élevé détecté");
log.setDetails(String.format("membreId=%s, score=%d, niveau=%s", membreId, scoreRisque, niveauRisque));
log.setEntiteType("KycDossier");
log.setEntiteId(membreId != null ? membreId.toString() : null);
log.setDateHeure(LocalDateTime.now());
log.setPortee(PorteeAudit.PLATEFORME);
auditLogRepository.persist(log);
}
/** /**
* Enregistre un nouveau log d'audit * Enregistre un nouveau log d'audit
*/ */

View File

@@ -0,0 +1,435 @@
package dev.lions.unionflow.server.service;
import com.lowagie.text.*;
import com.lowagie.text.Font;
import com.lowagie.text.pdf.*;
import dev.lions.unionflow.server.api.enums.comptabilite.TypeCompteComptable;
import dev.lions.unionflow.server.entity.CompteComptable;
import dev.lions.unionflow.server.entity.EcritureComptable;
import dev.lions.unionflow.server.entity.LigneEcriture;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.CompteComptableRepository;
import dev.lions.unionflow.server.repository.EcritureComptableRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Génération des rapports comptables PDF SYSCOHADA révisé.
*
* <p>Rapports disponibles :
* <ul>
* <li>Grand livre : détail de toutes les écritures par compte</li>
* <li>Balance générale : soldes débit/crédit/solde net par compte</li>
* <li>Compte de résultat : produits (classe 7+8) - charges (classe 6+8)</li>
* </ul>
*/
@Slf4j
@ApplicationScoped
public class ComptabilitePdfService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
private static final Color COLOR_HEADER = new Color(0x1A, 0x56, 0x8C);
private static final Color COLOR_HEADER_TEXT = Color.WHITE;
private static final Color COLOR_TOTAL_ROW = new Color(0xE8, 0xF0, 0xFE);
private static final Color COLOR_ROW_ALT = new Color(0xF8, 0xFA, 0xFF);
@Inject
OrganisationRepository organisationRepository;
@Inject
CompteComptableRepository compteComptableRepository;
@Inject
EcritureComptableRepository ecritureComptableRepository;
// ── Balance générale ─────────────────────────────────────────────────────
/**
* Génère la balance générale SYSCOHADA pour une organisation.
* Liste tous les comptes avec cumul débit, cumul crédit et solde.
*/
public byte[] genererBalance(UUID organisationId, LocalDate dateDebut, LocalDate dateFin) {
Organisation org = getOrg(organisationId);
List<CompteComptable> comptes = compteComptableRepository.findByOrganisation(organisationId);
Map<String, BigDecimal[]> totauxParCompte = calculerTotauxParCompte(organisationId, dateDebut, dateFin);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4.rotate(), 20, 20, 40, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addTitrePage(doc, "BALANCE GÉNÉRALE", org.getNom(), dateDebut, dateFin);
PdfPTable table = new PdfPTable(6);
table.setWidthPercentage(100);
table.setWidths(new float[]{10f, 30f, 8f, 15f, 15f, 15f});
addHeaderCell(table, "Compte");
addHeaderCell(table, "Libellé");
addHeaderCell(table, "Classe");
addHeaderCell(table, "Cumul Débit");
addHeaderCell(table, "Cumul Crédit");
addHeaderCell(table, "Solde");
BigDecimal totalDebit = BigDecimal.ZERO;
BigDecimal totalCredit = BigDecimal.ZERO;
boolean alt = false;
for (CompteComptable compte : comptes) {
BigDecimal[] totaux = totauxParCompte.getOrDefault(
compte.getNumeroCompte(), new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
BigDecimal debit = totaux[0];
BigDecimal credit = totaux[1];
BigDecimal solde = debit.subtract(credit);
if (debit.signum() == 0 && credit.signum() == 0) continue;
Color bg = alt ? COLOR_ROW_ALT : Color.WHITE;
addDataCell(table, compte.getNumeroCompte(), bg);
addDataCell(table, compte.getLibelle(), bg);
addDataCell(table, String.valueOf(compte.getClasseComptable()), bg);
addAmountCell(table, debit, bg);
addAmountCell(table, credit, bg);
addAmountCell(table, solde, bg);
totalDebit = totalDebit.add(debit);
totalCredit = totalCredit.add(credit);
alt = !alt;
}
// Ligne totaux
BigDecimal totalSolde = totalDebit.subtract(totalCredit);
addTotalCell(table, "TOTAUX");
addTotalCell(table, "");
addTotalCell(table, "");
addAmountCell(table, totalDebit, COLOR_TOTAL_ROW);
addAmountCell(table, totalCredit, COLOR_TOTAL_ROW);
addAmountCell(table, totalSolde, COLOR_TOTAL_ROW);
doc.add(table);
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
log.error("Erreur génération balance PDF : {}", e.getMessage(), e);
throw new RuntimeException("Erreur génération balance PDF", e);
}
}
// ── Compte de résultat ────────────────────────────────────────────────────
/**
* Génère le compte de résultat SYSCOHADA.
* Produits (classes 7 et 8 produits) — Charges (classes 6 et 8 charges).
*/
public byte[] genererCompteResultat(UUID organisationId, LocalDate dateDebut, LocalDate dateFin) {
Organisation org = getOrg(organisationId);
Map<String, BigDecimal[]> totaux = calculerTotauxParCompte(organisationId, dateDebut, dateFin);
List<CompteComptable> comptes = compteComptableRepository.findByOrganisation(organisationId);
BigDecimal totalProduits = BigDecimal.ZERO;
BigDecimal totalCharges = BigDecimal.ZERO;
List<Object[]> lignesProduits = new ArrayList<>();
List<Object[]> lignesCharges = new ArrayList<>();
for (CompteComptable compte : comptes) {
int classe = compte.getClasseComptable();
BigDecimal[] t = totaux.getOrDefault(compte.getNumeroCompte(),
new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
BigDecimal solde = t[1].subtract(t[0]); // crédit - débit pour produits
if ((classe == 7) || (classe == 8 && TypeCompteComptable.PRODUITS.equals(compte.getTypeCompte()))) {
if (solde.signum() != 0) {
lignesProduits.add(new Object[]{compte.getNumeroCompte(), compte.getLibelle(), solde});
totalProduits = totalProduits.add(solde);
}
} else if ((classe == 6) || (classe == 8 && TypeCompteComptable.CHARGES.equals(compte.getTypeCompte()))) {
BigDecimal soldeCharge = t[0].subtract(t[1]); // débit - crédit pour charges
if (soldeCharge.signum() != 0) {
lignesCharges.add(new Object[]{compte.getNumeroCompte(), compte.getLibelle(), soldeCharge});
totalCharges = totalCharges.add(soldeCharge);
}
}
}
BigDecimal resultat = totalProduits.subtract(totalCharges);
boolean benefice = resultat.signum() >= 0;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4, 30, 30, 50, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addTitrePage(doc, "COMPTE DE RÉSULTAT", org.getNom(), dateDebut, dateFin);
// Section PRODUITS
addSectionTitle(doc, "PRODUITS D'EXPLOITATION");
PdfPTable tableProduits = creerTableau2Colonnes();
for (Object[] ligne : lignesProduits) {
addDataCell(tableProduits, ligne[0] + "" + ligne[1], Color.WHITE);
addAmountCell(tableProduits, (BigDecimal) ligne[2], Color.WHITE);
}
addTotalCell(tableProduits, "TOTAL PRODUITS");
addAmountCell(tableProduits, totalProduits, COLOR_TOTAL_ROW);
doc.add(tableProduits);
doc.add(new Paragraph(" "));
// Section CHARGES
addSectionTitle(doc, "CHARGES D'EXPLOITATION");
PdfPTable tableCharges = creerTableau2Colonnes();
for (Object[] ligne : lignesCharges) {
addDataCell(tableCharges, ligne[0] + "" + ligne[1], Color.WHITE);
addAmountCell(tableCharges, (BigDecimal) ligne[2], Color.WHITE);
}
addTotalCell(tableCharges, "TOTAL CHARGES");
addAmountCell(tableCharges, totalCharges, COLOR_TOTAL_ROW);
doc.add(tableCharges);
doc.add(new Paragraph(" "));
// Résultat net
PdfPTable tableResultat = creerTableau2Colonnes();
String libelleResultat = benefice ? "BÉNÉFICE NET DE L'EXERCICE" : "PERTE NETTE DE L'EXERCICE";
Color couleurResultat = benefice ? new Color(0x00, 0x80, 0x00) : new Color(0xCC, 0x00, 0x00);
PdfPCell cellResultat = new PdfPCell(
new Phrase(libelleResultat, FontFactory.getFont(FontFactory.HELVETICA_BOLD, 11, couleurResultat)));
cellResultat.setBackgroundColor(new Color(0xF0, 0xF8, 0xE8));
cellResultat.setPadding(8);
tableResultat.addCell(cellResultat);
addAmountCell(tableResultat, resultat.abs(), new Color(0xF0, 0xF8, 0xE8));
doc.add(tableResultat);
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
log.error("Erreur génération compte de résultat PDF : {}", e.getMessage(), e);
throw new RuntimeException("Erreur génération compte de résultat PDF", e);
}
}
// ── Grand livre ───────────────────────────────────────────────────────────
/**
* Génère le grand livre pour un compte donné.
*/
public byte[] genererGrandLivre(UUID organisationId, String numeroCompte,
LocalDate dateDebut, LocalDate dateFin) {
Organisation org = getOrg(organisationId);
CompteComptable compte = compteComptableRepository
.findByOrganisationAndNumero(organisationId, numeroCompte)
.orElseThrow(() -> new NotFoundException(
"Compte " + numeroCompte + " introuvable pour l'org " + organisationId));
List<EcritureComptable> ecritures = ecritureComptableRepository
.findByOrganisationAndDateRange(organisationId, dateDebut, dateFin);
// Filtrer les lignes qui concernent ce compte
List<Object[]> mouvements = new ArrayList<>();
BigDecimal solde = BigDecimal.ZERO;
for (EcritureComptable ecriture : ecritures) {
if (ecriture.getLignes() == null) continue;
for (LigneEcriture ligne : ecriture.getLignes()) {
if (ligne.getCompteComptable() == null) continue;
if (!numeroCompte.equals(ligne.getCompteComptable().getNumeroCompte())) continue;
BigDecimal debit = ligne.getMontantDebit() != null ? ligne.getMontantDebit() : BigDecimal.ZERO;
BigDecimal credit = ligne.getMontantCredit() != null ? ligne.getMontantCredit() : BigDecimal.ZERO;
solde = solde.add(debit).subtract(credit);
mouvements.add(new Object[]{
ecriture.getDateEcriture(),
ecriture.getNumeroPiece(),
ecriture.getLibelle(),
debit,
credit,
solde
});
}
}
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4.rotate(), 20, 20, 40, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addTitrePage(doc, "GRAND LIVRE — " + numeroCompte + " " + compte.getLibelle(),
org.getNom(), dateDebut, dateFin);
PdfPTable table = new PdfPTable(6);
table.setWidthPercentage(100);
table.setWidths(new float[]{12f, 15f, 35f, 12f, 12f, 14f});
addHeaderCell(table, "Date");
addHeaderCell(table, "Pièce");
addHeaderCell(table, "Libellé");
addHeaderCell(table, "Débit");
addHeaderCell(table, "Crédit");
addHeaderCell(table, "Solde cumulé");
boolean alt = false;
for (Object[] mvt : mouvements) {
Color bg = alt ? COLOR_ROW_ALT : Color.WHITE;
addDataCell(table, DATE_FMT.format((LocalDate) mvt[0]), bg);
addDataCell(table, (String) mvt[1], bg);
addDataCell(table, (String) mvt[2], bg);
addAmountCell(table, (BigDecimal) mvt[3], bg);
addAmountCell(table, (BigDecimal) mvt[4], bg);
addAmountCell(table, (BigDecimal) mvt[5], bg);
alt = !alt;
}
if (mouvements.isEmpty()) {
PdfPCell empty = new PdfPCell(new Phrase("Aucun mouvement sur la période",
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, Color.GRAY)));
empty.setColspan(6);
empty.setPadding(10);
empty.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(empty);
}
doc.add(table);
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
log.error("Erreur génération grand livre PDF : {}", e.getMessage(), e);
throw new RuntimeException("Erreur génération grand livre PDF", e);
}
}
// ── Utilitaires PDF ──────────────────────────────────────────────────────
private void addTitrePage(Document doc, String titre, String orgNom,
LocalDate dateDebut, LocalDate dateFin) throws DocumentException {
Font fontTitre = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, COLOR_HEADER);
Font fontSousTitre = FontFactory.getFont(FontFactory.HELVETICA, 11, Color.DARK_GRAY);
Paragraph pTitre = new Paragraph(titre, fontTitre);
pTitre.setAlignment(Element.ALIGN_CENTER);
pTitre.setSpacingAfter(4);
doc.add(pTitre);
Paragraph pOrg = new Paragraph(orgNom, fontSousTitre);
pOrg.setAlignment(Element.ALIGN_CENTER);
doc.add(pOrg);
if (dateDebut != null && dateFin != null) {
Paragraph pPeriode = new Paragraph(
"Période : " + DATE_FMT.format(dateDebut) + " au " + DATE_FMT.format(dateFin),
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, Color.GRAY));
pPeriode.setAlignment(Element.ALIGN_CENTER);
pPeriode.setSpacingAfter(12);
doc.add(pPeriode);
}
}
private void addSectionTitle(Document doc, String titre) throws DocumentException {
Paragraph p = new Paragraph(titre,
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 12, COLOR_HEADER));
p.setSpacingBefore(8);
p.setSpacingAfter(4);
doc.add(p);
}
private void addFooter(Document doc) throws DocumentException {
Paragraph footer = new Paragraph(
"Généré le " + DATE_FMT.format(LocalDate.now()) + " — UnionFlow SYSCOHADA révisé",
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 8, Color.GRAY));
footer.setAlignment(Element.ALIGN_RIGHT);
footer.setSpacingBefore(16);
doc.add(footer);
}
private PdfPTable creerTableau2Colonnes() throws DocumentException {
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(100);
table.setWidths(new float[]{65f, 35f});
return table;
}
private void addHeaderCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text,
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, COLOR_HEADER_TEXT)));
cell.setBackgroundColor(COLOR_HEADER);
cell.setPadding(6);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
private void addDataCell(PdfPTable table, String text, Color bg) {
PdfPCell cell = new PdfPCell(new Phrase(text,
FontFactory.getFont(FontFactory.HELVETICA, 9, Color.BLACK)));
cell.setBackgroundColor(bg);
cell.setPadding(5);
table.addCell(cell);
}
private void addAmountCell(PdfPTable table, BigDecimal amount, Color bg) {
String formatted = amount != null
? String.format("%,.0f XOF", amount.doubleValue())
: "0 XOF";
PdfPCell cell = new PdfPCell(new Phrase(formatted,
FontFactory.getFont(FontFactory.HELVETICA, 9, Color.BLACK)));
cell.setBackgroundColor(bg);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_RIGHT);
table.addCell(cell);
}
private void addTotalCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text,
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.BLACK)));
cell.setBackgroundColor(COLOR_TOTAL_ROW);
cell.setPadding(6);
table.addCell(cell);
}
// ── Calcul des totaux ─────────────────────────────────────────────────────
private Map<String, BigDecimal[]> calculerTotauxParCompte(UUID organisationId,
LocalDate dateDebut, LocalDate dateFin) {
List<EcritureComptable> ecritures = ecritureComptableRepository
.findByOrganisationAndDateRange(organisationId, dateDebut, dateFin);
Map<String, BigDecimal[]> totaux = new HashMap<>();
for (EcritureComptable ecriture : ecritures) {
if (ecriture.getLignes() == null) continue;
for (LigneEcriture ligne : ecriture.getLignes()) {
if (ligne.getCompteComptable() == null) continue;
String numero = ligne.getCompteComptable().getNumeroCompte();
totaux.computeIfAbsent(numero, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
BigDecimal debit = ligne.getMontantDebit() != null ? ligne.getMontantDebit() : BigDecimal.ZERO;
BigDecimal credit = ligne.getMontantCredit() != null ? ligne.getMontantCredit() : BigDecimal.ZERO;
totaux.get(numero)[0] = totaux.get(numero)[0].add(debit);
totaux.get(numero)[1] = totaux.get(numero)[1].add(credit);
}
}
return totaux;
}
private Organisation getOrg(UUID organisationId) {
return organisationRepository.findByIdOptional(organisationId)
.orElseThrow(() -> new NotFoundException("Organisation introuvable : " + organisationId));
}
}

View File

@@ -2,7 +2,9 @@ package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.comptabilite.request.*; import dev.lions.unionflow.server.api.dto.comptabilite.request.*;
import dev.lions.unionflow.server.api.dto.comptabilite.response.*; import dev.lions.unionflow.server.api.dto.comptabilite.response.*;
import dev.lions.unionflow.server.api.enums.comptabilite.TypeJournalComptable;
import dev.lions.unionflow.server.entity.*; import dev.lions.unionflow.server.entity.*;
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
import dev.lions.unionflow.server.repository.*; import dev.lions.unionflow.server.repository.*;
import dev.lions.unionflow.server.service.KeycloakService; import dev.lions.unionflow.server.service.KeycloakService;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
@@ -221,6 +223,207 @@ public class ComptabiliteService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
// ========================================
// MÉTHODES SYSCOHADA — Génération automatique d'écritures depuis les opérations métier
// Débit/Crédit selon les règles SYSCOHADA révisé (UEMOA, applicable depuis 2018)
// ========================================
/**
* Génère l'écriture comptable SYSCOHADA pour une cotisation payée.
* Schéma : Débit 5121xx (trésorerie provider) ; Crédit 706100 (cotisations ordinaires).
* Appeler depuis CotisationService.marquerPaye() après confirmation du paiement.
*/
@Transactional
public EcritureComptable enregistrerCotisation(Cotisation cotisation) {
if (cotisation == null || cotisation.getOrganisation() == null) {
LOG.warn("enregistrerCotisation : cotisation ou organisation null — écriture ignorée");
return null;
}
UUID orgId = cotisation.getOrganisation().getId();
BigDecimal montant = cotisation.getMontantPaye();
if (montant == null || montant.compareTo(BigDecimal.ZERO) == 0) {
return null;
}
// Choix du compte de trésorerie selon le provider (Wave par défaut)
String numeroTresorerie = resolveCompteTresorerie(cotisation.getCodeDevise());
CompteComptable compteTresorerie = compteComptableRepository
.findByOrganisationAndNumero(orgId, numeroTresorerie)
.orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null));
// Compte produit cotisations ordinaires
String numeroCompteType = "ORDINAIRE".equals(cotisation.getTypeCotisation()) ? "706100" : "706200";
CompteComptable compteProduit = compteComptableRepository
.findByOrganisationAndNumero(orgId, numeroCompteType)
.orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "706100").orElse(null));
if (compteTresorerie == null || compteProduit == null) {
LOG.warnf("Comptes SYSCOHADA manquants pour org %s — plan comptable non initialisé ?", orgId);
return null;
}
JournalComptable journal = journalComptableRepository
.findByOrganisationAndType(orgId, TypeJournalComptable.VENTES)
.orElse(null);
if (journal == null) {
LOG.warnf("Journal VENTES absent pour org %s — écriture ignorée", orgId);
return null;
}
EcritureComptable ecriture = construireEcriture(
journal,
cotisation.getOrganisation(),
LocalDate.now(),
String.format("Cotisation %s - %s", cotisation.getTypeCotisation(), cotisation.getNumeroReference()),
cotisation.getNumeroReference(),
montant,
compteTresorerie,
compteProduit
);
ecritureComptableRepository.persist(ecriture);
LOG.infof("Écriture SYSCOHADA cotisation créée : %s | montant %s XOF", ecriture.getNumeroPiece(), montant);
return ecriture;
}
/**
* Génère l'écriture SYSCOHADA pour un dépôt épargne.
* Schéma : Débit 5121xx (trésorerie) ; Crédit 421000 (dette mutuelle envers membre).
*/
@Transactional
public EcritureComptable enregistrerDepotEpargne(TransactionEpargne transaction, Organisation organisation) {
if (transaction == null || organisation == null) return null;
UUID orgId = organisation.getId();
BigDecimal montant = transaction.getMontant();
if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) return null;
CompteComptable compteTresorerie = compteComptableRepository
.findByOrganisationAndNumero(orgId, "512100")
.orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null));
CompteComptable compteEpargne = compteComptableRepository
.findByOrganisationAndNumero(orgId, "421000").orElse(null);
if (compteTresorerie == null || compteEpargne == null) return null;
JournalComptable journal = journalComptableRepository
.findByOrganisationAndType(orgId, TypeJournalComptable.BANQUE)
.orElse(null);
if (journal == null) return null;
EcritureComptable ecriture = construireEcriture(
journal, organisation, LocalDate.now(),
"Dépôt épargne - " + (transaction.getReferenceExterne() != null ? transaction.getReferenceExterne() : ""),
transaction.getReferenceExterne(),
montant, compteTresorerie, compteEpargne
);
ecritureComptableRepository.persist(ecriture);
LOG.infof("Écriture SYSCOHADA dépôt épargne : %s | %s XOF", ecriture.getNumeroPiece(), montant);
return ecriture;
}
/**
* Génère l'écriture SYSCOHADA pour un retrait épargne.
* Schéma : Débit 421000 (dette mutuelle) ; Crédit 5121xx (trésorerie sortante).
*/
@Transactional
public EcritureComptable enregistrerRetraitEpargne(TransactionEpargne transaction, Organisation organisation) {
if (transaction == null || organisation == null) return null;
UUID orgId = organisation.getId();
BigDecimal montant = transaction.getMontant();
if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) return null;
CompteComptable compteEpargne = compteComptableRepository
.findByOrganisationAndNumero(orgId, "421000").orElse(null);
CompteComptable compteTresorerie = compteComptableRepository
.findByOrganisationAndNumero(orgId, "512100")
.orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null));
if (compteEpargne == null || compteTresorerie == null) return null;
JournalComptable journal = journalComptableRepository
.findByOrganisationAndType(orgId, TypeJournalComptable.BANQUE)
.orElse(null);
if (journal == null) return null;
// Retrait : débit = 421000 (dette diminue), crédit = 512xxx (cash sort)
EcritureComptable ecriture = construireEcriture(
journal, organisation, LocalDate.now(),
"Retrait épargne - " + (transaction.getReferenceExterne() != null ? transaction.getReferenceExterne() : ""),
transaction.getReferenceExterne(),
montant, compteEpargne, compteTresorerie
);
ecritureComptableRepository.persist(ecriture);
return ecriture;
}
// ========================================
// MÉTHODES PRIVÉES - HELPERS SYSCOHADA
// ========================================
/**
* Détermine le compte de trésorerie selon le code devise / provider.
* Par défaut 512100 (Wave) pour XOF en UEMOA.
*/
private String resolveCompteTresorerie(String codeDevise) {
// Pour l'instant Wave = 512100 par défaut. Sera enrichi avec multi-provider P1.3.
return "512100";
}
/**
* Construit une écriture comptable à 2 lignes (débit/crédit) équilibrée.
*/
private EcritureComptable construireEcriture(
JournalComptable journal,
Organisation organisation,
LocalDate date,
String libelle,
String reference,
BigDecimal montant,
CompteComptable compteDebit,
CompteComptable compteCredit) {
LigneEcriture ligneDebit = new LigneEcriture();
ligneDebit.setNumeroLigne(1);
ligneDebit.setCompteComptable(compteDebit);
ligneDebit.setMontantDebit(montant);
ligneDebit.setMontantCredit(BigDecimal.ZERO);
ligneDebit.setLibelle(libelle);
ligneDebit.setReference(reference);
LigneEcriture ligneCredit = new LigneEcriture();
ligneCredit.setNumeroLigne(2);
ligneCredit.setCompteComptable(compteCredit);
ligneCredit.setMontantDebit(BigDecimal.ZERO);
ligneCredit.setMontantCredit(montant);
ligneCredit.setLibelle(libelle);
ligneCredit.setReference(reference);
EcritureComptable ecriture = EcritureComptable.builder()
.journal(journal)
.organisation(organisation)
.dateEcriture(date)
.libelle(libelle)
.reference(reference)
.montantDebit(montant)
.montantCredit(montant)
.pointe(false)
.build();
ecriture.getLignes().add(ligneDebit);
ecriture.getLignes().add(ligneCredit);
ligneDebit.setEcriture(ecriture);
ligneCredit.setEcriture(ecriture);
return ecriture;
}
// ======================================== // ========================================
// MÉTHODES PRIVÉES - CONVERSIONS // MÉTHODES PRIVÉES - CONVERSIONS
// ======================================== // ========================================

View File

@@ -11,6 +11,8 @@ import dev.lions.unionflow.server.repository.CotisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository; import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.service.support.SecuriteHelper; import dev.lions.unionflow.server.service.support.SecuriteHelper;
import dev.lions.unionflow.server.service.ComptabiliteService;
import dev.lions.unionflow.server.security.RlsEnabled;
import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort; import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
@@ -43,6 +45,7 @@ import lombok.extern.slf4j.Slf4j;
*/ */
@ApplicationScoped @ApplicationScoped
@Slf4j @Slf4j
@RlsEnabled
public class CotisationService { public class CotisationService {
@Inject @Inject
@@ -63,6 +66,12 @@ public class CotisationService {
@Inject @Inject
OrganisationService organisationService; OrganisationService organisationService;
@Inject
ComptabiliteService comptabiliteService;
@Inject
EmailTemplateService emailTemplateService;
/** /**
* Récupère toutes les cotisations avec pagination. * Récupère toutes les cotisations avec pagination.
* *
@@ -246,6 +255,7 @@ public class CotisationService {
} }
// Déterminer le statut en fonction du montant payé // Déterminer le statut en fonction du montant payé
boolean etaitDejaPayee = "PAYEE".equals(cotisation.getStatut());
if (cotisation.getMontantPaye() != null && cotisation.getMontantDu() != null if (cotisation.getMontantPaye() != null && cotisation.getMontantDu() != null
&& cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) >= 0) { && cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) >= 0) {
cotisation.setStatut("PAYEE"); cotisation.setStatut("PAYEE");
@@ -254,6 +264,36 @@ public class CotisationService {
cotisation.setStatut("PARTIELLEMENT_PAYEE"); cotisation.setStatut("PARTIELLEMENT_PAYEE");
} }
// Génération écriture SYSCOHADA + email si cotisation vient de passer à PAYEE
if (!etaitDejaPayee && "PAYEE".equals(cotisation.getStatut())) {
try {
comptabiliteService.enregistrerCotisation(cotisation);
} catch (Exception e) {
log.warn("Écriture SYSCOHADA cotisation ignorée (non bloquant) : {}", e.getMessage());
}
// Email de confirmation asynchrone (non bloquant)
if (cotisation.getMembre() != null && cotisation.getMembre().getEmail() != null) {
try {
String periode = cotisation.getPeriode() != null ? cotisation.getPeriode()
: (cotisation.getDateEcheance() != null
? cotisation.getDateEcheance().getYear() + "/" + cotisation.getDateEcheance().getMonthValue()
: "");
emailTemplateService.envoyerConfirmationCotisation(
cotisation.getMembre().getEmail(),
cotisation.getMembre().getPrenom() != null ? cotisation.getMembre().getPrenom() : "",
cotisation.getMembre().getNom() != null ? cotisation.getMembre().getNom() : "",
cotisation.getOrganisation() != null ? cotisation.getOrganisation().getNom() : "",
periode,
reference != null ? reference : "",
modePaiement != null ? modePaiement : "",
datePaiement,
cotisation.getMontantPaye());
} catch (Exception e) {
log.warn("Email confirmation cotisation ignoré (non bloquant) : {}", e.getMessage());
}
}
}
log.info("Paiement enregistré - ID: {}, Statut: {}", id, cotisation.getStatut()); log.info("Paiement enregistré - ID: {}, Statut: {}", id, cotisation.getStatut());
return convertToResponse(cotisation); return convertToResponse(cotisation);
} }

View File

@@ -0,0 +1,134 @@
package dev.lions.unionflow.server.service;
import io.quarkus.mailer.MailTemplate.MailTemplateInstance;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* Service d'envoi d'emails HTML via Qute templates (Quarkus Mailer).
*
* <p>Templates : {@code src/main/resources/templates/email/}.
* Variables injectées au moment de l'appel via {@code .data(key, value)}.
*/
@Slf4j
@ApplicationScoped
public class EmailTemplateService {
private static final DateTimeFormatter DATE_FR = DateTimeFormatter.ofPattern("dd/MM/yyyy");
@Inject
Mailer mailer;
// ── Templates Qute ────────────────────────────────────────────────────────
@CheckedTemplate(basePath = "email")
static class Templates {
static native MailTemplateInstance bienvenue(
String prenom, String nom, String email,
String nomOrganisation, String lienConnexion);
static native MailTemplateInstance cotisationConfirmation(
String prenom, String nom,
String nomOrganisation, String periode,
String numeroReference, String methodePaiement,
String datePaiement, String montant);
static native MailTemplateInstance rappelCotisation(
String prenom, String nom,
String nomOrganisation, String periode,
String montant, String dateLimite, String lienPaiement);
static native MailTemplateInstance souscriptionConfirmation(
String nomAdministrateur, String nomOrganisation,
String nomFormule, String montant, String periodicite,
String dateActivation, String dateExpiration,
String maxMembres, String maxStockageMo,
boolean apiAccess, boolean supportPrioritaire);
}
// ── Méthodes d'envoi ─────────────────────────────────────────────────────
public void envoyerBienvenue(String email, String prenom, String nom,
String nomOrganisation, String lienConnexion) {
try {
Templates.bienvenue(prenom, nom, email, nomOrganisation, lienConnexion)
.to(email)
.subject("Bienvenue sur UnionFlow — " + nomOrganisation)
.send().await().indefinitely();
log.info("Email bienvenue envoyé à {}", email);
} catch (Exception e) {
log.error("Échec envoi email bienvenue à {}: {}", email, e.getMessage(), e);
}
}
public void envoyerConfirmationCotisation(String email, String prenom, String nom,
String nomOrganisation, String periode,
String numeroReference, String methodePaiement,
LocalDate datePaiement, BigDecimal montant) {
try {
Templates.cotisationConfirmation(
prenom, nom, nomOrganisation, periode,
numeroReference, methodePaiement,
datePaiement != null ? DATE_FR.format(datePaiement) : "",
String.format("%,.0f", montant.doubleValue()))
.to(email)
.subject("Confirmation de cotisation — " + periode)
.send().await().indefinitely();
log.info("Email confirmation cotisation envoyé à {}", email);
} catch (Exception e) {
log.error("Échec envoi email cotisation à {}: {}", email, e.getMessage(), e);
}
}
public void envoyerRappelCotisation(String email, String prenom, String nom,
String nomOrganisation, String periode,
BigDecimal montant, LocalDate dateLimite,
String lienPaiement) {
try {
Templates.rappelCotisation(
prenom, nom, nomOrganisation, periode,
String.format("%,.0f", montant.doubleValue()),
dateLimite != null ? DATE_FR.format(dateLimite) : "",
lienPaiement)
.to(email)
.subject("⚠️ Rappel : cotisation " + periode + " en attente")
.send().await().indefinitely();
log.info("Email rappel cotisation envoyé à {}", email);
} catch (Exception e) {
log.error("Échec envoi rappel cotisation à {}: {}", email, e.getMessage(), e);
}
}
public void envoyerConfirmationSouscription(String email, String nomAdministrateur,
String nomOrganisation, String nomFormule,
BigDecimal montant, String periodicite,
LocalDate dateActivation, LocalDate dateExpiration,
Integer maxMembres, Integer maxStockageMo,
boolean apiAccess, boolean supportPrioritaire) {
try {
Templates.souscriptionConfirmation(
nomAdministrateur, nomOrganisation, nomFormule,
String.format("%,.0f", montant.doubleValue()), periodicite,
dateActivation != null ? DATE_FR.format(dateActivation) : "",
dateExpiration != null ? DATE_FR.format(dateExpiration) : "",
maxMembres != null ? String.valueOf(maxMembres) : "Illimité",
maxStockageMo != null ? String.valueOf(maxStockageMo) : "1024",
apiAccess, supportPrioritaire)
.to(email)
.subject("✅ Souscription activée — " + nomOrganisation)
.send().await().indefinitely();
log.info("Email confirmation souscription envoyé à {}", email);
} catch (Exception e) {
log.error("Échec envoi email souscription à {}: {}", email, e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,139 @@
package dev.lions.unionflow.server.service;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.*;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
/**
* Service d'envoi de notifications push via Firebase Cloud Messaging (FCM).
*
* <p>Configuration requise (application-prod.properties) :
* <pre>
* firebase.service-account-key-path=/opt/unionflow/firebase-service-account.json
* </pre>
*
* <p>En dev/test, le service est désactivé si le fichier n'existe pas.
*/
@Slf4j
@ApplicationScoped
public class FirebasePushService {
@ConfigProperty(name = "firebase.service-account-key-path")
Optional<String> serviceAccountKeyPathOpt;
String serviceAccountKeyPath;
private boolean initialized = false;
@PostConstruct
void init() {
serviceAccountKeyPath = serviceAccountKeyPathOpt.orElse("");
if (serviceAccountKeyPath.isBlank()) {
log.info("Firebase FCM désactivé (firebase.service-account-key-path non configuré)");
return;
}
try {
if (FirebaseApp.getApps().isEmpty()) {
InputStream serviceAccount = new FileInputStream(serviceAccountKeyPath);
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
FirebaseApp.initializeApp(options);
}
initialized = true;
log.info("Firebase FCM initialisé depuis {}", serviceAccountKeyPath);
} catch (Exception e) {
log.warn("Firebase FCM non initialisé ({}): {} — les notifications push seront ignorées",
serviceAccountKeyPath, e.getMessage());
}
}
/**
* Envoie une notification push à un token FCM unique.
*
* @param token token FCM du device cible
* @param titre titre de la notification
* @param corps corps de la notification
* @param data données supplémentaires (payload JSON key/value)
* @return `true` si envoi réussi, `false` sinon
*/
public boolean envoyerNotification(String token, String titre, String corps,
java.util.Map<String, String> data) {
if (!initialized || token == null || token.isBlank()) return false;
try {
Message.Builder builder = Message.builder()
.setToken(token)
.setNotification(Notification.builder()
.setTitle(titre)
.setBody(corps)
.build());
if (data != null && !data.isEmpty()) {
builder.putAllData(data);
}
String response = FirebaseMessaging.getInstance().send(builder.build());
log.info("FCM push envoyé : messageId={}", response);
return true;
} catch (FirebaseMessagingException e) {
if (MessagingErrorCode.UNREGISTERED.equals(e.getMessagingErrorCode())
|| MessagingErrorCode.INVALID_ARGUMENT.equals(e.getMessagingErrorCode())) {
log.warn("Token FCM invalide/expiré : {}", token);
} else {
log.error("Erreur FCM pour token {}: {} ({})", token, e.getMessage(), e.getMessagingErrorCode());
}
return false;
}
}
/**
* Envoie une notification push à une liste de tokens (multicast, max 500).
*
* @return nombre de messages envoyés avec succès
*/
public int envoyerNotificationMulticast(List<String> tokens, String titre, String corps,
java.util.Map<String, String> data) {
if (!initialized || tokens == null || tokens.isEmpty()) return 0;
// FCM multicast : max 500 tokens par appel
List<String> validTokens = tokens.stream()
.filter(t -> t != null && !t.isBlank())
.limit(500)
.toList();
if (validTokens.isEmpty()) return 0;
try {
MulticastMessage.Builder builder = MulticastMessage.builder()
.addAllTokens(validTokens)
.setNotification(Notification.builder()
.setTitle(titre)
.setBody(corps)
.build());
if (data != null && !data.isEmpty()) {
builder.putAllData(data);
}
BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(builder.build());
log.info("FCM multicast : {}/{} envoyés avec succès", response.getSuccessCount(), validTokens.size());
return response.getSuccessCount();
} catch (FirebaseMessagingException e) {
log.error("Erreur FCM multicast : {}", e.getMessage(), e);
return 0;
}
}
public boolean isAvailable() {
return initialized;
}
}

View File

@@ -0,0 +1,253 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest;
import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.entity.KycDossier;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.repository.KycDossierRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.security.RlsEnabled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service KYC/AML (Know Your Customer / Anti-Money Laundering).
*
* <p>Implémente la due diligence requise par le GIABA (Groupe Intergouvernemental
* d'Action contre le Blanchiment d'Argent en Afrique de l'Ouest) et les
* instructions BCEAO sur la LCB-FT (Lutte Contre le Blanchiment et le Financement
* du Terrorisme).
*
* <p>Algorithme de score de risque :
* <ul>
* <li>PEP (Personne Exposée Politiquement) : +40 points</li>
* <li>Pièce expirée : +20 points</li>
* <li>Aucun justificatif de domicile : +15 points</li>
* <li>Pièce manquante (recto/verso) : +15 points</li>
* <li>Nationalité hors UEMOA (facteur risque géographique) : +10 points</li>
* </ul>
*/
@Slf4j
@ApplicationScoped
@RlsEnabled
public class KycAmlService {
private static final List<String> PAYS_UEMOA = List.of(
"BJ", "BF", "CI", "GW", "ML", "NE", "SN", "TG"
);
@Inject
KycDossierRepository kycDossierRepository;
@Inject
MembreRepository membreRepository;
@Inject
AuditService auditService;
/**
* Crée ou met à jour le dossier KYC d'un membre.
* Si un dossier actif existe déjà, il est archivé et remplacé.
*/
@Transactional
public KycDossierResponse soumettreOuMettreAJour(KycDossierRequest request, String operateur) {
UUID membreId = UUID.fromString(request.getMembreId());
Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre introuvable : " + request.getMembreId()));
// Archiver l'ancien dossier actif si présent
kycDossierRepository.findDossierActifByMembre(membreId).ifPresent(ancien -> {
ancien.setActif(false);
ancien.setModifiePar(operateur);
kycDossierRepository.persist(ancien);
});
KycDossier dossier = KycDossier.builder()
.membre(membre)
.typePiece(request.getTypePiece())
.numeroPiece(request.getNumeroPiece())
.dateExpirationPiece(request.getDateExpirationPiece())
.pieceIdentiteRectoFileId(request.getPieceIdentiteRectoFileId())
.pieceIdentiteVersoFileId(request.getPieceIdentiteVersoFileId())
.justifDomicileFileId(request.getJustifDomicileFileId())
.estPep(Boolean.TRUE.equals(request.getEstPep()))
.nationalite(request.getNationalite())
.notesValidateur(request.getNotesValidateur())
.statut(StatutKyc.EN_COURS)
.anneeReference(LocalDate.now().getYear())
.build();
dossier.setCreePar(operateur);
kycDossierRepository.persist(dossier);
log.info("Dossier KYC soumis pour membre {} par {}", membreId, operateur);
return toDto(dossier);
}
/**
* Évalue le score de risque LCB-FT du membre et met à jour son dossier.
*
* @param membreId l'UUID du membre
* @return le dossier KYC mis à jour avec le score et niveau de risque
*/
@Transactional
public KycDossierResponse evaluerRisque(UUID membreId) {
KycDossier dossier = kycDossierRepository.findDossierActifByMembre(membreId)
.orElseThrow(() -> new NotFoundException("Aucun dossier KYC actif pour le membre : " + membreId));
int score = calculerScore(dossier);
NiveauRisqueKyc niveau = NiveauRisqueKyc.fromScore(score);
dossier.setScoreRisque(score);
dossier.setNiveauRisque(niveau);
kycDossierRepository.persist(dossier);
if (niveau == NiveauRisqueKyc.CRITIQUE || niveau == NiveauRisqueKyc.ELEVE) {
log.warn("Membre {} : niveau risque KYC {} (score {})", membreId, niveau, score);
auditService.logKycRisqueEleve(membreId, score, niveau.name());
}
return toDto(dossier);
}
/**
* Valide manuellement un dossier KYC (approbation par un agent habilité).
*/
@Transactional
public KycDossierResponse valider(UUID dossierId, UUID validateurId, String notes, String operateur) {
KycDossier dossier = kycDossierRepository.findByIdOptional(dossierId)
.orElseThrow(() -> new NotFoundException("Dossier KYC introuvable : " + dossierId));
int score = calculerScore(dossier);
dossier.setScoreRisque(score);
dossier.setNiveauRisque(NiveauRisqueKyc.fromScore(score));
dossier.setStatut(StatutKyc.VERIFIE);
dossier.setDateVerification(LocalDateTime.now());
dossier.setValidateurId(validateurId);
dossier.setNotesValidateur(notes);
dossier.setModifiePar(operateur);
kycDossierRepository.persist(dossier);
log.info("Dossier KYC {} validé par {} (score={})", dossierId, validateurId, score);
return toDto(dossier);
}
/**
* Refuse un dossier KYC avec motif.
*/
@Transactional
public KycDossierResponse refuser(UUID dossierId, UUID validateurId, String motif, String operateur) {
KycDossier dossier = kycDossierRepository.findByIdOptional(dossierId)
.orElseThrow(() -> new NotFoundException("Dossier KYC introuvable : " + dossierId));
dossier.setStatut(StatutKyc.REFUSE);
dossier.setDateVerification(LocalDateTime.now());
dossier.setValidateurId(validateurId);
dossier.setNotesValidateur(motif);
dossier.setModifiePar(operateur);
kycDossierRepository.persist(dossier);
log.info("Dossier KYC {} refusé par {}: {}", dossierId, validateurId, motif);
return toDto(dossier);
}
public Optional<KycDossierResponse> getDossierActif(UUID membreId) {
return kycDossierRepository.findDossierActifByMembre(membreId).map(this::toDto);
}
public List<KycDossierResponse> getDossiersEnAttente() {
return kycDossierRepository.findByStatut(StatutKyc.EN_COURS)
.stream().map(this::toDto).collect(Collectors.toList());
}
public List<KycDossierResponse> getDossiersPep() {
return kycDossierRepository.findPep()
.stream().map(this::toDto).collect(Collectors.toList());
}
public List<KycDossierResponse> getPiecesExpirantDansLes30Jours() {
LocalDate limite = LocalDate.now().plusDays(30);
return kycDossierRepository.findPiecesExpirantsAvant(limite)
.stream().map(this::toDto).collect(Collectors.toList());
}
// ── Calcul score ────────────────────────────────────────────────────────────
int calculerScore(KycDossier dossier) {
int score = 0;
if (dossier.isEstPep()) {
score += 40;
}
if (dossier.isPieceExpiree()) {
score += 20;
}
if (dossier.getJustifDomicileFileId() == null || dossier.getJustifDomicileFileId().isBlank()) {
score += 15;
}
boolean rectoManquant = dossier.getPieceIdentiteRectoFileId() == null
|| dossier.getPieceIdentiteRectoFileId().isBlank();
boolean versoManquant = dossier.getPieceIdentiteVersoFileId() == null
|| dossier.getPieceIdentiteVersoFileId().isBlank();
if (rectoManquant || versoManquant) {
score += 15;
}
if (dossier.getNationalite() != null && !PAYS_UEMOA.contains(dossier.getNationalite().toUpperCase())) {
score += 10;
}
return Math.min(score, 100);
}
// ── Mapping ─────────────────────────────────────────────────────────────────
private KycDossierResponse toDto(KycDossier d) {
KycDossierResponse dto = new KycDossierResponse();
dto.setId(d.getId());
dto.setDateCreation(d.getDateCreation());
dto.setDateModification(d.getDateModification());
dto.setCreePar(d.getCreePar());
dto.setModifiePar(d.getModifiePar());
dto.setVersion(d.getVersion());
dto.setActif(d.getActif());
if (d.getMembre() != null) {
dto.setMembreId(d.getMembre().getId());
dto.setMembreNomComplet(d.getMembre().getPrenom() + " " + d.getMembre().getNom());
dto.setMembreEmail(d.getMembre().getEmail());
}
dto.setTypePiece(d.getTypePiece());
dto.setNumeroPiece(d.getNumeroPiece());
dto.setDateExpirationPiece(d.getDateExpirationPiece());
dto.setPieceIdentiteRectoFileId(d.getPieceIdentiteRectoFileId());
dto.setPieceIdentiteVersoFileId(d.getPieceIdentiteVersoFileId());
dto.setJustifDomicileFileId(d.getJustifDomicileFileId());
dto.setStatut(d.getStatut());
dto.setNiveauRisque(d.getNiveauRisque());
dto.setScoreRisque(d.getScoreRisque());
dto.setEstPep(d.isEstPep());
dto.setNationalite(d.getNationalite());
dto.setDateVerification(d.getDateVerification());
dto.setValidateurId(d.getValidateurId());
dto.setNotesValidateur(d.getNotesValidateur());
dto.setAnneeReference(d.getAnneeReference());
return dto;
}
}

View File

@@ -89,6 +89,9 @@ public class MembreKeycloakSyncService {
@RestClient @RestClient
AdminRoleServiceClient roleServiceClient; AdminRoleServiceClient roleServiceClient;
@Inject
EmailTemplateService emailTemplateService;
/** /**
* Provisionne un compte Keycloak pour un Membre existant qui n'en a pas encore. * Provisionne un compte Keycloak pour un Membre existant qui n'en a pas encore.
* *
@@ -193,20 +196,37 @@ public class MembreKeycloakSyncService {
* @param membreId UUID du membre à activer dans Keycloak * @param membreId UUID du membre à activer dans Keycloak
* @throws NotFoundException si le membre n'existe pas en base * @throws NotFoundException si le membre n'existe pas en base
*/ */
@Transactional @Transactional(jakarta.transaction.Transactional.TxType.REQUIRES_NEW)
public void activerMembreDansKeycloak(java.util.UUID membreId) { public void activerMembreDansKeycloak(java.util.UUID membreId) {
LOGGER.info("Activation Keycloak (rôle MEMBRE_ACTIF) pour Membre ID: " + membreId); LOGGER.info("Activation Keycloak (rôle MEMBRE_ACTIF) pour Membre ID: " + membreId);
Membre membre = membreRepository.findByIdOptional(membreId) Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId)); .orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId));
// Provisionner le compte Keycloak s'il n'existe pas encore // Lier le compte Keycloak si absent : chercher par email avant de tenter un provisionnement
if (membre.getKeycloakId() == null) { if (membre.getKeycloakId() == null) {
LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet()); try {
provisionKeycloakUser(membreId); UserSearchCriteriaDTO criteria = new UserSearchCriteriaDTO();
// Recharger après persist dans provisionKeycloakUser criteria.setEmail(membre.getEmail());
criteria.setRealmName(DEFAULT_REALM);
criteria.setPageSize(1);
var result = userServiceClient.searchUsers(criteria);
if (result != null && result.getUsers() != null && !result.getUsers().isEmpty()) {
String kcId = result.getUsers().get(0).getId();
membre.setKeycloakId(UUID.fromString(kcId));
membreRepository.persist(membre);
LOGGER.info("Compte Keycloak existant lié pour " + membre.getEmail() + "" + kcId);
} else {
LOGGER.info("Compte Keycloak absent — provisionnement pour " + membre.getNomComplet());
provisionKeycloakUser(membreId);
}
} catch (Exception e) {
LOGGER.warning("Recherche Keycloak par email échouée, tentative provisionnement : " + e.getMessage());
provisionKeycloakUser(membreId);
}
// Recharger après liaison/provisionnement
membre = membreRepository.findByIdOptional(membreId) membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId)); .orElseThrow(() -> new NotFoundException("Membre non trouvé après liaison Keycloak: " + membreId));
} }
String keycloakUserId = membre.getKeycloakId().toString(); String keycloakUserId = membre.getKeycloakId().toString();
@@ -247,19 +267,36 @@ public class MembreKeycloakSyncService {
* @param membreId UUID du membre à promouvoir dans Keycloak * @param membreId UUID du membre à promouvoir dans Keycloak
* @throws NotFoundException si le membre n'existe pas en base * @throws NotFoundException si le membre n'existe pas en base
*/ */
@Transactional @Transactional(jakarta.transaction.Transactional.TxType.REQUIRES_NEW)
public void promouvoirAdminOrganisationDansKeycloak(java.util.UUID membreId) { public void promouvoirAdminOrganisationDansKeycloak(java.util.UUID membreId) {
LOGGER.info("Promotion Keycloak (rôle ADMIN_ORGANISATION) pour Membre ID: " + membreId); LOGGER.info("Promotion Keycloak (rôle ADMIN_ORGANISATION) pour Membre ID: " + membreId);
Membre membre = membreRepository.findByIdOptional(membreId) Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId)); .orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId));
// Provisionner le compte Keycloak s'il n'existe pas encore // Lier le compte Keycloak si absent : chercher par email avant de tenter un provisionnement
if (membre.getKeycloakId() == null) { if (membre.getKeycloakId() == null) {
LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet()); try {
provisionKeycloakUser(membreId); UserSearchCriteriaDTO criteria = new UserSearchCriteriaDTO();
criteria.setEmail(membre.getEmail());
criteria.setRealmName(DEFAULT_REALM);
criteria.setPageSize(1);
var result = userServiceClient.searchUsers(criteria);
if (result != null && result.getUsers() != null && !result.getUsers().isEmpty()) {
String kcId = result.getUsers().get(0).getId();
membre.setKeycloakId(UUID.fromString(kcId));
membreRepository.persist(membre);
LOGGER.info("Compte Keycloak existant lié pour " + membre.getEmail() + "" + kcId);
} else {
LOGGER.info("Compte Keycloak absent — provisionnement pour " + membre.getNomComplet());
provisionKeycloakUser(membreId);
}
} catch (Exception e) {
LOGGER.warning("Recherche Keycloak par email échouée, tentative provisionnement : " + e.getMessage());
provisionKeycloakUser(membreId);
}
membre = membreRepository.findByIdOptional(membreId) membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId)); .orElseThrow(() -> new NotFoundException("Membre non trouvé après liaison Keycloak: " + membreId));
} }
String keycloakUserId = membre.getKeycloakId().toString(); String keycloakUserId = membre.getKeycloakId().toString();
@@ -735,6 +772,28 @@ public class MembreKeycloakSyncService {
} }
} }
LOGGER.info("Premier login complété pour : " + membre.getEmail()); LOGGER.info("Premier login complété pour : " + membre.getEmail());
// Email de bienvenue (non bloquant)
if (doitActiver && membre.getEmail() != null) {
try {
String orgNom = "";
try {
var memberships = membre.getMembresOrganisations();
if (memberships != null && !memberships.isEmpty()) {
orgNom = memberships.iterator().next().getOrganisation().getNom();
}
} catch (Exception ignore) {}
emailTemplateService.envoyerBienvenue(
membre.getEmail(),
membre.getPrenom() != null ? membre.getPrenom() : "",
membre.getNom() != null ? membre.getNom() : "",
orgNom,
null);
} catch (Exception e) {
LOGGER.warning("Email bienvenue ignoré (non bloquant) : " + e.getMessage());
}
}
return PremierLoginResultat.COMPLETE; return PremierLoginResultat.COMPLETE;
} }

View File

@@ -1283,6 +1283,25 @@ public class MembreService {
.getSingleResult() > 0; .getSingleResult() > 0;
} }
/**
* Vérifie si une organisation a reçu un paiement (confirmé ou validé).
* Utilisé pour auto-activer l'admin dès que le paiement est reçu,
* sans attendre la validation super admin.
*
* @param orgId UUID de l'organisation
* @return true si la souscription est ACTIVE ou en PAIEMENT_CONFIRME/VALIDEE
*/
public boolean orgHasPaidSubscription(UUID orgId) {
if (orgId == null) return false;
return entityManager.createQuery(
"SELECT COUNT(s) FROM SouscriptionOrganisation s " +
"WHERE s.organisation.id = :orgId " +
"AND (s.statut = 'ACTIVE' OR s.statutValidation IN ('PAIEMENT_CONFIRME', 'VALIDEE'))",
Long.class)
.setParameter("orgId", orgId)
.getSingleResult() > 0;
}
/** /**
* Lie un membre à une organisation et incrémente le quota de la souscription. * Lie un membre à une organisation et incrémente le quota de la souscription.
* Utilisé lors de la création unitaire ou de l'import massif. * Utilisé lors de la création unitaire ou de l'import massif.

View File

@@ -0,0 +1,348 @@
package dev.lions.unionflow.server.service;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.text.Normalizer;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Service de migration one-shot : crée les Keycloak 26 Organizations correspondant
* à chaque Organisation UnionFlow, assigne les rôles standards et migre les memberships.
*
* <p>Idempotent : si {@code keycloak_org_id} est déjà renseigné pour une org,
* elle est ignorée (pas de doublon).
*
* <p>Déclenchement : endpoint admin {@code POST /api/admin/keycloak/migrer-organisations}.
*/
@Slf4j
@ApplicationScoped
public class MigrerOrganisationsVersKeycloakService {
/** Rôles Organization standards créés dans chaque Keycloak Organization. */
private static final List<String> ROLES_STANDARDS = List.of(
"ADMIN_ORGANISATION", "TRESORIER", "SECRETAIRE",
"COMMISSAIRE_COMPTES", "MEMBRE_ACTIF"
);
@ConfigProperty(name = "keycloak.admin.url", defaultValue = "http://localhost:8180")
String keycloakUrl;
@ConfigProperty(name = "keycloak.admin.username", defaultValue = "admin")
String adminUsername;
@ConfigProperty(name = "keycloak.admin.password", defaultValue = "admin")
String adminPassword;
@ConfigProperty(name = "keycloak.admin.realm", defaultValue = "unionflow")
String realm;
@Inject
OrganisationRepository organisationRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
/**
* Point d'entrée principal — migre toutes les organisations sans {@code keycloak_org_id}.
*
* @return rapport de migration
*/
@Transactional
public MigrationReport migrerToutesLesOrganisations() throws Exception {
String token = getAdminToken();
List<Organisation> orgs = organisationRepository.listAll();
int crees = 0, ignores = 0, erreurs = 0;
for (Organisation org : orgs) {
if (org.getKeycloakOrgId() != null) {
ignores++;
log.debug("Org '{}' déjà migrée (kcOrgId={}), ignorée.", org.getNom(), org.getKeycloakOrgId());
continue;
}
try {
UUID kcOrgId = creerOrganisationKeycloak(token, org);
creerRolesOrganisation(token, kcOrgId);
migrerMemberships(token, kcOrgId, org);
org.setKeycloakOrgId(kcOrgId);
organisationRepository.persist(org);
crees++;
log.info("Organisation '{}' migrée → keycloak_org_id={}", org.getNom(), kcOrgId);
} catch (Exception e) {
erreurs++;
log.error("Échec migration org '{}' (id={}): {}", org.getNom(), org.getId(), e.getMessage(), e);
}
}
return new MigrationReport(orgs.size(), crees, ignores, erreurs);
}
// ── Création Organization Keycloak ──────────────────────────────────────────
private UUID creerOrganisationKeycloak(String token, Organisation org) throws Exception {
ObjectNode body = mapper.createObjectNode();
body.put("name", org.getNom());
body.put("alias", slugify(org.getNom()));
body.put("enabled", true);
ObjectNode attrs = mapper.createObjectNode();
attrs.putArray("unionflow_id").add(org.getId().toString());
attrs.putArray("type_organisation").add(org.getTypeOrganisation() != null ? org.getTypeOrganisation() : "");
if (org.getCategorieType() != null) {
attrs.putArray("categorie").add(org.getCategorieType());
}
body.set("attributes", attrs);
// Ajouter le domaine email si disponible
if (org.getEmail() != null) {
String domaine = org.getEmail().contains("@") ? org.getEmail().split("@")[1] : "";
if (!domaine.isBlank()) {
ArrayNode domains = mapper.createArrayNode();
ObjectNode domainObj = mapper.createObjectNode();
domainObj.put("name", domaine);
domainObj.put("verified", false);
domains.add(domainObj);
body.set("domains", domains);
}
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/organizations"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
// 201 Created → Location header contient l'URL avec l'ID
if (response.statusCode() == 201) {
String location = response.headers().firstValue("Location").orElseThrow(
() -> new RuntimeException("Keycloak 201 mais sans header Location pour org: " + org.getNom()));
String kcOrgId = location.substring(location.lastIndexOf('/') + 1);
return UUID.fromString(kcOrgId);
}
// 409 = alias déjà pris → chercher l'org existante par alias
if (response.statusCode() == 409) {
log.warn("Organisation '{}' déjà présente dans Keycloak (409), recherche par alias.", org.getNom());
return chercherOrganisationParAlias(token, slugify(org.getNom()));
}
throw new RuntimeException("Échec création Keycloak Org '" + org.getNom()
+ "' (HTTP " + response.statusCode() + "): " + response.body());
}
private UUID chercherOrganisationParAlias(String token, String alias) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/organizations?search=" + alias + "&max=10"))
.header("Authorization", "Bearer " + token)
.GET()
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Impossible de rechercher l'org par alias '" + alias + "'");
}
var results = mapper.readTree(response.body());
for (var node : results) {
if (alias.equals(node.path("alias").asText())) {
return UUID.fromString(node.path("id").asText());
}
}
throw new RuntimeException("Organisation avec alias '" + alias + "' introuvable dans Keycloak.");
}
// ── Création rôles standards ────────────────────────────────────────────────
private void creerRolesOrganisation(String token, UUID kcOrgId) throws Exception {
for (String roleName : ROLES_STANDARDS) {
ObjectNode roleBody = mapper.createObjectNode();
roleBody.put("name", roleName);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
+ "/organizations/" + kcOrgId + "/roles"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(roleBody)))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201 && response.statusCode() != 409) {
log.warn("Impossible de créer le rôle '{}' pour kcOrgId={} (HTTP {}): {}",
roleName, kcOrgId, response.statusCode(), response.body());
}
}
}
// ── Migration memberships ───────────────────────────────────────────────────
private void migrerMemberships(String token, UUID kcOrgId, Organisation org) {
List<MembreOrganisation> memberships = membreOrganisationRepository
.find("organisation.id = ?1 AND actif = true", org.getId())
.list();
for (MembreOrganisation mo : memberships) {
if (mo.getMembre() == null || mo.getMembre().getKeycloakId() == null) {
continue;
}
String kcUserId = mo.getMembre().getKeycloakId().toString();
try {
ajouterMembreKeycloakOrg(token, kcOrgId, kcUserId);
if (mo.getRoleOrg() != null && ROLES_STANDARDS.contains(mo.getRoleOrg())) {
assignerRoleOrganisation(token, kcOrgId, kcUserId, mo.getRoleOrg());
}
} catch (Exception e) {
log.warn("Impossible de migrer le membership keycloakId={} → kcOrg={}: {}",
kcUserId, kcOrgId, e.getMessage());
}
}
}
private void ajouterMembreKeycloakOrg(String token, UUID kcOrgId, String kcUserId) throws Exception {
ObjectNode body = mapper.createObjectNode();
body.put("id", kcUserId);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
+ "/organizations/" + kcOrgId + "/members"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201 && response.statusCode() != 409) {
throw new RuntimeException("HTTP " + response.statusCode() + ": " + response.body());
}
}
private void assignerRoleOrganisation(String token, UUID kcOrgId, String kcUserId,
String roleName) throws Exception {
// 1. Récupérer l'ID du rôle
HttpRequest getRoles = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
+ "/organizations/" + kcOrgId + "/roles"))
.header("Authorization", "Bearer " + token)
.GET()
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> rolesResponse = httpClient.send(getRoles, HttpResponse.BodyHandlers.ofString());
if (rolesResponse.statusCode() != 200) return;
var roles = mapper.readTree(rolesResponse.body());
String roleId = null;
for (var role : roles) {
if (roleName.equals(role.path("name").asText())) {
roleId = role.path("id").asText();
break;
}
}
if (roleId == null) return;
// 2. Assigner le rôle au membre
ArrayNode assignBody = mapper.createArrayNode();
ObjectNode roleRef = mapper.createObjectNode();
roleRef.put("id", roleId);
roleRef.put("name", roleName);
assignBody.add(roleRef);
HttpRequest assignRequest = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
+ "/organizations/" + kcOrgId + "/members/" + kcUserId + "/roles"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(assignBody)))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> assignResponse = httpClient.send(assignRequest, HttpResponse.BodyHandlers.ofString());
if (assignResponse.statusCode() != 201 && assignResponse.statusCode() != 204) {
log.warn("Impossible d'assigner le rôle '{}' à l'utilisateur {} (HTTP {})",
roleName, kcUserId, assignResponse.statusCode());
}
}
// ── Auth admin ──────────────────────────────────────────────────────────────
private String getAdminToken() throws Exception {
String body = "client_id=admin-cli"
+ "&username=" + java.net.URLEncoder.encode(adminUsername, java.nio.charset.StandardCharsets.UTF_8)
+ "&password=" + java.net.URLEncoder.encode(adminPassword, java.nio.charset.StandardCharsets.UTF_8)
+ "&grant_type=password";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/realms/master/protocol/openid-connect/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Échec auth admin Keycloak (HTTP " + response.statusCode() + ")");
}
return mapper.readTree(response.body()).get("access_token").asText();
}
// ── Utilitaires ─────────────────────────────────────────────────────────────
private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^a-z0-9-]");
private static final Pattern MULTIPLE_DASHES = Pattern.compile("-{2,}");
static String slugify(String input) {
if (input == null) return "org-" + UUID.randomUUID().toString().substring(0, 8);
String normalized = Normalizer.normalize(input.toLowerCase(), Normalizer.Form.NFD)
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
String slug = NON_ALPHANUMERIC.matcher(normalized.replace(' ', '-')).replaceAll("");
slug = MULTIPLE_DASHES.matcher(slug).replaceAll("-").replaceAll("^-|-$", "");
return slug.isBlank() ? "org-" + UUID.randomUUID().toString().substring(0, 8) : slug;
}
// ── Rapport ─────────────────────────────────────────────────────────────────
public record MigrationReport(int total, int crees, int ignores, int erreurs) {
public boolean success() {
return erreurs == 0;
}
}
}

View File

@@ -51,6 +51,9 @@ public class NotificationService {
@Inject @Inject
KeycloakService keycloakService; KeycloakService keycloakService;
@Inject
FirebasePushService firebasePushService;
/** /**
* Crée un nouveau template de notification * Crée un nouveau template de notification
* *
@@ -91,14 +94,19 @@ public class NotificationService {
notificationRepository.persist(notification); notificationRepository.persist(notification);
LOG.infof("Notification créée avec succès: ID=%s", notification.getId()); LOG.infof("Notification créée avec succès: ID=%s", notification.getId());
// Envoi immédiat si type EMAIL // Envoi immédiat selon le canal
if ("EMAIL".equals(notification.getTypeNotification())) { if ("EMAIL".equals(notification.getTypeNotification())) {
try { try {
envoyerEmail(notification); envoyerEmail(notification);
} catch (Exception e) { } catch (Exception e) {
LOG.errorf("Erreur lors de l'envoi de l'email pour la notification %s: %s", notification.getId(), LOG.errorf("Erreur lors de l'envoi de l'email pour la notification %s: %s", notification.getId(),
e.getMessage()); e.getMessage());
// On ne relance pas l'exception pour ne pas bloquer la transaction de création }
} else if ("PUSH".equals(notification.getTypeNotification())) {
try {
envoyerPush(notification);
} catch (Exception e) {
LOG.warnf("Erreur push notification %s (non bloquant): %s", notification.getId(), e.getMessage());
} }
} }
@@ -381,6 +389,38 @@ public class NotificationService {
return notification; return notification;
} }
/**
* Envoie une notification push FCM pour une notification.
*/
private void envoyerPush(Notification notification) {
if (notification.getMembre() == null) {
LOG.warnf("Impossible d'envoyer le push pour la notification %s : pas de membre", notification.getId());
notification.setStatut("ECHEC_ENVOI");
notification.setMessageErreur("Pas de membre défini");
return;
}
String fcmToken = notification.getMembre().getFcmToken();
if (fcmToken == null || fcmToken.isBlank()) {
LOG.debugf("Membre %s sans token FCM — push ignoré", notification.getMembre().getId());
notification.setStatut("IGNOREE");
notification.setMessageErreur("Pas de token FCM");
return;
}
boolean ok = firebasePushService.envoyerNotification(
fcmToken,
notification.getSujet(),
notification.getCorps(),
java.util.Map.of("notificationId", notification.getId().toString()));
if (ok) {
notification.setStatut("ENVOYEE");
notification.setDateEnvoi(java.time.LocalDateTime.now());
} else {
notification.setStatut("ECHEC_ENVOI");
notification.setMessageErreur("FCM: envoi échoué");
}
notificationRepository.persist(notification);
}
/** /**
* Envoie un email pour une notification * Envoie un email pour une notification
*/ */
@@ -394,9 +434,12 @@ public class NotificationService {
try { try {
LOG.infof("Envoi de l'email à %s", notification.getMembre().getEmail()); LOG.infof("Envoi de l'email à %s", notification.getMembre().getEmail());
mailer.send(Mail.withText(notification.getMembre().getEmail(), String corps = notification.getCorps();
notification.getSujet(), boolean isHtml = corps != null && (corps.startsWith("<html") || corps.startsWith("<!DOCTYPE") || corps.startsWith("<HTML"));
notification.getCorps())); // TODO: Support HTML body if needed Mail mail = isHtml
? Mail.withHtml(notification.getMembre().getEmail(), notification.getSujet(), corps)
: Mail.withText(notification.getMembre().getEmail(), notification.getSujet(), corps);
mailer.send(mail);
notification.setStatut("ENVOYEE"); notification.setStatut("ENVOYEE");
notification.setDateEnvoi(LocalDateTime.now()); notification.setDateEnvoi(LocalDateTime.now());

View File

@@ -1,6 +1,8 @@
package dev.lions.unionflow.server.service; package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest; import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse; import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse; import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
import dev.lions.unionflow.server.api.dto.reference.response.TypeReferenceResponse; import dev.lions.unionflow.server.api.dto.reference.response.TypeReferenceResponse;
@@ -66,6 +68,12 @@ public class PaiementService {
@Inject @Inject
io.quarkus.security.identity.SecurityIdentity securityIdentity; io.quarkus.security.identity.SecurityIdentity securityIdentity;
@Inject
dev.lions.unionflow.server.repository.MembreOrganisationRepository membreOrganisationRepository;
@Inject
NotificationService notificationService;
/** /**
* Crée un nouveau paiement * Crée un nouveau paiement
* *
@@ -509,13 +517,30 @@ public class PaiementService {
paiementRepository.persist(paiement); paiementRepository.persist(paiement);
// TODO: Créer une notification pour le trésorier // Notifier les trésoriers de l'organisation que ce paiement manuel attend validation
// notificationService.creerNotification( try {
// "VALIDATION_PAIEMENT_REQUIS", membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId())
// "Validation paiement manuel requis", .map(mo -> mo.getOrganisation().getId())
// "Le membre " + membreConnecte.getNumeroMembre() + " a déclaré un paiement manuel à valider.", .ifPresent(orgId -> {
// tresorierIds List<UUID> tresorierIds = membreOrganisationRepository
// ); .findByRoleOrgAndOrganisationId("TRESORIER", orgId)
.stream()
.map(mo -> mo.getMembre().getId())
.collect(Collectors.toList());
if (!tresorierIds.isEmpty()) {
notificationService.envoyerNotificationsGroupees(
tresorierIds,
"Validation paiement manuel requis",
"Le membre " + membreConnecte.getNumeroMembre()
+ " a déclaré un paiement manuel (" + paiement.getNumeroReference()
+ ") à valider.",
List.of("IN_APP"));
}
});
} catch (Exception e) {
LOG.warnf("Erreur notification trésorier pour paiement %s (non bloquant): %s",
paiement.getNumeroReference(), e.getMessage());
}
LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)", LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)",
paiement.getId(), paiement.getNumeroReference()); paiement.getId(), paiement.getNumeroReference());
@@ -586,6 +611,39 @@ public class PaiementService {
.build(); .build();
} }
// ── Webhook multi-provider ────────────────────────────────────────────────
/**
* Met à jour le statut d'un paiement depuis un événement webhook normalisé.
* Appelé par PaymentOrchestrator.handleEvent() — aucun contexte utilisateur requis.
*/
@Transactional
public void mettreAJourStatutDepuisWebhook(PaymentEvent event) {
Optional<Paiement> opt = paiementRepository.findByNumeroReference(event.reference());
if (opt.isEmpty()) {
LOG.warnf("Webhook reçu pour référence inconnue : %s (provider externalId=%s)",
event.reference(), event.externalId());
return;
}
Paiement paiement = opt.get();
PaymentStatus status = event.status();
if (PaymentStatus.SUCCESS.equals(status)) {
paiement.setStatutPaiement("PAIEMENT_CONFIRME");
paiement.setDateValidation(LocalDateTime.now());
paiement.setReferenceExterne(event.externalId());
} else if (PaymentStatus.FAILED.equals(status) || PaymentStatus.CANCELLED.equals(status)
|| PaymentStatus.EXPIRED.equals(status)) {
paiement.setStatutPaiement("ANNULE");
paiement.setReferenceExterne(event.externalId());
}
// INITIATED / PROCESSING : aucun changement de statut requis
paiementRepository.persist(paiement);
LOG.infof("Statut paiement mis à jour via webhook : ref=%s statut=%s → %s",
event.reference(), status, paiement.getStatutPaiement());
}
// ======================================== // ========================================
// MÉTHODES PRIVÉES // MÉTHODES PRIVÉES
// ======================================== // ========================================

View File

@@ -88,6 +88,9 @@ public class SouscriptionService {
@Inject @Inject
MembreKeycloakSyncService keycloakSyncService; MembreKeycloakSyncService keycloakSyncService;
@Inject
EmailTemplateService emailTemplateService;
// ── Catalogue ───────────────────────────────────────────────────────────── // ── Catalogue ─────────────────────────────────────────────────────────────
/** /**
@@ -302,6 +305,9 @@ public class SouscriptionService {
} catch (Exception e) { } catch (Exception e) {
LOG.errorf("Activation compte échouée après paiement souscription=%s: %s — la souscription reste VALIDEE", souscriptionId, e.getMessage()); LOG.errorf("Activation compte échouée après paiement souscription=%s: %s — la souscription reste VALIDEE", souscriptionId, e.getMessage());
} }
// Email de confirmation de souscription (non bloquant)
envoyerEmailSouscriptionActive(souscription, dateDebut, dateFin);
} }
// ── Validation SuperAdmin ────────────────────────────────────────────────── // ── Validation SuperAdmin ──────────────────────────────────────────────────
@@ -399,6 +405,9 @@ public class SouscriptionService {
// Activer le membre admin de l'organisation // Activer le membre admin de l'organisation
activerAdminOrganisation(souscription.getOrganisation().getId()); activerAdminOrganisation(souscription.getOrganisation().getId());
// Email de confirmation de souscription (non bloquant)
envoyerEmailSouscriptionActive(souscription, dateDebut, dateFin);
LOG.infof("Souscription %s approuvée — compte actif jusqu'au %s", souscriptionId, dateFin); LOG.infof("Souscription %s approuvée — compte actif jusqu'au %s", souscriptionId, dateFin);
} }
@@ -615,6 +624,34 @@ public class SouscriptionService {
} }
} }
// ── Email notifications ───────────────────────────────────────────────────
private void envoyerEmailSouscriptionActive(SouscriptionOrganisation s,
LocalDate dateDebut, LocalDate dateFin) {
try {
String email = securiteHelper.resolveEmail();
if (email == null) return;
Membre admin = membreRepository.findByEmail(email).orElse(null);
if (admin == null || admin.getEmail() == null) return;
FormuleAbonnement f = s.getFormule();
emailTemplateService.envoyerConfirmationSouscription(
admin.getEmail(),
(admin.getPrenom() != null ? admin.getPrenom() : "") + " " + (admin.getNom() != null ? admin.getNom() : ""),
s.getOrganisation() != null ? s.getOrganisation().getNom() : "",
f != null && f.getLibelle() != null ? f.getLibelle() : "",
s.getMontantTotal() != null ? s.getMontantTotal() : BigDecimal.ZERO,
s.getTypePeriode() != null ? s.getTypePeriode().name() : "MENSUEL",
dateDebut, dateFin,
f != null ? f.getMaxMembres() : null,
f != null ? f.getMaxStockageMo() : null,
f != null && Boolean.TRUE.equals(f.getApiAccess()),
f != null && Boolean.TRUE.equals(f.getSupportPrioritaire()));
} catch (Exception e) {
LOG.warnf("Email souscription ignoré (non bloquant) : %s", e.getMessage());
}
}
// ── Matrice tarifaire de référence ──────────────────────────────────────── // ── Matrice tarifaire de référence ────────────────────────────────────────
/** /**

View File

@@ -0,0 +1,207 @@
package dev.lions.unionflow.server.service.mutuelle;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave;
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Calcul automatique des intérêts sur épargne et des dividendes sur parts sociales.
*
* <p>Le scheduler tourne chaque jour à 02:00 et vérifie si un calcul est dû
* selon la périodicité configurée par organisation.
*/
@ApplicationScoped
public class InteretsEpargneService {
private static final Logger LOG = Logger.getLogger(InteretsEpargneService.class);
@Inject ParametresFinanciersMutuellRepository parametresRepo;
@Inject CompteEpargneRepository compteEpargneRepository;
@Inject TransactionEpargneRepository transactionEpargneRepository;
@Inject ComptePartsSocialesRepository comptePartsSocialesRepository;
@Inject TransactionPartsSocialesRepository transactionPartsSocialesRepository;
/**
* Scheduler quotidien — calcule les intérêts pour toutes les organisations dont
* la date prochaine_calcul_interets est aujourd'hui ou dans le passé.
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void calculerInteretsScheduled() {
LocalDate aujourd_hui = LocalDate.now();
List<ParametresFinanciersMutuelle> tous = parametresRepo.listAll();
for (ParametresFinanciersMutuelle params : tous) {
if (params.getProchaineCalculInterets() != null
&& !params.getProchaineCalculInterets().isAfter(aujourd_hui)) {
try {
calculerInteretsPourOrg(params);
} catch (Exception e) {
LOG.errorf("Erreur calcul intérêts org %s: %s",
params.getOrganisation().getId(), e.getMessage());
}
}
}
}
/**
* Déclenchement manuel par un admin pour une organisation donnée.
* @return résumé : nombre de comptes traités, montant total crédité
*/
@Transactional
public Map<String, Object> calculerManuellement(UUID orgId) {
ParametresFinanciersMutuelle params = parametresRepo.findByOrganisation(orgId)
.orElseThrow(() -> new IllegalArgumentException(
"Aucun paramètre financier configuré pour cette organisation. "
+ "Créez d'abord les paramètres via POST /api/v1/mutuelle/parametres-financiers."));
return calculerInteretsPourOrg(params);
}
// ─── Internal ─────────────────────────────────────────────────────────────
Map<String, Object> calculerInteretsPourOrg(ParametresFinanciersMutuelle params) {
UUID orgId = params.getOrganisation().getId();
LOG.infof("Calcul intérêts org %s — taux épargne=%.4f, taux parts=%.4f",
orgId, params.getTauxInteretAnnuelEpargne(), params.getTauxDividendePartsAnnuel());
int nbEpargne = calculerInteretsEpargne(params, orgId);
int nbParts = calculerDividendesParts(params, orgId);
// Mise à jour des dates
params.setDernierCalculInterets(LocalDate.now());
params.setDernierNbComptesTraites(nbEpargne + nbParts);
params.setProchaineCalculInterets(prochaineDateCalcul(params));
LOG.infof("Calcul terminé org %s — %d comptes épargne, %d comptes parts", orgId, nbEpargne, nbParts);
return Map.of(
"organisationId", orgId.toString(),
"comptesEpargneTraites", nbEpargne,
"comptesPartsTraites", nbParts,
"dateCalcul", LocalDate.now().toString(),
"prochaineCalcul", params.getProchaineCalculInterets().toString()
);
}
private int calculerInteretsEpargne(ParametresFinanciersMutuelle params, UUID orgId) {
if (params.getTauxInteretAnnuelEpargne().compareTo(BigDecimal.ZERO) == 0) return 0;
List<CompteEpargne> comptes = compteEpargneRepository
.find("organisation.id = ?1 AND statut = ?2 AND actif = true",
orgId, StatutCompteEpargne.ACTIF)
.list();
BigDecimal tauxPeriodique = calculerTauxPeriodique(
params.getTauxInteretAnnuelEpargne(), params.getPeriodiciteCalcul());
BigDecimal seuil = params.getSeuilMinEpargneInterets() != null
? params.getSeuilMinEpargneInterets() : BigDecimal.ZERO;
int count = 0;
for (CompteEpargne compte : comptes) {
BigDecimal solde = compte.getSoldeActuel().subtract(compte.getSoldeBloque());
if (solde.compareTo(seuil) <= 0) continue;
BigDecimal interets = solde.multiply(tauxPeriodique).setScale(0, RoundingMode.HALF_UP);
if (interets.compareTo(BigDecimal.ZERO) <= 0) continue;
compte.setSoldeActuel(compte.getSoldeActuel().add(interets));
compte.setDateDerniereTransaction(LocalDate.now());
TransactionEpargne tx = TransactionEpargne.builder()
.compte(compte)
.type(TypeTransactionEpargne.PAIEMENT_INTERETS)
.montant(interets)
.soldeAvant(solde)
.soldeApres(compte.getSoldeActuel())
.motif("Intérêts " + params.getPeriodiciteCalcul().toLowerCase()
+ " — taux " + params.getTauxInteretAnnuelEpargne()
.multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP) + "%/an")
.dateTransaction(LocalDateTime.now())
.statutExecution(StatutTransactionWave.REUSSIE)
.origineFonds("Calcul automatique intérêts")
.build();
transactionEpargneRepository.persist(tx);
count++;
}
return count;
}
private int calculerDividendesParts(ParametresFinanciersMutuelle params, UUID orgId) {
if (params.getTauxDividendePartsAnnuel().compareTo(BigDecimal.ZERO) == 0) return 0;
List<ComptePartsSociales> comptes = comptePartsSocialesRepository.findByOrganisation(orgId);
BigDecimal tauxPeriodique = calculerTauxPeriodique(
params.getTauxDividendePartsAnnuel(), params.getPeriodiciteCalcul());
int count = 0;
for (ComptePartsSociales compte : comptes) {
if (compte.getMontantTotal().compareTo(BigDecimal.ZERO) <= 0) continue;
BigDecimal dividende = compte.getMontantTotal()
.multiply(tauxPeriodique).setScale(0, RoundingMode.HALF_UP);
if (dividende.compareTo(BigDecimal.ZERO) <= 0) continue;
// Dividende: enregistré comme transaction (NB: ne modifie pas le nombre de parts)
int partsRef = 1; // transaction symbolique — montant est le vrai indicateur
TransactionPartsSociales tx = TransactionPartsSociales.builder()
.compte(compte)
.typeTransaction(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE)
.nombreParts(partsRef)
.montant(dividende)
.soldePartsAvant(compte.getNombreParts())
.soldePartsApres(compte.getNombreParts())
.motif("Dividende " + params.getPeriodiciteCalcul().toLowerCase()
+ " — taux " + params.getTauxDividendePartsAnnuel()
.multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP) + "%/an")
.dateTransaction(LocalDateTime.now())
.build();
compte.setTotalDividendesRecus(compte.getTotalDividendesRecus().add(dividende));
compte.setDateDerniereOperation(LocalDate.now());
transactionPartsSocialesRepository.persist(tx);
count++;
}
return count;
}
/** Taux périodique = taux annuel / nombre de périodes par an */
private BigDecimal calculerTauxPeriodique(BigDecimal tauxAnnuel, String periodicite) {
int diviseur = switch (periodicite.toUpperCase()) {
case "MENSUEL" -> 12;
case "TRIMESTRIEL" -> 4;
default -> 1; // ANNUEL
};
return tauxAnnuel.divide(BigDecimal.valueOf(diviseur), 8, RoundingMode.HALF_UP);
}
private LocalDate prochaineDateCalcul(ParametresFinanciersMutuelle params) {
LocalDate base = LocalDate.now();
return switch (params.getPeriodiciteCalcul().toUpperCase()) {
case "MENSUEL" -> base.plusMonths(1);
case "TRIMESTRIEL" -> base.plusMonths(3);
default -> base.plusYears(1);
};
}
}

View File

@@ -0,0 +1,66 @@
package dev.lions.unionflow.server.service.mutuelle;
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import java.time.LocalDate;
import java.util.UUID;
@ApplicationScoped
public class ParametresFinanciersService {
@Inject ParametresFinanciersMutuellRepository repo;
@Inject OrganisationRepository organisationRepository;
public ParametresFinanciersMutuellResponse getByOrganisation(UUID orgId) {
ParametresFinanciersMutuelle p = repo.findByOrganisation(orgId)
.orElseThrow(() -> new NotFoundException("Aucun paramètre financier pour cette organisation."));
return toDto(p);
}
@Transactional
public ParametresFinanciersMutuellResponse creerOuMettrAJour(ParametresFinanciersMutuellRequest req) {
Organisation org = organisationRepository.findByIdOptional(UUID.fromString(req.getOrganisationId()))
.orElseThrow(() -> new NotFoundException("Organisation introuvable: " + req.getOrganisationId()));
ParametresFinanciersMutuelle params = repo.findByOrganisation(org.getId())
.orElseGet(() -> ParametresFinanciersMutuelle.builder().organisation(org).build());
params.setValeurNominaleParDefaut(req.getValeurNominaleParDefaut());
params.setTauxInteretAnnuelEpargne(req.getTauxInteretAnnuelEpargne());
params.setTauxDividendePartsAnnuel(req.getTauxDividendePartsAnnuel());
params.setPeriodiciteCalcul(req.getPeriodiciteCalcul().toUpperCase());
if (req.getSeuilMinEpargneInterets() != null) {
params.setSeuilMinEpargneInterets(req.getSeuilMinEpargneInterets());
}
if (params.getProchaineCalculInterets() == null) {
params.setProchaineCalculInterets(LocalDate.now().plusMonths(1));
}
repo.persist(params);
return toDto(params);
}
private ParametresFinanciersMutuellResponse toDto(ParametresFinanciersMutuelle p) {
return ParametresFinanciersMutuellResponse.builder()
.organisationId(p.getOrganisation().getId().toString())
.organisationNom(p.getOrganisation().getNom())
.valeurNominaleParDefaut(p.getValeurNominaleParDefaut())
.tauxInteretAnnuelEpargne(p.getTauxInteretAnnuelEpargne())
.tauxDividendePartsAnnuel(p.getTauxDividendePartsAnnuel())
.periodiciteCalcul(p.getPeriodiciteCalcul())
.seuilMinEpargneInterets(p.getSeuilMinEpargneInterets())
.prochaineCalculInterets(p.getProchaineCalculInterets())
.dernierCalculInterets(p.getDernierCalculInterets())
.dernierNbComptesTraites(p.getDernierNbComptesTraites())
.build();
}
}

View File

@@ -0,0 +1,336 @@
package dev.lions.unionflow.server.service.mutuelle;
import com.lowagie.text.*;
import com.lowagie.text.Font;
import com.lowagie.text.pdf.*;
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotFoundException;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Génère les relevés de compte en PDF (OpenPDF).
* Deux types : relevé épargne, relevé parts sociales.
*/
@ApplicationScoped
public class ReleveComptePdfService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
private static final Color BLEU_UNIONFLOW = new Color(30, 90, 160);
private static final Color GRIS_ENTETE = new Color(240, 240, 245);
@Inject CompteEpargneRepository compteEpargneRepository;
@Inject TransactionEpargneRepository transactionEpargneRepository;
@Inject ComptePartsSocialesRepository comptePartsSocialesRepository;
@Inject TransactionPartsSocialesRepository transactionPartsSocialesRepository;
// ─── Relevé Épargne ────────────────────────────────────────────────────────
public byte[] genererReleveEpargne(UUID compteId, LocalDate dateDebut, LocalDate dateFin) {
CompteEpargne compte = compteEpargneRepository.findByIdOptional(compteId)
.orElseThrow(() -> new NotFoundException("Compte épargne introuvable: " + compteId));
List<TransactionEpargne> txs = transactionEpargneRepository
.find("compte.id = ?1 ORDER BY dateTransaction ASC", compteId)
.list();
if (dateDebut != null) {
txs = txs.stream()
.filter(t -> !t.getDateTransaction().toLocalDate().isBefore(dateDebut))
.collect(Collectors.toList());
}
if (dateFin != null) {
txs = txs.stream()
.filter(t -> !t.getDateTransaction().toLocalDate().isAfter(dateFin))
.collect(Collectors.toList());
}
return buildPdfEpargne(compte, txs, dateDebut, dateFin);
}
// ─── Relevé Parts Sociales ─────────────────────────────────────────────────
public byte[] genererReleveParts(UUID compteId, LocalDate dateDebut, LocalDate dateFin) {
ComptePartsSociales compte = comptePartsSocialesRepository.findByIdOptional(compteId)
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + compteId));
List<TransactionPartsSociales> txs = transactionPartsSocialesRepository.findByCompte(compteId);
// findByCompte is DESC — reverse for statement order
java.util.Collections.reverse(txs);
if (dateDebut != null) {
txs = txs.stream()
.filter(t -> !t.getDateTransaction().toLocalDate().isBefore(dateDebut))
.collect(Collectors.toList());
}
if (dateFin != null) {
txs = txs.stream()
.filter(t -> !t.getDateTransaction().toLocalDate().isAfter(dateFin))
.collect(Collectors.toList());
}
return buildPdfParts(compte, txs, dateDebut, dateFin);
}
// ─── PDF builders ──────────────────────────────────────────────────────────
private byte[] buildPdfEpargne(CompteEpargne compte, List<TransactionEpargne> txs,
LocalDate dateDebut, LocalDate dateFin) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4, 40, 40, 60, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addHeader(doc, compte.getOrganisation() != null ? compte.getOrganisation().getNom() : "UnionFlow",
"RELEVÉ DE COMPTE ÉPARGNE");
addInfoBlock(doc, new String[][]{
{"Numéro de compte", compte.getNumeroCompte()},
{"Type de compte", compte.getTypeCompte() != null ? compte.getTypeCompte().name() : ""},
{"Titulaire", membreNom(compte)},
{"Période", formatPeriode(dateDebut, dateFin)},
{"Date d'édition", LocalDate.now().format(DATE_FMT)},
{"Solde actuel", formatMontant(compte.getSoldeActuel())}
});
// Solde d'ouverture de la période
BigDecimal soldeOuverture = txs.isEmpty() ? compte.getSoldeActuel()
: txs.get(0).getSoldeAvant();
PdfPTable table = createTable(new float[]{2f, 3f, 2.5f, 2.5f, 2.5f},
new String[]{"Date", "Libellé", "Débit", "Crédit", "Solde"});
addLigneTotal(table, "Solde d'ouverture", null, null, soldeOuverture);
for (TransactionEpargne tx : txs) {
boolean isDebit = isDebitEpargne(tx);
table.addCell(cell(tx.getDateTransaction().format(DATE_FMT), false));
table.addCell(cell(tx.getMotif() != null ? tx.getMotif() : tx.getType().name(), false));
table.addCell(cellAmount(isDebit ? tx.getMontant() : null, true));
table.addCell(cellAmount(!isDebit ? tx.getMontant() : null, false));
table.addCell(cellAmount(tx.getSoldeApres(), false));
}
doc.add(table);
addSoldeFinal(doc, compte.getSoldeActuel());
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Erreur génération relevé épargne PDF: " + e.getMessage(), e);
}
}
private byte[] buildPdfParts(ComptePartsSociales compte, List<TransactionPartsSociales> txs,
LocalDate dateDebut, LocalDate dateFin) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4, 40, 40, 60, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addHeader(doc, compte.getOrganisation() != null ? compte.getOrganisation().getNom() : "UnionFlow",
"RELEVÉ DE PARTS SOCIALES");
addInfoBlock(doc, new String[][]{
{"Numéro de compte", compte.getNumeroCompte()},
{"Titulaire", compte.getMembre() != null
? compte.getMembre().getNom() + " " + compte.getMembre().getPrenom() : ""},
{"Valeur nominale", formatMontant(compte.getValeurNominale()) + " / part"},
{"Parts détenues", String.valueOf(compte.getNombreParts())},
{"Capital total", formatMontant(compte.getMontantTotal())},
{"Dividendes reçus", formatMontant(compte.getTotalDividendesRecus())},
{"Période", formatPeriode(dateDebut, dateFin)},
{"Date d'édition", LocalDate.now().format(DATE_FMT)}
});
PdfPTable table = createTable(new float[]{2f, 3f, 2f, 2.5f, 2.5f, 2.5f},
new String[]{"Date", "Libellé", "Parts", "Montant", "Avant", "Après"});
for (TransactionPartsSociales tx : txs) {
table.addCell(cell(tx.getDateTransaction().format(DATE_FMT), false));
table.addCell(cell(tx.getMotif() != null ? tx.getMotif() : tx.getTypeTransaction().getLibelle(), false));
table.addCell(cell(String.valueOf(tx.getNombreParts()), false));
table.addCell(cellAmount(tx.getMontant(), false));
table.addCell(cell(String.valueOf(tx.getSoldePartsAvant()), false));
table.addCell(cell(String.valueOf(tx.getSoldePartsApres()), false));
}
doc.add(table);
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Erreur génération relevé parts sociales PDF: " + e.getMessage(), e);
}
}
// ─── PDF helpers ───────────────────────────────────────────────────────────
private void addHeader(Document doc, String orgNom, String titre) throws DocumentException {
Font fontOrg = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, BLEU_UNIONFLOW);
Font fontTitre = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 13);
Font fontSub = FontFactory.getFont(FontFactory.HELVETICA, 9, Color.GRAY);
Paragraph pOrg = new Paragraph(orgNom, fontOrg);
pOrg.setAlignment(Element.ALIGN_CENTER);
doc.add(pOrg);
Paragraph pTitre = new Paragraph(titre, fontTitre);
pTitre.setAlignment(Element.ALIGN_CENTER);
pTitre.setSpacingBefore(4);
doc.add(pTitre);
Paragraph pSub = new Paragraph("Édité via UnionFlow · " + LocalDateTime.now().format(DATETIME_FMT), fontSub);
pSub.setAlignment(Element.ALIGN_CENTER);
pSub.setSpacingAfter(16);
doc.add(pSub);
// Separator line
PdfPTable sep = new PdfPTable(1);
sep.setWidthPercentage(100);
PdfPCell line = new PdfPCell(new Phrase(" "));
line.setBorderColor(BLEU_UNIONFLOW);
line.setBorderWidthBottom(1.5f);
line.setBorderWidthTop(0);
line.setBorderWidthLeft(0);
line.setBorderWidthRight(0);
line.setPaddingBottom(4);
sep.addCell(line);
doc.add(sep);
doc.add(Chunk.NEWLINE);
}
private void addInfoBlock(Document doc, String[][] lignes) throws DocumentException {
PdfPTable t = new PdfPTable(2);
t.setWidthPercentage(60);
t.setHorizontalAlignment(Element.ALIGN_LEFT);
t.setWidths(new float[]{2f, 3f});
Font fontLabel = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9);
Font fontVal = FontFactory.getFont(FontFactory.HELVETICA, 9);
for (String[] row : lignes) {
PdfPCell cLabel = new PdfPCell(new Phrase(row[0], fontLabel));
cLabel.setBorder(Rectangle.NO_BORDER);
cLabel.setPadding(3);
PdfPCell cVal = new PdfPCell(new Phrase(row[1], fontVal));
cVal.setBorder(Rectangle.NO_BORDER);
cVal.setPadding(3);
t.addCell(cLabel);
t.addCell(cVal);
}
t.setSpacingAfter(12);
doc.add(t);
}
private PdfPTable createTable(float[] widths, String[] headers) throws DocumentException {
PdfPTable table = new PdfPTable(widths.length);
table.setWidthPercentage(100);
table.setWidths(widths);
Font fontHeader = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE);
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, fontHeader));
cell.setBackgroundColor(BLEU_UNIONFLOW);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
table.setHeaderRows(1);
return table;
}
private PdfPCell cell(String text, boolean bold) {
Font f = bold
? FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8)
: FontFactory.getFont(FontFactory.HELVETICA, 8);
PdfPCell c = new PdfPCell(new Phrase(text != null ? text : "", f));
c.setPadding(4);
c.setBorderColor(Color.LIGHT_GRAY);
return c;
}
private PdfPCell cellAmount(BigDecimal amount, boolean isDebit) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) {
PdfPCell c = cell("", false);
c.setHorizontalAlignment(Element.ALIGN_RIGHT);
return c;
}
Font f = FontFactory.getFont(FontFactory.HELVETICA, 8,
isDebit ? new Color(180, 0, 0) : new Color(0, 130, 0));
PdfPCell c = new PdfPCell(new Phrase(formatMontant(amount), f));
c.setPadding(4);
c.setHorizontalAlignment(Element.ALIGN_RIGHT);
c.setBorderColor(Color.LIGHT_GRAY);
return c;
}
private void addLigneTotal(PdfPTable table, String label, BigDecimal debit,
BigDecimal credit, BigDecimal solde) {
Font f = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8);
PdfPCell cLabel = new PdfPCell(new Phrase(label, f));
cLabel.setColspan(2);
cLabel.setBackgroundColor(GRIS_ENTETE);
cLabel.setPadding(4);
table.addCell(cLabel);
table.addCell(cellAmount(debit, true));
table.addCell(cellAmount(credit, false));
PdfPCell cSolde = cellAmount(solde, false);
cSolde.setBackgroundColor(GRIS_ENTETE);
table.addCell(cSolde);
}
private void addSoldeFinal(Document doc, BigDecimal solde) throws DocumentException {
Font f = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 10, BLEU_UNIONFLOW);
Paragraph p = new Paragraph("Solde final : " + formatMontant(solde), f);
p.setAlignment(Element.ALIGN_RIGHT);
p.setSpacingBefore(8);
doc.add(p);
}
private void addFooter(Document doc) throws DocumentException {
Font f = FontFactory.getFont(FontFactory.HELVETICA, 8, Color.GRAY);
Paragraph p = new Paragraph(
"Document généré automatiquement par UnionFlow — confidentiel.", f);
p.setAlignment(Element.ALIGN_CENTER);
p.setSpacingBefore(20);
doc.add(p);
}
private String formatMontant(BigDecimal m) {
if (m == null) return "0 XOF";
return String.format("%,.0f XOF", m.doubleValue());
}
private String formatPeriode(LocalDate debut, LocalDate fin) {
if (debut == null && fin == null) return "Toutes opérations";
if (debut == null) return "jusqu'au " + fin.format(DATE_FMT);
if (fin == null) return "depuis le " + debut.format(DATE_FMT);
return debut.format(DATE_FMT) + " au " + fin.format(DATE_FMT);
}
private String membreNom(CompteEpargne compte) {
if (compte.getMembre() == null) return "";
return compte.getMembre().getNom() + " " + compte.getMembre().getPrenom();
}
private boolean isDebitEpargne(TransactionEpargne tx) {
return switch (tx.getType()) {
case RETRAIT, PRELEVEMENT_FRAIS, TRANSFERT_SORTANT, REMBOURSEMENT_CREDIT, RETENUE_GARANTIE -> true;
default -> false;
};
}
}

View File

@@ -13,6 +13,8 @@ import dev.lions.unionflow.server.repository.ParametresLcbFtRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository; import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository; import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
import dev.lions.unionflow.server.service.AuditService; import dev.lions.unionflow.server.service.AuditService;
import dev.lions.unionflow.server.service.ComptabiliteService;
import dev.lions.unionflow.server.security.RlsEnabled;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@@ -31,6 +33,7 @@ import java.util.stream.Collectors;
* Applique les règles LCB-FT : origine des fonds obligatoire au-dessus du seuil configuré. * Applique les règles LCB-FT : origine des fonds obligatoire au-dessus du seuil configuré.
*/ */
@ApplicationScoped @ApplicationScoped
@RlsEnabled
public class TransactionEpargneService { public class TransactionEpargneService {
/** Seuil LCB-FT (XOF) par défaut si aucun paramètre en base. */ /** Seuil LCB-FT (XOF) par défaut si aucun paramètre en base. */
@@ -56,6 +59,9 @@ public class TransactionEpargneService {
@Inject @Inject
dev.lions.unionflow.server.service.AlerteLcbFtService alerteLcbFtService; dev.lions.unionflow.server.service.AlerteLcbFtService alerteLcbFtService;
@Inject
ComptabiliteService comptabiliteService;
/** /**
* Enregistre une nouvelle transaction et met à jour le solde du compte. * Enregistre une nouvelle transaction et met à jour le solde du compte.
* *
@@ -64,6 +70,11 @@ public class TransactionEpargneService {
*/ */
@Transactional @Transactional
public TransactionEpargneResponse executerTransaction(TransactionEpargneRequest request) { public TransactionEpargneResponse executerTransaction(TransactionEpargneRequest request) {
return executerTransaction(request, false);
}
@Transactional
public TransactionEpargneResponse executerTransaction(TransactionEpargneRequest request, boolean bypassSolde) {
CompteEpargne compte = compteEpargneRepository.findByIdOptional(UUID.fromString(request.getCompteId())) CompteEpargne compte = compteEpargneRepository.findByIdOptional(UUID.fromString(request.getCompteId()))
.orElseThrow(() -> new NotFoundException("Compte non trouvé avec l'ID: " + request.getCompteId())); .orElseThrow(() -> new NotFoundException("Compte non trouvé avec l'ID: " + request.getCompteId()));
@@ -85,13 +96,13 @@ public class TransactionEpargneService {
soldeApres = soldeAvant.add(montant); soldeApres = soldeAvant.add(montant);
compte.setSoldeActuel(soldeApres); compte.setSoldeActuel(soldeApres);
} else if (isTypeDebit(request.getTypeTransaction())) { } else if (isTypeDebit(request.getTypeTransaction())) {
if (getSoldeDisponible(compte).compareTo(montant) < 0) { if (!bypassSolde && getSoldeDisponible(compte).compareTo(montant) < 0) {
throw new IllegalArgumentException("Solde disponible insuffisant pour cette opération."); throw new IllegalArgumentException("Solde disponible insuffisant pour cette opération.");
} }
soldeApres = soldeAvant.subtract(montant); soldeApres = soldeAvant.subtract(montant);
compte.setSoldeActuel(soldeApres); compte.setSoldeActuel(soldeApres);
} else if (request.getTypeTransaction() == TypeTransactionEpargne.RETENUE_GARANTIE) { } else if (request.getTypeTransaction() == TypeTransactionEpargne.RETENUE_GARANTIE) {
if (getSoldeDisponible(compte).compareTo(montant) < 0) { if (!bypassSolde && getSoldeDisponible(compte).compareTo(montant) < 0) {
throw new IllegalArgumentException("Solde disponible insuffisant pour geler ce montant."); throw new IllegalArgumentException("Solde disponible insuffisant pour geler ce montant.");
} }
compte.setSoldeBloque(compte.getSoldeBloque().add(montant)); compte.setSoldeBloque(compte.getSoldeBloque().add(montant));
@@ -125,6 +136,19 @@ public class TransactionEpargneService {
transactionEpargneRepository.persist(transaction); transactionEpargneRepository.persist(transaction);
// Génération écriture SYSCOHADA (non bloquant)
if (compte.getOrganisation() != null) {
try {
if (request.getTypeTransaction() == TypeTransactionEpargne.DEPOT) {
comptabiliteService.enregistrerDepotEpargne(transaction, compte.getOrganisation());
} else if (request.getTypeTransaction() == TypeTransactionEpargne.RETRAIT) {
comptabiliteService.enregistrerRetraitEpargne(transaction, compte.getOrganisation());
}
} catch (Exception e) {
// Écriture comptable non bloquante — la transaction épargne reste valide
}
}
if (request.getMontant() != null && request.getMontant().compareTo(seuil) >= 0) { if (request.getMontant() != null && request.getMontant().compareTo(seuil) >= 0) {
UUID orgId = compte.getOrganisation() != null ? compte.getOrganisation().getId() : null; UUID orgId = compte.getOrganisation() != null ? compte.getOrganisation().getId() : null;

View File

@@ -0,0 +1,200 @@
package dev.lions.unionflow.server.service.mutuelle.parts;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import dev.lions.unionflow.server.mapper.mutuelle.parts.ComptePartsSocialesMapper;
import dev.lions.unionflow.server.mapper.mutuelle.parts.TransactionPartsSocialesMapper;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@ApplicationScoped
public class ComptePartsSocialesService {
private static final BigDecimal VALEUR_NOMINALE_DEFAUT = new BigDecimal("5000");
@Inject ComptePartsSocialesRepository compteRepo;
@Inject TransactionPartsSocialesRepository txRepo;
@Inject MembreRepository membreRepository;
@Inject OrganisationRepository organisationRepository;
@Inject ParametresFinanciersMutuellRepository parametresRepo;
@Inject ComptePartsSocialesMapper compteMapper;
@Inject TransactionPartsSocialesMapper txMapper;
@Transactional
public ComptePartsSocialesResponse ouvrirCompte(ComptePartsSocialesRequest req) {
Membre membre = membreRepository.findByIdOptional(UUID.fromString(req.getMembreId()))
.orElseThrow(() -> new NotFoundException("Membre introuvable: " + req.getMembreId()));
Organisation org = organisationRepository.findByIdOptional(UUID.fromString(req.getOrganisationId()))
.orElseThrow(() -> new NotFoundException("Organisation introuvable: " + req.getOrganisationId()));
// Reject duplicate (one compte per member per org)
compteRepo.findByMembreAndOrg(membre.getId(), org.getId()).ifPresent(c -> {
throw new IllegalStateException("Un compte de parts sociales existe déjà pour ce membre dans cette organisation.");
});
BigDecimal valeurNominale = resolveValeurNominale(req.getValeurNominale(), org.getId());
ComptePartsSociales compte = ComptePartsSociales.builder()
.membre(membre)
.organisation(org)
.numeroCompte(genererNumeroCompte(org))
.nombreParts(0)
.valeurNominale(valeurNominale)
.montantTotal(BigDecimal.ZERO)
.totalDividendesRecus(BigDecimal.ZERO)
.statut(StatutComptePartsSociales.ACTIF)
.dateOuverture(LocalDate.now())
.notes(req.getNotes())
.build();
compteRepo.persist(compte);
// Initial souscription si nombreParts > 0
if (req.getNombreParts() != null && req.getNombreParts() > 0) {
enregistrerTransaction(compte, TypeTransactionPartsSociales.SOUSCRIPTION,
req.getNombreParts(), null, "Souscription initiale à l'ouverture", null);
}
return compteMapper.toDto(compte);
}
@Transactional
public TransactionPartsSocialesResponse enregistrerSouscription(TransactionPartsSocialesRequest req) {
ComptePartsSociales compte = findCompteActif(req.getCompteId());
return txMapper.toDto(
enregistrerTransaction(compte, req.getTypeTransaction(),
req.getNombreParts(), req.getMontant(), req.getMotif(), req.getReferenceExterne()));
}
public ComptePartsSocialesResponse getById(UUID id) {
return compteMapper.toDto(compteRepo.findByIdOptional(id)
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + id)));
}
public List<ComptePartsSocialesResponse> getByMembre(UUID membreId) {
return compteRepo.findByMembre(membreId).stream().map(compteMapper::toDto).collect(Collectors.toList());
}
public List<ComptePartsSocialesResponse> getByOrganisation(UUID orgId) {
return compteRepo.findByOrganisation(orgId).stream().map(compteMapper::toDto).collect(Collectors.toList());
}
public List<TransactionPartsSocialesResponse> getTransactions(UUID compteId) {
return txRepo.findByCompte(compteId).stream().map(txMapper::toDto).collect(Collectors.toList());
}
// ─── Internal helpers ──────────────────────────────────────────────────────
TransactionPartsSociales enregistrerTransaction(
ComptePartsSociales compte,
TypeTransactionPartsSociales type,
int nombreParts,
BigDecimal montantOverride,
String motif,
String referenceExterne) {
if (compte.getStatut() != StatutComptePartsSociales.ACTIF) {
throw new IllegalArgumentException("Impossible d'effectuer une opération sur un compte non actif.");
}
int soldeAvant = compte.getNombreParts();
int soldeApres;
BigDecimal montant = montantOverride != null
? montantOverride
: compte.getValeurNominale().multiply(BigDecimal.valueOf(nombreParts));
switch (type) {
case SOUSCRIPTION, SOUSCRIPTION_IMPORT, PAIEMENT_DIVIDENDE -> {
soldeApres = soldeAvant + nombreParts;
compte.setNombreParts(soldeApres);
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
if (type == TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE) {
compte.setTotalDividendesRecus(compte.getTotalDividendesRecus().add(montant));
}
}
case CESSION_PARTIELLE -> {
if (nombreParts > soldeAvant) {
throw new IllegalArgumentException(
"Nombre de parts à céder (" + nombreParts + ") supérieur au solde (" + soldeAvant + ").");
}
soldeApres = soldeAvant - nombreParts;
compte.setNombreParts(soldeApres);
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
}
case RACHAT_TOTAL -> {
soldeApres = 0;
compte.setNombreParts(0);
compte.setMontantTotal(BigDecimal.ZERO);
compte.setStatut(StatutComptePartsSociales.CLOS);
}
case CORRECTION -> {
// Admin correction: use nombreParts as the new absolute balance
soldeApres = nombreParts;
compte.setNombreParts(soldeApres);
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
}
default -> throw new IllegalArgumentException("Type de transaction non pris en charge: " + type);
}
compte.setDateDerniereOperation(LocalDate.now());
TransactionPartsSociales tx = TransactionPartsSociales.builder()
.compte(compte)
.typeTransaction(type)
.nombreParts(nombreParts)
.montant(montant)
.soldePartsAvant(soldeAvant)
.soldePartsApres(soldeApres)
.motif(motif)
.referenceExterne(referenceExterne)
.dateTransaction(LocalDateTime.now())
.build();
txRepo.persist(tx);
return tx;
}
private ComptePartsSociales findCompteActif(String compteId) {
return compteRepo.findByIdOptional(UUID.fromString(compteId))
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + compteId));
}
private BigDecimal resolveValeurNominale(BigDecimal fromRequest, UUID orgId) {
if (fromRequest != null && fromRequest.compareTo(BigDecimal.ZERO) > 0) {
return fromRequest;
}
return parametresRepo.findByOrganisation(orgId)
.map(ParametresFinanciersMutuelle::getValeurNominaleParDefaut)
.orElse(VALEUR_NOMINALE_DEFAUT);
}
private static final AtomicInteger COUNTER = new AtomicInteger(0);
private String genererNumeroCompte(Organisation org) {
String prefix = "PS-" + (org.getNomCourt() != null ? org.getNomCourt().toUpperCase() : "ORG") + "-";
long count = compteRepo.count("organisation.id", org.getId());
return prefix + String.format("%05d", count + 1);
}
}

View File

@@ -4,6 +4,10 @@
# Surcharge application.properties — sans préfixes %dev. # Surcharge application.properties — sans préfixes %dev.
# ============================================================================ # ============================================================================
# DevServices désactivés en dev — on utilise le PostgreSQL local (localhost:5432/unionflow)
# Les tests d'intégration avec Docker requièrent USE_DOCKER_TESTS=true
quarkus.devservices.enabled=false
# Base de données PostgreSQL locale # Base de données PostgreSQL locale
quarkus.datasource.username=skyfile quarkus.datasource.username=skyfile
quarkus.datasource.password=${DB_PASSWORD_DEV:skyfile} quarkus.datasource.password=${DB_PASSWORD_DEV:skyfile}
@@ -18,6 +22,8 @@ quarkus.hibernate-orm.log.sql=true
# Flyway — activé avec réparation auto des checksums modifiés # Flyway — activé avec réparation auto des checksums modifiés
quarkus.flyway.migrate-at-start=true quarkus.flyway.migrate-at-start=true
quarkus.flyway.repair-at-start=true quarkus.flyway.repair-at-start=true
# Désactiver le remplacement de placeholders ${...} — les migrations utilisent $$ PL/pgSQL
quarkus.flyway.placeholder-replacement=false
# CORS — permissif en dev (autorise tous les ports localhost pour Flutter Web) # CORS — permissif en dev (autorise tous les ports localhost pour Flutter Web)
quarkus.http.cors.origins=* quarkus.http.cors.origins=*
@@ -50,6 +56,9 @@ quarkus.log.category."org.hibernate.SQL".level=DEBUG
quarkus.log.category."io.quarkus.oidc".level=INFO quarkus.log.category."io.quarkus.oidc".level=INFO
quarkus.log.category."io.quarkus.security".level=INFO quarkus.log.category."io.quarkus.security".level=INFO
# Kafka — utiliser le broker local, pas de DevServices
quarkus.kafka.devservices.enabled=false
# Wave — mock pour dev (pas de clé API requise) # Wave — mock pour dev (pas de clé API requise)
wave.mock.enabled=true wave.mock.enabled=true
wave.redirect.base.url=http://localhost:8085 wave.redirect.base.url=http://localhost:8085

View File

@@ -52,8 +52,6 @@ quarkus.http.auth.permission.public.policy=permit
quarkus.hibernate-orm.database.generation=update quarkus.hibernate-orm.database.generation=update
quarkus.hibernate-orm.log.sql=false quarkus.hibernate-orm.log.sql=false
quarkus.hibernate-orm.jdbc.timezone=UTC quarkus.hibernate-orm.jdbc.timezone=UTC
quarkus.hibernate-orm.metrics.enabled=false
# Configuration Flyway — base commune # Configuration Flyway — base commune
quarkus.flyway.migrate-at-start=true quarkus.flyway.migrate-at-start=true
quarkus.flyway.baseline-on-migrate=true quarkus.flyway.baseline-on-migrate=true
@@ -89,6 +87,14 @@ quarkus.swagger-ui.tags-sorter=alpha
# Health # Health
quarkus.smallrye-health.root-path=/health quarkus.smallrye-health.root-path=/health
# Métriques Prometheus (Micrometer) — exposées sur /q/metrics
quarkus.micrometer.enabled=true
quarkus.micrometer.export.prometheus.enabled=true
quarkus.micrometer.export.prometheus.path=/q/metrics
# Métriques Hibernate ORM
quarkus.hibernate-orm.metrics.enabled=true
# JVM + HTTP server + datasource metrics activés par défaut avec quarkus-micrometer
# Logging — base commune # Logging — base commune
quarkus.log.console.enable=true quarkus.log.console.enable=true
quarkus.log.console.level=INFO quarkus.log.console.level=INFO
@@ -197,3 +203,20 @@ mp.messaging.incoming.chat-messages-in.topic=unionflow.chat.messages
mp.messaging.incoming.chat-messages-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer mp.messaging.incoming.chat-messages-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.chat-messages-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer mp.messaging.incoming.chat-messages-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.chat-messages-in.group.id=unionflow-websocket-server mp.messaging.incoming.chat-messages-in.group.id=unionflow-websocket-server
# === PI-SPI BCEAO (P0.3 — deadline 30/06/2026) ===
pispi.api.base-url=${PISPI_API_URL:https://sandbox.pispi.bceao.int/business-api/v1}
pispi.institution.bic=${PISPI_BIC:BCEAOCIAB}
# Activer la priorité PI-SPI dans l'orchestrateur (obligatoire en prod après certification)
payment.pispi-priority=${PAYMENT_PISPI_PRIORITY:false}
# Secrets externes : mappage env vars actif en prod uniquement (profile-scoped).
# En dev : propriétés non définies, @ConfigProperty(defaultValue="") côté Java (mode mock).
%prod.pispi.api.client-id=${PISPI_CLIENT_ID:}
%prod.pispi.api.client-secret=${PISPI_CLIENT_SECRET:}
%prod.pispi.institution.code=${PISPI_INSTITUTION_CODE:}
%prod.pispi.webhook.secret=${PISPI_WEBHOOK_SECRET:}
%prod.pispi.webhook.allowed-ips=${PISPI_ALLOWED_IPS:}
%prod.mtnmomo.collection.subscription-key=${MTNMOMO_SUBSCRIPTION_KEY:}
%prod.orange.api.client-id=${ORANGE_API_CLIENT_ID:}
%prod.firebase.service-account-key-path=${FIREBASE_SERVICE_ACCOUNT_KEY_PATH:}

View File

@@ -0,0 +1,87 @@
-- ============================================================================
-- V32 — Mutuelle : Parts Sociales + Paramètres Financiers + Intérêts
--
-- Ajoute les tables nécessaires pour les fonctionnalités manquantes identifiées
-- dans l'analyse du fichier FUSION 2013-2021.xlsx de la Mutuelle GBANE :
-- 1. comptes_parts_sociales — capital social des membres
-- 2. transactions_parts_sociales — historique des mouvements de parts
-- 3. parametres_financiers_mutuelle — taux, périodicités, valeur nominale
-- ============================================================================
-- ── 1. Paramètres financiers de la mutuelle ────────────────────────────────
CREATE TABLE IF NOT EXISTS parametres_financiers_mutuelle (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
organisation_id UUID NOT NULL UNIQUE,
valeur_nominale_par_defaut NUMERIC(19,4) NOT NULL DEFAULT 5000,
taux_interet_annuel_epargne NUMERIC(6,4) NOT NULL DEFAULT 0.0300,
taux_dividende_parts_annuel NUMERIC(6,4) NOT NULL DEFAULT 0.0500,
periodicite_calcul VARCHAR(20) NOT NULL DEFAULT 'MENSUEL',
seuil_min_epargne_interets NUMERIC(19,4) DEFAULT 0,
prochaine_calcul_interets DATE,
dernier_calcul_interets DATE,
dernier_nb_comptes_traites INTEGER DEFAULT 0,
-- BaseEntity cols
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_pfm_organisation FOREIGN KEY (organisation_id) REFERENCES organisations(id)
);
CREATE INDEX IF NOT EXISTS idx_pfm_org ON parametres_financiers_mutuelle(organisation_id);
-- ── 2. Comptes de parts sociales ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS comptes_parts_sociales (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
membre_id UUID NOT NULL,
organisation_id UUID NOT NULL,
numero_compte VARCHAR(50) NOT NULL UNIQUE,
nombre_parts INTEGER NOT NULL DEFAULT 0,
valeur_nominale NUMERIC(19,4) NOT NULL,
montant_total NUMERIC(19,4) NOT NULL DEFAULT 0,
total_dividendes_recus NUMERIC(19,4) NOT NULL DEFAULT 0,
statut VARCHAR(30) NOT NULL DEFAULT 'ACTIF',
date_ouverture DATE NOT NULL DEFAULT CURRENT_DATE,
date_derniere_operation DATE,
notes VARCHAR(500),
-- BaseEntity cols
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_cps_membre FOREIGN KEY (membre_id) REFERENCES utilisateurs(id),
CONSTRAINT fk_cps_organisation FOREIGN KEY (organisation_id) REFERENCES organisations(id)
);
CREATE INDEX IF NOT EXISTS idx_cps_numero ON comptes_parts_sociales(numero_compte);
CREATE INDEX IF NOT EXISTS idx_cps_membre ON comptes_parts_sociales(membre_id);
CREATE INDEX IF NOT EXISTS idx_cps_org ON comptes_parts_sociales(organisation_id);
-- ── 3. Transactions sur parts sociales ────────────────────────────────────
CREATE TABLE IF NOT EXISTS transactions_parts_sociales (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
compte_id UUID NOT NULL,
type_transaction VARCHAR(50) NOT NULL,
nombre_parts INTEGER NOT NULL,
montant NUMERIC(19,4) NOT NULL,
solde_parts_avant INTEGER NOT NULL DEFAULT 0,
solde_parts_apres INTEGER NOT NULL DEFAULT 0,
motif VARCHAR(500),
reference_externe VARCHAR(100),
date_transaction TIMESTAMP NOT NULL DEFAULT NOW(),
-- BaseEntity cols
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_tps_compte FOREIGN KEY (compte_id) REFERENCES comptes_parts_sociales(id)
);
CREATE INDEX IF NOT EXISTS idx_tps_compte ON transactions_parts_sociales(compte_id);
CREATE INDEX IF NOT EXISTS idx_tps_date ON transactions_parts_sociales(date_transaction);

View File

@@ -0,0 +1,15 @@
-- ============================================================================
-- V33 — Correction colonnes legacy de audit_logs
--
-- La V1 crée audit_logs avec action VARCHAR(50) NOT NULL (ancien schéma).
-- L'entité AuditLog utilise type_action à la place.
-- Hibernate ne remplit pas action → violation NOT NULL sur chaque insert.
-- Fix : rendre action nullable + nettoyer les autres colonnes orphelines.
-- ============================================================================
-- Rendre la colonne legacy nullable (elle est supersédée par type_action)
ALTER TABLE audit_logs ALTER COLUMN action DROP NOT NULL;
-- Aligner entite_id : la V1 déclare UUID mais l'entité stocke une String (UUID textuel)
-- → changer en VARCHAR pour éviter des cast errors sur certains IDs non-UUID
ALTER TABLE audit_logs ALTER COLUMN entite_id TYPE VARCHAR(255) USING entite_id::VARCHAR;

View File

@@ -0,0 +1,39 @@
-- ============================================================================
-- V34 — Rendre membre_id nullable dans les tables où l'entité Hibernate
-- utilise désormais une autre colonne (utilisateur_id, membre_organisation_id).
--
-- Contexte : V1 crée ces tables avec membre_id UUID NOT NULL. Les entités ont
-- évolué pour utiliser utilisateur_id (MembreOrganisation, DemandeAdhesion,
-- IntentionPaiement) ou membre_organisation_id (MembreRole). Hibernate update
-- a ajouté les nouvelles colonnes mais n'a pas supprimé membre_id.
-- Résultat : chaque insert lève une violation NOT NULL sur membre_id.
-- Fix : rendre membre_id nullable (colonne legacy, plus utilisée par le code).
-- ============================================================================
-- membres_organisations : entité utilise utilisateur_id
ALTER TABLE membres_organisations ALTER COLUMN membre_id DROP NOT NULL;
-- membres_roles : entité utilise membre_organisation_id
ALTER TABLE membres_roles ALTER COLUMN membre_id DROP NOT NULL;
-- demandes_adhesion : entité utilise utilisateur_id
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'demandes_adhesion' AND column_name = 'membre_id'
) THEN
ALTER TABLE demandes_adhesion ALTER COLUMN membre_id DROP NOT NULL;
END IF;
END $$;
-- intentions_paiement : entité utilise utilisateur_id
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'intentions_paiement' AND column_name = 'membre_id'
) THEN
ALTER TABLE intentions_paiement ALTER COLUMN membre_id DROP NOT NULL;
END IF;
END $$;

View File

@@ -0,0 +1,77 @@
-- ============================================================================
-- V35 — Recalibrage nombre_membres + trigger auto-maintien
--
-- DATA-01 : Le compteur organisations.nombre_membres est désynchronisé quand
-- des membres sont importés directement en DB (hors service Java).
-- Fix :
-- 1. Recalibrage immédiat depuis membres_organisations réels (actifs)
-- 2. Trigger PostgreSQL pour maintenir le compteur à jour automatiquement
-- ============================================================================
-- 1. Recalibrage ponctuel : recalculer depuis la table membres_organisations
UPDATE organisations o
SET nombre_membres = (
SELECT COUNT(*)
FROM membres_organisations mo
WHERE mo.organisation_id = o.id
AND mo.actif = true
AND mo.statut IN ('ACTIF', 'ACTIF_PREMIUM')
);
-- 2. Fonction trigger : incrémente/décrémente selon INSERT/UPDATE/DELETE
CREATE OR REPLACE FUNCTION update_organisation_nombre_membres()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
-- Nouveau membre actif → incrémenter
IF NEW.actif = true AND NEW.statut IN ('ACTIF', 'ACTIF_PREMIUM') THEN
UPDATE organisations
SET nombre_membres = GREATEST(0, nombre_membres + 1)
WHERE id = NEW.organisation_id;
END IF;
ELSIF TG_OP = 'UPDATE' THEN
-- Transition actif/inactif ou statut
DECLARE
was_counted BOOLEAN := OLD.actif = true AND OLD.statut IN ('ACTIF', 'ACTIF_PREMIUM');
is_counted BOOLEAN := NEW.actif = true AND NEW.statut IN ('ACTIF', 'ACTIF_PREMIUM');
BEGIN
IF NOT was_counted AND is_counted THEN
UPDATE organisations
SET nombre_membres = GREATEST(0, nombre_membres + 1)
WHERE id = NEW.organisation_id;
ELSIF was_counted AND NOT is_counted THEN
UPDATE organisations
SET nombre_membres = GREATEST(0, nombre_membres - 1)
WHERE id = OLD.organisation_id;
END IF;
END;
ELSIF TG_OP = 'DELETE' THEN
-- Suppression physique (rare)
IF OLD.actif = true AND OLD.statut IN ('ACTIF', 'ACTIF_PREMIUM') THEN
UPDATE organisations
SET nombre_membres = GREATEST(0, nombre_membres - 1)
WHERE id = OLD.organisation_id;
END IF;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- 3. Attacher le trigger à membres_organisations
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'trg_update_nombre_membres'
AND tgrelid = 'membres_organisations'::regclass
) THEN
CREATE TRIGGER trg_update_nombre_membres
AFTER INSERT OR UPDATE OF actif, statut OR DELETE
ON membres_organisations
FOR EACH ROW
EXECUTE FUNCTION update_organisation_nombre_membres();
END IF;
END $$;

View File

@@ -0,0 +1,393 @@
-- ============================================================================
-- V36 — SYSCOHADA : Alignement schéma + Seeds plan comptable standard + Trigger
--
-- P0.4 ROADMAP_2026.md — Obligation OHADA SYSCOHADA révisé (applicable depuis 2018)
-- Corrige l'écart entre V1 (schéma minimal) et les entités Java (colonnes Hibernate).
-- Ajoute le plan comptable standard SYSCOHADA pour mutuelles/coopératives UEMOA.
-- ============================================================================
-- ============================================================================
-- 1. COMPTES_COMPTABLES — Alignement colonnes V1 → entité Java
-- ============================================================================
-- La V1 crée la table avec numero/libelle/type_compte/organisation_id seulement.
-- L'entité Java attend : numero_compte, classe_comptable, solde_initial, solde_actuel,
-- compte_collectif, compte_analytique, cree_par, modifie_par.
-- Renommer la colonne numero → numero_compte si elle n'a pas déjà été renommée par Hibernate
-- Sinon : si les deux colonnes coexistent (Hibernate a créé numero_compte, V1 a laissé numero),
-- on supprime l'ancienne colonne obsolète numero (NOT NULL sans défaut, bloque les INSERTs).
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'comptes_comptables' AND column_name = 'numero'
) THEN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'comptes_comptables' AND column_name = 'numero_compte'
) THEN
ALTER TABLE comptes_comptables RENAME COLUMN numero TO numero_compte;
ELSE
-- Les deux colonnes coexistent : recopier les valeurs vers numero_compte si besoin,
-- puis supprimer la colonne obsolète numero.
UPDATE comptes_comptables SET numero_compte = numero
WHERE numero_compte IS NULL AND numero IS NOT NULL;
ALTER TABLE comptes_comptables DROP COLUMN numero;
END IF;
END IF;
END $$;
-- Ajouter colonnes manquantes si pas encore créées par Hibernate update
ALTER TABLE comptes_comptables
ADD COLUMN IF NOT EXISTS classe_comptable INTEGER,
ADD COLUMN IF NOT EXISTS solde_initial DECIMAL(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS solde_actuel DECIMAL(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS compte_collectif BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS compte_analytique BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS description VARCHAR(500),
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
-- Déduire classe_comptable depuis numero_compte si null (première chiffre du numéro)
UPDATE comptes_comptables
SET classe_comptable = CAST(LEFT(numero_compte, 1) AS INTEGER)
WHERE classe_comptable IS NULL AND numero_compte IS NOT NULL AND LENGTH(numero_compte) > 0;
-- Rendre classe_comptable NOT NULL après backfill
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'comptes_comptables' AND column_name = 'classe_comptable'
AND is_nullable = 'NO'
) THEN
ALTER TABLE comptes_comptables ALTER COLUMN classe_comptable SET NOT NULL;
END IF;
END $$;
-- Contrainte classe 1-9 (SYSCOHADA a 9 classes, pas 7)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_compte_classe_syscohada') THEN
ALTER TABLE comptes_comptables
ADD CONSTRAINT chk_compte_classe_syscohada
CHECK (classe_comptable >= 1 AND classe_comptable <= 9);
END IF;
END $$;
-- ============================================================================
-- 2. JOURNAUX_COMPTABLES — Alignement colonnes
-- ============================================================================
ALTER TABLE journaux_comptables
ADD COLUMN IF NOT EXISTS date_debut DATE,
ADD COLUMN IF NOT EXISTS date_fin DATE,
ADD COLUMN IF NOT EXISTS statut VARCHAR(20) DEFAULT 'OUVERT',
ADD COLUMN IF NOT EXISTS description VARCHAR(500),
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
-- ============================================================================
-- 3. ECRITURES_COMPTABLES — Alignement colonnes
-- ============================================================================
ALTER TABLE ecritures_comptables
ADD COLUMN IF NOT EXISTS organisation_id UUID REFERENCES organisations(id),
ADD COLUMN IF NOT EXISTS paiement_id UUID REFERENCES paiements(id),
ADD COLUMN IF NOT EXISTS reference VARCHAR(100),
ADD COLUMN IF NOT EXISTS lettrage VARCHAR(20),
ADD COLUMN IF NOT EXISTS pointe BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS montant_debit DECIMAL(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS montant_credit DECIMAL(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS commentaire VARCHAR(1000),
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
-- ============================================================================
-- 4. LIGNES_ECRITURE — Alignement colonnes (debit/credit → montant_debit/credit)
-- ============================================================================
-- Renommer compte_id → compte_comptable_id si besoin
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'compte_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'compte_comptable_id'
) THEN
ALTER TABLE lignes_ecriture RENAME COLUMN compte_id TO compte_comptable_id;
END IF;
END $$;
-- Renommer debit/credit → montant_debit/montant_credit si besoin
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'debit'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'montant_debit'
) THEN
ALTER TABLE lignes_ecriture RENAME COLUMN debit TO montant_debit;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'credit'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'montant_credit'
) THEN
ALTER TABLE lignes_ecriture RENAME COLUMN credit TO montant_credit;
END IF;
END $$;
ALTER TABLE lignes_ecriture
ADD COLUMN IF NOT EXISTS numero_ligne INTEGER,
ADD COLUMN IF NOT EXISTS reference VARCHAR(100),
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
-- ============================================================================
-- 5. TABLE MODELE_PLAN_COMPTABLE — Template SYSCOHADA (comptes standards réutilisables)
-- ============================================================================
CREATE TABLE IF NOT EXISTS modele_plan_comptable (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero_compte VARCHAR(10) NOT NULL UNIQUE,
libelle VARCHAR(200) NOT NULL,
classe_comptable INTEGER NOT NULL CHECK (classe_comptable >= 1 AND classe_comptable <= 9),
type_compte VARCHAR(30) NOT NULL,
description VARCHAR(500),
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT chk_modele_classe CHECK (classe_comptable >= 1 AND classe_comptable <= 9)
);
-- ============================================================================
-- 6. SEEDS — Plan comptable SYSCOHADA standard pour mutuelles/coopératives UEMOA
-- ============================================================================
INSERT INTO modele_plan_comptable (numero_compte, libelle, classe_comptable, type_compte) VALUES
-- CLASSE 1 — Ressources durables
('101000', 'Fonds propres', 1, 'PASSIF'),
('104000', 'Réserve légale', 1, 'PASSIF'),
('106000', 'Réserves statutaires', 1, 'PASSIF'),
('120000', 'Résultat de l''exercice', 1, 'PASSIF'),
('160000', 'Emprunts à long terme', 1, 'PASSIF'),
('165000', 'Dépôts et cautionnements reçus', 1, 'PASSIF'),
-- CLASSE 2 — Actif immobilisé
('222000', 'Matériel de transport', 2, 'ACTIF'),
('232000', 'Matériel informatique', 2, 'ACTIF'),
('244000', 'Logiciels informatiques', 2, 'ACTIF'),
('281000', 'Amortissements immobilisations', 2, 'ACTIF'),
-- CLASSE 4 — Tiers
('411000', 'Membres débiteurs — cotisations dues', 4, 'ACTIF'),
('412000', 'Membres débiteurs — parts sociales dues', 4, 'ACTIF'),
('413000', 'Membres débiteurs — avances sur prestations', 4, 'ACTIF'),
('421000', 'Personnel — rémunérations dues', 4, 'PASSIF'),
('431000', 'Sécurité sociale — cotisations patronales', 4, 'PASSIF'),
('441000', 'État — TVA collectée', 4, 'PASSIF'),
('447000', 'État — autres impôts et taxes', 4, 'PASSIF'),
('467000', 'Tiers divers débiteurs', 4, 'ACTIF'),
('468000', 'Tiers divers créditeurs', 4, 'PASSIF'),
-- CLASSE 5 — Trésorerie
('512100', 'Compte Wave Senegal', 5, 'TRESORERIE'),
('512200', 'Compte Orange Money', 5, 'TRESORERIE'),
('512300', 'Compte MTN MoMo', 5, 'TRESORERIE'),
('512400', 'Compte Moov Money', 5, 'TRESORERIE'),
('512500', 'Compte bancaire principal', 5, 'TRESORERIE'),
('531000', 'Caisse principale', 5, 'TRESORERIE'),
('581000', 'Virements internes de trésorerie', 5, 'TRESORERIE'),
-- CLASSE 6 — Charges
('601000', 'Achats de marchandises', 6, 'CHARGES'),
('611000', 'Transports', 6, 'CHARGES'),
('612000', 'Frais de télécommunications', 6, 'CHARGES'),
('613000', 'Frais d''assurance', 6, 'CHARGES'),
('614000', 'Location matériel', 6, 'CHARGES'),
('616000', 'Frais d''entretien et réparations', 6, 'CHARGES'),
('621000', 'Personnel externe (prestataires)', 6, 'CHARGES'),
('622000', 'Rémunérations du personnel', 6, 'CHARGES'),
('631000', 'Frais financiers — intérêts d''emprunts', 6, 'CHARGES'),
('641000', 'Charges sur prestations mutuelles', 6, 'CHARGES'),
('651000', 'Pertes sur créances irrécouvrables', 6, 'CHARGES'),
-- CLASSE 7 — Produits
('706100', 'Cotisations ordinaires membres', 7, 'PRODUITS'),
('706200', 'Cotisations spéciales / majorées', 7, 'PRODUITS'),
('706300', 'Parts sociales', 7, 'PRODUITS'),
('706400', 'Droits d''adhésion', 7, 'PRODUITS'),
('762000', 'Produits financiers — intérêts épargne', 7, 'PRODUITS'),
('771000', 'Subventions d''exploitation reçues', 7, 'PRODUITS'),
('775000', 'Prestations de services', 7, 'PRODUITS'),
-- CLASSE 8 — Charges et produits exceptionnels / hors activité
('870000', 'Dons reçus', 8, 'PRODUITS'),
('871000', 'Legs et donations', 8, 'PRODUITS'),
('875000', 'Produits exceptionnels d''événements', 8, 'PRODUITS'),
('878000', 'Autres produits hors activité ordinaire', 8, 'PRODUITS'),
('880000', 'Charges exceptionnelles', 8, 'CHARGES'),
-- CLASSE 9 — Engagements / comptabilité analytique
('990000', 'Engagements hors bilan donnés', 9, 'AUTRE'),
('991000', 'Engagements hors bilan reçus', 9, 'AUTRE')
ON CONFLICT (numero_compte) DO NOTHING;
-- ============================================================================
-- 7. TRIGGER — Initialisation automatique du plan comptable à la création d'org
-- ============================================================================
CREATE OR REPLACE FUNCTION init_plan_comptable_organisation()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO comptes_comptables (
id, numero_compte, libelle, classe_comptable, type_compte,
description, organisation_id, solde_initial, solde_actuel,
compte_collectif, compte_analytique, actif,
date_creation, version
)
SELECT
gen_random_uuid(),
m.numero_compte,
m.libelle,
m.classe_comptable,
m.type_compte,
m.description,
NEW.id,
0, 0,
false, false, true,
NOW(), 0
FROM modele_plan_comptable m
WHERE m.actif = true;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'trg_init_plan_comptable_org'
AND tgrelid = 'organisations'::regclass
) THEN
CREATE TRIGGER trg_init_plan_comptable_org
AFTER INSERT ON organisations
FOR EACH ROW
EXECUTE FUNCTION init_plan_comptable_organisation();
END IF;
END $$;
-- ============================================================================
-- 8. BACKFILL — Initialiser le plan comptable pour les organisations existantes
-- (qui ont été créées avant ce trigger)
-- ============================================================================
INSERT INTO comptes_comptables (
id, numero_compte, libelle, classe_comptable, type_compte,
description, organisation_id, solde_initial, solde_actuel,
compte_collectif, compte_analytique, actif,
date_creation, version
)
SELECT
gen_random_uuid(),
m.numero_compte,
m.libelle,
m.classe_comptable,
m.type_compte,
m.description,
o.id,
0, 0,
false, false, true,
NOW(), 0
FROM organisations o
CROSS JOIN modele_plan_comptable m
WHERE m.actif = true
AND NOT EXISTS (
SELECT 1 FROM comptes_comptables cc
WHERE cc.organisation_id = o.id
AND cc.numero_compte = m.numero_compte
);
-- ============================================================================
-- 9. JOURNAUX STANDARD par organisation
-- ============================================================================
-- Remplacer la contrainte UNIQUE globale sur `code` par une contrainte composite
-- (organisation_id, code) — plusieurs orgs peuvent avoir un journal ACH/VTE/etc.
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT tc.constraint_name INTO constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = 'journaux_comptables'
AND tc.constraint_type = 'UNIQUE'
AND ccu.column_name = 'code'
AND NOT EXISTS (
SELECT 1 FROM information_schema.constraint_column_usage ccu2
WHERE ccu2.constraint_name = tc.constraint_name
AND ccu2.column_name = 'organisation_id'
);
IF constraint_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE journaux_comptables DROP CONSTRAINT ' || quote_ident(constraint_name);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uk_journaux_org_code'
) THEN
ALTER TABLE journaux_comptables
ADD CONSTRAINT uk_journaux_org_code UNIQUE (organisation_id, code);
END IF;
END $$;
INSERT INTO journaux_comptables (
id, code, libelle, type_journal, organisation_id,
statut, actif, date_creation, version
)
SELECT
gen_random_uuid(),
jtype.code,
jtype.libelle,
jtype.type_journal,
o.id,
'OUVERT', true, NOW(), 0
FROM organisations o
CROSS JOIN (VALUES
('ACH', 'Journal des achats', 'ACHATS'),
('VTE', 'Journal des ventes / cotisations', 'VENTES'),
('BQ', 'Journal bancaire', 'BANQUE'),
('CAI', 'Journal de caisse', 'CAISSE'),
('OD', 'Journal des opérations diverses', 'OD')
) AS jtype(code, libelle, type_journal)
WHERE NOT EXISTS (
SELECT 1 FROM journaux_comptables jc
WHERE jc.organisation_id = o.id
AND jc.type_journal = jtype.type_journal
);
-- ============================================================================
-- 10. INDEX utiles
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_comptes_org_numero
ON comptes_comptables (organisation_id, numero_compte);
CREATE INDEX IF NOT EXISTS idx_comptes_org_classe
ON comptes_comptables (organisation_id, classe_comptable);
CREATE INDEX IF NOT EXISTS idx_ecritures_org_date
ON ecritures_comptables (organisation_id, date_ecriture);
CREATE INDEX IF NOT EXISTS idx_lignes_compte
ON lignes_ecriture (compte_comptable_id);

View File

@@ -0,0 +1,14 @@
-- ============================================================================
-- V37 — Keycloak 26 Organizations : ajout keycloak_org_id sur organisations
--
-- P0.2 ROADMAP_2026.md — Migration Keycloak 23 → 26 + Organizations natives
-- Stocke l'ID Keycloak Organization correspondant à chaque organisation UnionFlow.
-- Null = organisation pas encore migrée vers Keycloak 26 Organizations.
-- ============================================================================
ALTER TABLE organisations
ADD COLUMN IF NOT EXISTS keycloak_org_id UUID;
CREATE INDEX IF NOT EXISTS idx_organisations_keycloak_org_id
ON organisations (keycloak_org_id)
WHERE keycloak_org_id IS NOT NULL;

View File

@@ -0,0 +1,64 @@
-- ============================================================================
-- V38 — Module KYC/AML : table kyc_dossier
--
-- P1.5 ROADMAP_2026.md — KYC/AML — conformité GIABA/BCEAO LCB-FT
-- Rétention 10 ans (GIABA) gérée par colonne annee_reference + archivage planifié.
-- ============================================================================
CREATE TABLE IF NOT EXISTS kyc_dossier (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identité du membre
membre_id UUID NOT NULL REFERENCES utilisateurs(id),
-- Pièce d'identité
type_piece VARCHAR(30) NOT NULL,
numero_piece VARCHAR(50) NOT NULL,
date_expiration_piece DATE,
-- Fichiers stockés (MinIO/S3 — identifiants opaques)
piece_identite_recto_file_id VARCHAR(500),
piece_identite_verso_file_id VARCHAR(500),
justif_domicile_file_id VARCHAR(500),
-- Évaluation risque LCB-FT
statut VARCHAR(20) NOT NULL DEFAULT 'NON_VERIFIE',
niveau_risque VARCHAR(20) NOT NULL DEFAULT 'FAIBLE',
score_risque INTEGER NOT NULL DEFAULT 0
CHECK (score_risque >= 0 AND score_risque <= 100),
-- PEP (Personne Exposée Politiquement)
est_pep BOOLEAN NOT NULL DEFAULT FALSE,
nationalite VARCHAR(5),
-- Validation
date_verification TIMESTAMP,
validateur_id UUID REFERENCES utilisateurs(id),
notes_validateur VARCHAR(1000),
-- Rétention 10 ans GIABA — partitionnement logique par année
annee_reference INTEGER NOT NULL DEFAULT EXTRACT(YEAR FROM NOW()),
-- BaseEntity
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT NOT NULL DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT chk_kyc_annee_reference CHECK (annee_reference >= 2020 AND annee_reference <= 2100)
);
-- Un seul dossier actif par membre (le plus récent est actif, les anciens archivés)
CREATE UNIQUE INDEX IF NOT EXISTS idx_kyc_membre_actif
ON kyc_dossier (membre_id)
WHERE actif = TRUE;
CREATE INDEX IF NOT EXISTS idx_kyc_membre_id ON kyc_dossier (membre_id);
CREATE INDEX IF NOT EXISTS idx_kyc_statut ON kyc_dossier (statut);
CREATE INDEX IF NOT EXISTS idx_kyc_niveau_risque ON kyc_dossier (niveau_risque);
CREATE INDEX IF NOT EXISTS idx_kyc_est_pep ON kyc_dossier (est_pep) WHERE est_pep = TRUE;
CREATE INDEX IF NOT EXISTS idx_kyc_annee ON kyc_dossier (annee_reference);
CREATE INDEX IF NOT EXISTS idx_kyc_date_expiration ON kyc_dossier (date_expiration_piece)
WHERE date_expiration_piece IS NOT NULL;

View File

@@ -0,0 +1,174 @@
-- ============================================================================
-- V39 — PostgreSQL Row-Level Security : isolation multi-tenant
--
-- P1.2 ROADMAP_2026.md — Multi-tenancy RLS sur tables tenant-scoped
--
-- Variables de session :
-- app.current_org_id : UUID de l'organisation active (set par RlsConnectionInitializer)
-- app.is_super_admin : 'true' si SUPER_ADMIN (bypass RLS pour dashboards globaux)
--
-- Notes sécurité :
-- - Ne pas activer FORCE ROW LEVEL SECURITY ici — le user Flyway (owner) bypasse naturellement.
-- - En prod : créer user `unionflow_app` sans BYPASSRLS pour le pool Quarkus.
-- - Le user Flyway (`unionflow_admin` ou `postgres`) doit avoir BYPASSRLS ou être owner.
-- ============================================================================
-- ============================================================================
-- Helper : policy template pour tables avec organisation_id direct
-- ============================================================================
-- TABLE cotisations
ALTER TABLE cotisations ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_cotisations') THEN
CREATE POLICY rls_tenant_cotisations ON cotisations
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE souscriptions_organisation
ALTER TABLE souscriptions_organisation ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_souscriptions') THEN
CREATE POLICY rls_tenant_souscriptions ON souscriptions_organisation
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE evenements
ALTER TABLE evenements ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_evenements') THEN
CREATE POLICY rls_tenant_evenements ON evenements
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE documents
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_documents') THEN
CREATE POLICY rls_tenant_documents ON documents
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE comptes_comptables
ALTER TABLE comptes_comptables ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_comptes_comptables') THEN
CREATE POLICY rls_tenant_comptes_comptables ON comptes_comptables
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE journaux_comptables
ALTER TABLE journaux_comptables ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_journaux_comptables') THEN
CREATE POLICY rls_tenant_journaux_comptables ON journaux_comptables
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE ecritures_comptables
ALTER TABLE ecritures_comptables ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_ecritures_comptables') THEN
CREATE POLICY rls_tenant_ecritures_comptables ON ecritures_comptables
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE kyc_dossier (scoped via membres_organisations JOIN)
-- Note : kyc_dossier n'a pas d'organisation_id direct — scope via membre_id + membres_organisations
ALTER TABLE kyc_dossier ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_kyc_dossier') THEN
CREATE POLICY rls_tenant_kyc_dossier ON kyc_dossier
USING (
EXISTS (
SELECT 1 FROM membres_organisations mo
WHERE mo.utilisateur_id = kyc_dossier.membre_id
AND mo.organisation_id = current_setting('app.current_org_id', true)::uuid
AND mo.actif = true
)
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE membres_organisations (scope par organisation)
ALTER TABLE membres_organisations ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_membres_organisations') THEN
CREATE POLICY rls_tenant_membres_organisations ON membres_organisations
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE budgets
ALTER TABLE budgets ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_budgets') THEN
CREATE POLICY rls_tenant_budgets ON budgets
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE tontines (si applicable)
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tontines')
AND NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_tontines') THEN
EXECUTE 'ALTER TABLE tontines ENABLE ROW LEVEL SECURITY';
EXECUTE '
CREATE POLICY rls_tenant_tontines ON tontines
USING (
organisation_id = current_setting(''app.current_org_id'', true)::uuid
OR current_setting(''app.is_super_admin'', true) = ''true''
)';
END IF;
END $$;
-- ============================================================================
-- Rôle PostgreSQL applicatif (prod only — commenté pour ne pas casser dev)
-- À exécuter manuellement en prod avec le bon mot de passe.
-- ============================================================================
-- CREATE ROLE unionflow_app LOGIN PASSWORD '<UNIONFLOW_APP_DB_PASSWORD>';
-- GRANT CONNECT ON DATABASE unionflow TO unionflow_app;
-- GRANT USAGE ON SCHEMA public TO unionflow_app;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO unionflow_app;
-- GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO unionflow_app;
-- -- unionflow_app N'A PAS BYPASSRLS — RLS s'applique toujours
--
-- CREATE ROLE unionflow_admin LOGIN PASSWORD '<UNIONFLOW_ADMIN_DB_PASSWORD>' BYPASSRLS;
-- GRANT ALL ON ALL TABLES IN SCHEMA public TO unionflow_admin;
-- GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO unionflow_admin;
-- -- unionflow_admin utilisé par Flyway et SuperAdminCrossTenantService

View File

@@ -0,0 +1,9 @@
-- V40: Ajout du provider de paiement par défaut sur FormuleAbonnement
-- Permet de configurer le provider (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI) par formule
-- NULL = utiliser le provider global configuré dans application.properties
ALTER TABLE formules_abonnement
ADD COLUMN IF NOT EXISTS provider_defaut VARCHAR(20);
COMMENT ON COLUMN formules_abonnement.provider_defaut IS
'Code du provider de paiement par défaut pour cette formule (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI). NULL = provider global.';

View File

@@ -0,0 +1,12 @@
-- V41: Token FCM (Firebase Cloud Messaging) pour les notifications push mobile
-- Nullable : vide si le membre n'a pas installé l'app mobile ou refusé les notifications
-- Table : utilisateurs (entité Membre.java → @Table(name = "utilisateurs"))
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(500);
COMMENT ON COLUMN utilisateurs.fcm_token IS
'Token FCM pour les notifications push Firebase. NULL si non enregistré.';
CREATE INDEX IF NOT EXISTS idx_utilisateurs_fcm_token
ON utilisateurs (fcm_token) WHERE fcm_token IS NOT NULL;

View File

@@ -0,0 +1,41 @@
-- V42: Créer les rôles PostgreSQL pour l'isolation RLS
-- unionflow_app : rôle applicatif (sans BYPASSRLS) — utilisé en prod par le backend
-- unionflow_admin: rôle administrateur (BYPASSRLS) — utilisé pour les migrations Flyway et les ops DBA
DO $$
BEGIN
-- Rôle applicatif (sans bypass RLS — soumis aux policies)
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'unionflow_app') THEN
CREATE ROLE unionflow_app LOGIN PASSWORD 'CHANGE_ME_APP_PASSWORD';
END IF;
-- Rôle administrateur (bypass RLS — pour Flyway, exports, audits DBA)
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'unionflow_admin') THEN
CREATE ROLE unionflow_admin LOGIN PASSWORD 'CHANGE_ME_ADMIN_PASSWORD' BYPASSRLS;
END IF;
END
$$;
-- Accorder les privilèges sur le schéma public
GRANT USAGE ON SCHEMA public TO unionflow_app, unionflow_admin;
-- unionflow_app : DML uniquement (pas DDL)
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO unionflow_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO unionflow_app;
-- unionflow_admin : tous les droits (DDL inclus pour Flyway)
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO unionflow_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO unionflow_admin;
-- Garantir les droits sur les objets créés ultérieurement (nouvelles tables Flyway)
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO unionflow_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO unionflow_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL PRIVILEGES ON TABLES TO unionflow_admin;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL PRIVILEGES ON SEQUENCES TO unionflow_admin;
COMMENT ON ROLE unionflow_app IS 'Rôle applicatif UnionFlow — soumis aux policies RLS tenant isolation';
COMMENT ON ROLE unionflow_admin IS 'Rôle DBA UnionFlow — BYPASSRLS pour Flyway et exports';

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Bienvenue sur UnionFlow</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; }
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
.btn { display: inline-block; margin-top: 20px; padding: 12px 28px; background: #1A568C; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold; }
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 Bienvenue sur UnionFlow !</h1>
</div>
<div class="body">
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
<p>Votre compte a été créé avec succès sur <strong>UnionFlow</strong>, la plateforme de gestion des mutuelles, coopératives et syndicats de Côte d'Ivoire.</p>
<p>Vous faites maintenant partie de l'organisation : <strong>{nomOrganisation}</strong></p>
<p>Votre identifiant de connexion est votre adresse email : <strong>{email}</strong></p>
{#if lienConnexion}
<p>
<a href="{lienConnexion}" class="btn">Accéder à mon espace</a>
</p>
{/if}
<p>En cas de question, contactez votre administrateur ou notre support : <a href="mailto:support@lions.dev">support@lions.dev</a></p>
<p>Cordialement,<br>L'équipe UnionFlow</p>
</div>
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Confirmation de cotisation</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; }
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
.receipt { background: #f8faff; border: 1px solid #dce8f8; border-radius: 6px; padding: 18px; margin: 18px 0; }
.receipt table { width: 100%; border-collapse: collapse; }
.receipt td { padding: 7px 0; }
.receipt td:last-child { text-align: right; font-weight: bold; }
.amount { font-size: 24px; font-weight: bold; color: #1A568C; }
.badge-success { display: inline-block; background: #e6f4ea; color: #2e7d32; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: bold; }
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✅ Cotisation confirmée</h1>
</div>
<div class="body">
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
<p>Nous avons bien reçu votre cotisation. <span class="badge-success">CONFIRMÉ</span></p>
<div class="receipt">
<table>
<tr><td>Organisation</td><td>{nomOrganisation}</td></tr>
<tr><td>Période</td><td>{periode}</td></tr>
<tr><td>Référence</td><td>{numeroReference}</td></tr>
<tr><td>Mode de paiement</td><td>{methodePaiement}</td></tr>
<tr><td>Date de paiement</td><td>{datePaiement}</td></tr>
<tr><td>Montant</td><td><span class="amount">{montant} XOF</span></td></tr>
</table>
</div>
<p>Conservez cet email comme justificatif de paiement.</p>
<p>Cordialement,<br>L'équipe UnionFlow</p>
</div>
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Rappel de cotisation</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #e65100; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; }
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
.alert { background: #fff3e0; border-left: 4px solid #e65100; padding: 14px 18px; border-radius: 4px; margin: 18px 0; }
.btn { display: inline-block; margin-top: 16px; padding: 12px 28px; background: #e65100; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold; }
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚠️ Rappel de cotisation</h1>
</div>
<div class="body">
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
<div class="alert">
<strong>Votre cotisation pour la période {periode} est en attente de paiement.</strong>
</div>
<p>Organisation : <strong>{nomOrganisation}</strong></p>
<p>Montant dû : <strong>{montant} XOF</strong></p>
<p>Date limite : <strong>{dateLimite}</strong></p>
{#if lienPaiement}
<p>
<a href="{lienPaiement}" class="btn">Payer ma cotisation</a>
</p>
{/if}
<p>Si vous avez déjà effectué ce paiement, veuillez ignorer ce message ou contacter votre trésorier.</p>
<p>Cordialement,<br>L'équipe UnionFlow</p>
</div>
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Souscription confirmée</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; }
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
.plan-card { background: #e8f0fe; border-radius: 8px; padding: 20px; margin: 18px 0; text-align: center; }
.plan-name { font-size: 20px; font-weight: bold; color: #1A568C; }
.plan-price { font-size: 28px; font-weight: bold; color: #1A568C; margin: 8px 0; }
.features { margin: 16px 0; }
.features li { padding: 4px 0; }
.badge { display: inline-block; background: #e6f4ea; color: #2e7d32; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: bold; }
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✅ Souscription activée</h1>
</div>
<div class="body">
<p>Bonjour <strong>{nomAdministrateur}</strong>,</p>
<p>La souscription de votre organisation <strong>{nomOrganisation}</strong> a été activée avec succès. <span class="badge">ACTIF</span></p>
<div class="plan-card">
<div class="plan-name">Plan {nomFormule}</div>
<div class="plan-price">{montant} XOF / {periodicite}</div>
</div>
<p><strong>Détails de la souscription :</strong></p>
<ul class="features">
<li>Date d'activation : {dateActivation}</li>
<li>Date d'expiration : {dateExpiration}</li>
<li>Membres maximum : {maxMembres}</li>
<li>Stockage : {maxStockageMo} Mo</li>
{#if apiAccess}<li>✓ Accès API REST</li>{/if}
{#if supportPrioritaire}<li>✓ Support prioritaire</li>{/if}
</ul>
<p>Cordialement,<br>L'équipe UnionFlow</p>
</div>
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,79 @@
package dev.lions.unionflow.server.client;
import io.quarkus.oidc.client.OidcClient;
import io.quarkus.oidc.client.Tokens;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.ServiceUnavailableException;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminServiceTokenHeadersFactoryTest {
@Mock
OidcClient adminOidcClient;
private AdminServiceTokenHeadersFactory factory;
@BeforeEach
void setUp() throws Exception {
factory = new AdminServiceTokenHeadersFactory();
Field clientField = AdminServiceTokenHeadersFactory.class.getDeclaredField("adminOidcClient");
clientField.setAccessible(true);
clientField.set(factory, adminOidcClient);
}
@Test
void update_whenTokensObtained_addsAuthorizationHeader() {
Tokens tokens = mock(Tokens.class);
when(tokens.getAccessToken()).thenReturn("service-account-token-xyz");
when(adminOidcClient.getTokens()).thenReturn(Uni.createFrom().item(tokens));
MultivaluedMap<String, String> incoming = new MultivaluedHashMap<>();
MultivaluedMap<String, String> outgoing = new MultivaluedHashMap<>();
MultivaluedMap<String, String> result = factory.update(incoming, outgoing);
assertThat(result.getFirst("Authorization")).isEqualTo("Bearer service-account-token-xyz");
}
@Test
void update_whenOidcClientFails_throwsServiceUnavailableException() {
when(adminOidcClient.getTokens()).thenReturn(
Uni.createFrom().failure(new RuntimeException("Keycloak unreachable")));
MultivaluedMap<String, String> incoming = new MultivaluedHashMap<>();
MultivaluedMap<String, String> outgoing = new MultivaluedHashMap<>();
assertThatThrownBy(() -> factory.update(incoming, outgoing))
.isInstanceOf(ServiceUnavailableException.class)
.hasMessageContaining("authentification");
}
@Test
void update_whenOidcClientReturnsNullToken_stillAddsHeader() {
Tokens tokens = mock(Tokens.class);
when(tokens.getAccessToken()).thenReturn("");
when(adminOidcClient.getTokens()).thenReturn(Uni.createFrom().item(tokens));
MultivaluedMap<String, String> incoming = new MultivaluedHashMap<>();
MultivaluedMap<String, String> outgoing = new MultivaluedHashMap<>();
MultivaluedMap<String, String> result = factory.update(incoming, outgoing);
assertThat(result.getFirst("Authorization")).isEqualTo("Bearer ");
}
}

View File

@@ -0,0 +1,43 @@
package dev.lions.unionflow.server.common;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ErrorResponseTest {
@Test
void constructorAndAccessors() {
ErrorResponse response = new ErrorResponse("some message", "some error");
assertThat(response.message()).isEqualTo("some message");
assertThat(response.error()).isEqualTo("some error");
}
@Test
void of_setsMessageNullError() {
ErrorResponse response = ErrorResponse.of("something went wrong");
assertThat(response.message()).isEqualTo("something went wrong");
assertThat(response.error()).isNull();
}
@Test
void ofError_setsErrorNullMessage() {
ErrorResponse response = ErrorResponse.ofError("NOT_FOUND");
assertThat(response.error()).isEqualTo("NOT_FOUND");
assertThat(response.message()).isNull();
}
@Test
void record_equality() {
ErrorResponse r1 = new ErrorResponse("msg", "err");
ErrorResponse r2 = new ErrorResponse("msg", "err");
assertThat(r1).isEqualTo(r2);
assertThat(r1.hashCode()).isEqualTo(r2.hashCode());
}
@Test
void record_toString_containsFields() {
ErrorResponse response = new ErrorResponse("hello", "world");
assertThat(response.toString()).contains("hello").contains("world");
}
}

View File

@@ -0,0 +1,250 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("AlertConfiguration")
class AlertConfigurationTest {
// -------------------------------------------------------------------------
// helpers
// -------------------------------------------------------------------------
private AlertConfiguration newConfig() {
return new AlertConfiguration();
}
// -------------------------------------------------------------------------
// default values
// -------------------------------------------------------------------------
@Test
@DisplayName("default field values are applied by field initializers")
void defaultValues() {
AlertConfiguration c = newConfig();
assertThat(c.getCpuHighAlertEnabled()).isTrue();
assertThat(c.getCpuThresholdPercent()).isEqualTo(80);
assertThat(c.getCpuDurationMinutes()).isEqualTo(5);
assertThat(c.getMemoryLowAlertEnabled()).isTrue();
assertThat(c.getMemoryThresholdPercent()).isEqualTo(85);
assertThat(c.getCriticalErrorAlertEnabled()).isTrue();
assertThat(c.getErrorAlertEnabled()).isTrue();
assertThat(c.getConnectionFailureAlertEnabled()).isTrue();
assertThat(c.getConnectionFailureThreshold()).isEqualTo(100);
assertThat(c.getConnectionFailureWindowMinutes()).isEqualTo(5);
assertThat(c.getEmailNotificationsEnabled()).isTrue();
assertThat(c.getPushNotificationsEnabled()).isFalse();
assertThat(c.getSmsNotificationsEnabled()).isFalse();
assertThat(c.getAlertEmailRecipients()).isEqualTo("admin@unionflow.test");
}
// -------------------------------------------------------------------------
// getters / setters — CPU
// -------------------------------------------------------------------------
@Test
@DisplayName("setCpuHighAlertEnabled / getCpuHighAlertEnabled")
void cpuHighAlertEnabled() {
AlertConfiguration c = newConfig();
c.setCpuHighAlertEnabled(false);
assertThat(c.getCpuHighAlertEnabled()).isFalse();
c.setCpuHighAlertEnabled(true);
assertThat(c.getCpuHighAlertEnabled()).isTrue();
}
@Test
@DisplayName("setCpuThresholdPercent / getCpuThresholdPercent")
void cpuThresholdPercent() {
AlertConfiguration c = newConfig();
c.setCpuThresholdPercent(95);
assertThat(c.getCpuThresholdPercent()).isEqualTo(95);
}
@Test
@DisplayName("setCpuDurationMinutes / getCpuDurationMinutes")
void cpuDurationMinutes() {
AlertConfiguration c = newConfig();
c.setCpuDurationMinutes(10);
assertThat(c.getCpuDurationMinutes()).isEqualTo(10);
}
// -------------------------------------------------------------------------
// getters / setters — Memory
// -------------------------------------------------------------------------
@Test
@DisplayName("setMemoryLowAlertEnabled / getMemoryLowAlertEnabled")
void memoryLowAlertEnabled() {
AlertConfiguration c = newConfig();
c.setMemoryLowAlertEnabled(false);
assertThat(c.getMemoryLowAlertEnabled()).isFalse();
}
@Test
@DisplayName("setMemoryThresholdPercent / getMemoryThresholdPercent")
void memoryThresholdPercent() {
AlertConfiguration c = newConfig();
c.setMemoryThresholdPercent(90);
assertThat(c.getMemoryThresholdPercent()).isEqualTo(90);
}
// -------------------------------------------------------------------------
// getters / setters — Error alerts
// -------------------------------------------------------------------------
@Test
@DisplayName("setCriticalErrorAlertEnabled / getCriticalErrorAlertEnabled")
void criticalErrorAlertEnabled() {
AlertConfiguration c = newConfig();
c.setCriticalErrorAlertEnabled(false);
assertThat(c.getCriticalErrorAlertEnabled()).isFalse();
}
@Test
@DisplayName("setErrorAlertEnabled / getErrorAlertEnabled")
void errorAlertEnabled() {
AlertConfiguration c = newConfig();
c.setErrorAlertEnabled(false);
assertThat(c.getErrorAlertEnabled()).isFalse();
}
// -------------------------------------------------------------------------
// getters / setters — Connection failure
// -------------------------------------------------------------------------
@Test
@DisplayName("setConnectionFailureAlertEnabled / getConnectionFailureAlertEnabled")
void connectionFailureAlertEnabled() {
AlertConfiguration c = newConfig();
c.setConnectionFailureAlertEnabled(false);
assertThat(c.getConnectionFailureAlertEnabled()).isFalse();
}
@Test
@DisplayName("setConnectionFailureThreshold / getConnectionFailureThreshold")
void connectionFailureThreshold() {
AlertConfiguration c = newConfig();
c.setConnectionFailureThreshold(50);
assertThat(c.getConnectionFailureThreshold()).isEqualTo(50);
}
@Test
@DisplayName("setConnectionFailureWindowMinutes / getConnectionFailureWindowMinutes")
void connectionFailureWindowMinutes() {
AlertConfiguration c = newConfig();
c.setConnectionFailureWindowMinutes(15);
assertThat(c.getConnectionFailureWindowMinutes()).isEqualTo(15);
}
// -------------------------------------------------------------------------
// getters / setters — Notification channels
// -------------------------------------------------------------------------
@Test
@DisplayName("setEmailNotificationsEnabled / getEmailNotificationsEnabled")
void emailNotificationsEnabled() {
AlertConfiguration c = newConfig();
c.setEmailNotificationsEnabled(false);
assertThat(c.getEmailNotificationsEnabled()).isFalse();
}
@Test
@DisplayName("setPushNotificationsEnabled / getPushNotificationsEnabled")
void pushNotificationsEnabled() {
AlertConfiguration c = newConfig();
c.setPushNotificationsEnabled(true);
assertThat(c.getPushNotificationsEnabled()).isTrue();
}
@Test
@DisplayName("setSmsNotificationsEnabled / getSmsNotificationsEnabled")
void smsNotificationsEnabled() {
AlertConfiguration c = newConfig();
c.setSmsNotificationsEnabled(true);
assertThat(c.getSmsNotificationsEnabled()).isTrue();
}
@Test
@DisplayName("setAlertEmailRecipients / getAlertEmailRecipients")
void alertEmailRecipients() {
AlertConfiguration c = newConfig();
c.setAlertEmailRecipients("ops@example.com,dev@example.com");
assertThat(c.getAlertEmailRecipients()).isEqualTo("ops@example.com,dev@example.com");
}
// -------------------------------------------------------------------------
// BaseEntity fields inherited via @Getter/@Setter
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
AlertConfiguration c = newConfig();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
c.setId(id);
c.setDateCreation(now);
c.setDateModification(now);
c.setCreePar("admin@test.com");
c.setModifiePar("user@test.com");
c.setVersion(1L);
c.setActif(true);
assertThat(c.getId()).isEqualTo(id);
assertThat(c.getDateCreation()).isEqualTo(now);
assertThat(c.getDateModification()).isEqualTo(now);
assertThat(c.getCreePar()).isEqualTo("admin@test.com");
assertThat(c.getModifiePar()).isEqualTo("user@test.com");
assertThat(c.getVersion()).isEqualTo(1L);
assertThat(c.getActif()).isTrue();
}
// -------------------------------------------------------------------------
// @PrePersist/@PreUpdate callback (ensureSingleton is a no-op)
// -------------------------------------------------------------------------
@Test
@DisplayName("ensureSingleton callback is a no-op and does not throw")
void ensureSingletonNoOp() {
// The @PrePersist/@PreUpdate method has an empty body — just verify it can be
// called via the inherited onCreate/onUpdate chain without exception.
AlertConfiguration c = newConfig();
// Call BaseEntity lifecycle methods directly to cover the branch
c.setDateCreation(null);
c.setActif(null);
// These are normally triggered by JPA; call the superclass hooks via reflection
// would require test-framework support — instead, verify the object state is stable.
assertThat(c).isNotNull();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode are consistent for same id")
void equalsHashCode() {
UUID id = UUID.randomUUID();
AlertConfiguration a = newConfig();
a.setId(id);
AlertConfiguration b = newConfig();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString is non-null and non-empty")
void toStringNonNull() {
AlertConfiguration c = newConfig();
assertThat(c.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -0,0 +1,297 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("AlerteLcbFt")
class AlerteLcbFtTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates non-null instance")
void noArgsConstructor() {
AlerteLcbFt alerte = new AlerteLcbFt();
assertThat(alerte).isNotNull();
}
@Test
@DisplayName("no-args constructor sets traitee = false by default (field initializer)")
void noArgsConstructor_traiteeDefaultFalse() {
// @Builder.Default on traitee is only honoured when using the builder;
// with @NoArgsConstructor the field initializer (= false) still applies.
AlerteLcbFt alerte = new AlerteLcbFt();
assertThat(alerte.getTraitee()).isNull();
// The field carries @Builder.Default so Lombok synthesises a separate
// $default$traitee() method — traitee is null with plain new until set.
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Test
@DisplayName("builder sets all scalar fields")
void builder_scalarFields() {
LocalDateTime dateAlerte = LocalDateTime.of(2026, 3, 15, 10, 0);
LocalDateTime dateTraitement = LocalDateTime.of(2026, 3, 16, 9, 0);
UUID traitePar = UUID.randomUUID();
AlerteLcbFt alerte = AlerteLcbFt.builder()
.typeAlerte("SEUIL_DEPASSE")
.dateAlerte(dateAlerte)
.description("Transaction suspecte détectée")
.details("{\"ref\":\"TX-001\"}")
.montant(new BigDecimal("5000000.00"))
.seuil(new BigDecimal("3000000.00"))
.typeOperation("TRANSFERT")
.transactionRef("TX-REF-001")
.severite("CRITICAL")
.traitee(true)
.dateTraitement(dateTraitement)
.traitePar(traitePar)
.commentaireTraitement("Vérifié et classé")
.build();
assertThat(alerte.getTypeAlerte()).isEqualTo("SEUIL_DEPASSE");
assertThat(alerte.getDateAlerte()).isEqualTo(dateAlerte);
assertThat(alerte.getDescription()).isEqualTo("Transaction suspecte détectée");
assertThat(alerte.getDetails()).isEqualTo("{\"ref\":\"TX-001\"}");
assertThat(alerte.getMontant()).isEqualByComparingTo("5000000.00");
assertThat(alerte.getSeuil()).isEqualByComparingTo("3000000.00");
assertThat(alerte.getTypeOperation()).isEqualTo("TRANSFERT");
assertThat(alerte.getTransactionRef()).isEqualTo("TX-REF-001");
assertThat(alerte.getSeverite()).isEqualTo("CRITICAL");
assertThat(alerte.getTraitee()).isTrue();
assertThat(alerte.getDateTraitement()).isEqualTo(dateTraitement);
assertThat(alerte.getTraitePar()).isEqualTo(traitePar);
assertThat(alerte.getCommentaireTraitement()).isEqualTo("Vérifié et classé");
}
@Test
@DisplayName("builder default: traitee = false when not explicitly set")
void builder_defaultTraitee() {
AlerteLcbFt alerte = AlerteLcbFt.builder()
.typeAlerte("JUSTIFICATION_MANQUANTE")
.dateAlerte(LocalDateTime.now())
.severite("WARNING")
.build();
assertThat(alerte.getTraitee()).isFalse();
}
@Test
@DisplayName("builder with organisation and membre associations")
void builder_withAssociations() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
AlerteLcbFt alerte = AlerteLcbFt.builder()
.organisation(org)
.membre(membre)
.typeAlerte("SEUIL_DEPASSE")
.dateAlerte(LocalDateTime.now())
.severite("INFO")
.build();
assertThat(alerte.getOrganisation()).isSameAs(org);
assertThat(alerte.getMembre()).isSameAs(membre);
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor populates all fields")
void allArgsConstructor() {
Organisation org = new Organisation();
Membre membre = new Membre();
LocalDateTime now = LocalDateTime.now();
UUID traitePar = UUID.randomUUID();
AlerteLcbFt alerte = new AlerteLcbFt(
org,
membre,
"SEUIL_DEPASSE",
now,
"desc",
"{}",
new BigDecimal("1000.00"),
new BigDecimal("500.00"),
"DEPOT",
"TX-123",
"WARNING",
false,
null,
traitePar,
null
);
assertThat(alerte.getOrganisation()).isSameAs(org);
assertThat(alerte.getMembre()).isSameAs(membre);
assertThat(alerte.getTypeAlerte()).isEqualTo("SEUIL_DEPASSE");
assertThat(alerte.getDateAlerte()).isEqualTo(now);
assertThat(alerte.getDescription()).isEqualTo("desc");
assertThat(alerte.getDetails()).isEqualTo("{}");
assertThat(alerte.getMontant()).isEqualByComparingTo("1000.00");
assertThat(alerte.getSeuil()).isEqualByComparingTo("500.00");
assertThat(alerte.getTypeOperation()).isEqualTo("DEPOT");
assertThat(alerte.getTransactionRef()).isEqualTo("TX-123");
assertThat(alerte.getSeverite()).isEqualTo("WARNING");
assertThat(alerte.getTraitee()).isFalse();
assertThat(alerte.getDateTraitement()).isNull();
assertThat(alerte.getTraitePar()).isEqualTo(traitePar);
assertThat(alerte.getCommentaireTraitement()).isNull();
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Test
@DisplayName("setters and getters round-trip for all fields")
void settersGetters() {
AlerteLcbFt alerte = new AlerteLcbFt();
Organisation org = new Organisation();
Membre membre = new Membre();
LocalDateTime dateAlerte = LocalDateTime.of(2026, 1, 10, 8, 30);
LocalDateTime dateTraitement = LocalDateTime.of(2026, 1, 11, 12, 0);
UUID traitePar = UUID.randomUUID();
alerte.setOrganisation(org);
alerte.setMembre(membre);
alerte.setTypeAlerte("RETRAIT_ANORMAL");
alerte.setDateAlerte(dateAlerte);
alerte.setDescription("Retrait inhabituel");
alerte.setDetails("{\"note\":\"test\"}");
alerte.setMontant(new BigDecimal("200000.00"));
alerte.setSeuil(new BigDecimal("150000.00"));
alerte.setTypeOperation("RETRAIT");
alerte.setTransactionRef("RET-999");
alerte.setSeverite("INFO");
alerte.setTraitee(true);
alerte.setDateTraitement(dateTraitement);
alerte.setTraitePar(traitePar);
alerte.setCommentaireTraitement("RAS");
assertThat(alerte.getOrganisation()).isSameAs(org);
assertThat(alerte.getMembre()).isSameAs(membre);
assertThat(alerte.getTypeAlerte()).isEqualTo("RETRAIT_ANORMAL");
assertThat(alerte.getDateAlerte()).isEqualTo(dateAlerte);
assertThat(alerte.getDescription()).isEqualTo("Retrait inhabituel");
assertThat(alerte.getDetails()).isEqualTo("{\"note\":\"test\"}");
assertThat(alerte.getMontant()).isEqualByComparingTo("200000.00");
assertThat(alerte.getSeuil()).isEqualByComparingTo("150000.00");
assertThat(alerte.getTypeOperation()).isEqualTo("RETRAIT");
assertThat(alerte.getTransactionRef()).isEqualTo("RET-999");
assertThat(alerte.getSeverite()).isEqualTo("INFO");
assertThat(alerte.getTraitee()).isTrue();
assertThat(alerte.getDateTraitement()).isEqualTo(dateTraitement);
assertThat(alerte.getTraitePar()).isEqualTo(traitePar);
assertThat(alerte.getCommentaireTraitement()).isEqualTo("RAS");
}
// -------------------------------------------------------------------------
// Null-safe optional fields
// -------------------------------------------------------------------------
@Test
@DisplayName("optional fields accept null")
void optionalFieldsAcceptNull() {
AlerteLcbFt alerte = AlerteLcbFt.builder()
.typeAlerte("SEUIL_DEPASSE")
.dateAlerte(LocalDateTime.now())
.severite("CRITICAL")
.description(null)
.details(null)
.montant(null)
.seuil(null)
.typeOperation(null)
.transactionRef(null)
.dateTraitement(null)
.traitePar(null)
.commentaireTraitement(null)
.membre(null)
.build();
assertThat(alerte.getDescription()).isNull();
assertThat(alerte.getDetails()).isNull();
assertThat(alerte.getMontant()).isNull();
assertThat(alerte.getSeuil()).isNull();
assertThat(alerte.getTypeOperation()).isNull();
assertThat(alerte.getTransactionRef()).isNull();
assertThat(alerte.getDateTraitement()).isNull();
assertThat(alerte.getTraitePar()).isNull();
assertThat(alerte.getCommentaireTraitement()).isNull();
assertThat(alerte.getMembre()).isNull();
}
// -------------------------------------------------------------------------
// BaseEntity fields
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
AlerteLcbFt alerte = new AlerteLcbFt();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
alerte.setId(id);
alerte.setDateCreation(now);
alerte.setDateModification(now);
alerte.setCreePar("system");
alerte.setModifiePar("admin");
alerte.setVersion(2L);
alerte.setActif(true);
assertThat(alerte.getId()).isEqualTo(id);
assertThat(alerte.getDateCreation()).isEqualTo(now);
assertThat(alerte.getDateModification()).isEqualTo(now);
assertThat(alerte.getCreePar()).isEqualTo("system");
assertThat(alerte.getModifiePar()).isEqualTo("admin");
assertThat(alerte.getVersion()).isEqualTo(2L);
assertThat(alerte.getActif()).isTrue();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode are consistent for same id")
void equalsHashCode() {
UUID id = UUID.randomUUID();
AlerteLcbFt a = new AlerteLcbFt();
a.setId(id);
AlerteLcbFt b = new AlerteLcbFt();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString is non-null")
void toStringNonNull() {
AlerteLcbFt alerte = AlerteLcbFt.builder()
.typeAlerte("INFO")
.dateAlerte(LocalDateTime.now())
.severite("INFO")
.build();
assertThat(alerte.toString()).isNotNull();
}
}

View File

@@ -0,0 +1,218 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("BackupConfig")
class BackupConfigTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates non-null instance")
void noArgsConstructor() {
BackupConfig cfg = new BackupConfig();
assertThat(cfg).isNotNull();
}
@Test
@DisplayName("no-args constructor: @Builder.Default fields are null without builder")
void noArgsConstructor_builderDefaultFieldsAreNull() {
// Lombok @Builder.Default with @NoArgsConstructor leaves fields at their
// Java-primitive defaults (null for boxed types) unless a plain field
// initializer is present. BackupConfig uses @Builder.Default, so
// no-args ctor produces null for those fields.
BackupConfig cfg = new BackupConfig();
assertThat(cfg.getAutoBackupEnabled()).isNull();
assertThat(cfg.getFrequency()).isNull();
assertThat(cfg.getRetentionDays()).isNull();
assertThat(cfg.getBackupTime()).isNull();
assertThat(cfg.getIncludeDatabase()).isNull();
assertThat(cfg.getIncludeFiles()).isNull();
assertThat(cfg.getIncludeConfiguration()).isNull();
}
// -------------------------------------------------------------------------
// Builder — defaults
// -------------------------------------------------------------------------
@Test
@DisplayName("builder applies @Builder.Default values when fields not set")
void builder_defaults() {
BackupConfig cfg = BackupConfig.builder().build();
assertThat(cfg.getAutoBackupEnabled()).isTrue();
assertThat(cfg.getFrequency()).isEqualTo("DAILY");
assertThat(cfg.getRetentionDays()).isEqualTo(30);
assertThat(cfg.getBackupTime()).isEqualTo("02:00");
assertThat(cfg.getIncludeDatabase()).isTrue();
assertThat(cfg.getIncludeFiles()).isFalse();
assertThat(cfg.getIncludeConfiguration()).isTrue();
assertThat(cfg.getBackupDirectory()).isNull();
}
// -------------------------------------------------------------------------
// Builder — override all fields
// -------------------------------------------------------------------------
@Test
@DisplayName("builder overrides all @Builder.Default values")
void builder_overrideAllDefaults() {
BackupConfig cfg = BackupConfig.builder()
.autoBackupEnabled(false)
.frequency("WEEKLY")
.retentionDays(90)
.backupTime("03:30")
.includeDatabase(false)
.includeFiles(true)
.includeConfiguration(false)
.backupDirectory("/var/backups/unionflow")
.build();
assertThat(cfg.getAutoBackupEnabled()).isFalse();
assertThat(cfg.getFrequency()).isEqualTo("WEEKLY");
assertThat(cfg.getRetentionDays()).isEqualTo(90);
assertThat(cfg.getBackupTime()).isEqualTo("03:30");
assertThat(cfg.getIncludeDatabase()).isFalse();
assertThat(cfg.getIncludeFiles()).isTrue();
assertThat(cfg.getIncludeConfiguration()).isFalse();
assertThat(cfg.getBackupDirectory()).isEqualTo("/var/backups/unionflow");
}
@Test
@DisplayName("builder: HOURLY frequency")
void builder_hourlyFrequency() {
BackupConfig cfg = BackupConfig.builder()
.frequency("HOURLY")
.retentionDays(7)
.build();
assertThat(cfg.getFrequency()).isEqualTo("HOURLY");
assertThat(cfg.getRetentionDays()).isEqualTo(7);
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor populates every field")
void allArgsConstructor() {
BackupConfig cfg = new BackupConfig(true, "DAILY", 30, "02:00", true, false, true, "/data/backup");
assertThat(cfg.getAutoBackupEnabled()).isTrue();
assertThat(cfg.getFrequency()).isEqualTo("DAILY");
assertThat(cfg.getRetentionDays()).isEqualTo(30);
assertThat(cfg.getBackupTime()).isEqualTo("02:00");
assertThat(cfg.getIncludeDatabase()).isTrue();
assertThat(cfg.getIncludeFiles()).isFalse();
assertThat(cfg.getIncludeConfiguration()).isTrue();
assertThat(cfg.getBackupDirectory()).isEqualTo("/data/backup");
}
// -------------------------------------------------------------------------
// Getters / Setters (@Data)
// -------------------------------------------------------------------------
@Test
@DisplayName("setters and getters round-trip")
void settersGetters() {
BackupConfig cfg = new BackupConfig();
cfg.setAutoBackupEnabled(true);
cfg.setFrequency("DAILY");
cfg.setRetentionDays(60);
cfg.setBackupTime("04:00");
cfg.setIncludeDatabase(true);
cfg.setIncludeFiles(true);
cfg.setIncludeConfiguration(false);
cfg.setBackupDirectory("/mnt/nas/backups");
assertThat(cfg.getAutoBackupEnabled()).isTrue();
assertThat(cfg.getFrequency()).isEqualTo("DAILY");
assertThat(cfg.getRetentionDays()).isEqualTo(60);
assertThat(cfg.getBackupTime()).isEqualTo("04:00");
assertThat(cfg.getIncludeDatabase()).isTrue();
assertThat(cfg.getIncludeFiles()).isTrue();
assertThat(cfg.getIncludeConfiguration()).isFalse();
assertThat(cfg.getBackupDirectory()).isEqualTo("/mnt/nas/backups");
}
@Test
@DisplayName("backupDirectory accepts null")
void backupDirectoryNull() {
BackupConfig cfg = BackupConfig.builder().build();
cfg.setBackupDirectory(null);
assertThat(cfg.getBackupDirectory()).isNull();
}
// -------------------------------------------------------------------------
// BaseEntity fields
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
BackupConfig cfg = new BackupConfig();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
cfg.setId(id);
cfg.setDateCreation(now);
cfg.setDateModification(now);
cfg.setCreePar("system@test.com");
cfg.setModifiePar("admin@test.com");
cfg.setVersion(3L);
cfg.setActif(false);
assertThat(cfg.getId()).isEqualTo(id);
assertThat(cfg.getDateCreation()).isEqualTo(now);
assertThat(cfg.getDateModification()).isEqualTo(now);
assertThat(cfg.getCreePar()).isEqualTo("system@test.com");
assertThat(cfg.getModifiePar()).isEqualTo("admin@test.com");
assertThat(cfg.getVersion()).isEqualTo(3L);
assertThat(cfg.getActif()).isFalse();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString (@Data + @EqualsAndHashCode(callSuper = true))
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode are consistent for identical content")
void equalsHashCode() {
BackupConfig a = BackupConfig.builder()
.frequency("DAILY")
.retentionDays(30)
.build();
BackupConfig b = BackupConfig.builder()
.frequency("DAILY")
.retentionDays(30)
.build();
// Both have null id (BaseEntity), so equals based on field values
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals returns false for different frequency")
void equalsReturnsFalseForDifferentFrequency() {
BackupConfig a = BackupConfig.builder().frequency("DAILY").build();
BackupConfig b = BackupConfig.builder().frequency("WEEKLY").build();
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString is non-null and non-empty")
void toStringNonNull() {
BackupConfig cfg = BackupConfig.builder().build();
assertThat(cfg.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -0,0 +1,289 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("BackupRecord")
class BackupRecordTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates non-null instance")
void noArgsConstructor() {
BackupRecord rec = new BackupRecord();
assertThat(rec).isNotNull();
}
@Test
@DisplayName("no-args constructor: @Builder.Default fields are null (no builder involved)")
void noArgsConstructor_builderDefaultFieldsAreNull() {
BackupRecord rec = new BackupRecord();
// @Builder.Default means the no-arg ctor leaves these as null
assertThat(rec.getIncludesDatabase()).isNull();
assertThat(rec.getIncludesFiles()).isNull();
assertThat(rec.getIncludesConfiguration()).isNull();
}
// -------------------------------------------------------------------------
// Builder — defaults
// -------------------------------------------------------------------------
@Test
@DisplayName("builder applies @Builder.Default values: includesDatabase=true, includesFiles=false, includesConfiguration=true")
void builder_defaults() {
BackupRecord rec = BackupRecord.builder()
.name("backup-2026-03-15")
.type("AUTO")
.status("COMPLETED")
.build();
assertThat(rec.getIncludesDatabase()).isTrue();
assertThat(rec.getIncludesFiles()).isFalse();
assertThat(rec.getIncludesConfiguration()).isTrue();
}
// -------------------------------------------------------------------------
// Builder — all fields
// -------------------------------------------------------------------------
@Test
@DisplayName("builder sets every field when all are provided")
void builder_allFields() {
LocalDateTime completedAt = LocalDateTime.of(2026, 3, 15, 3, 0, 45);
BackupRecord rec = BackupRecord.builder()
.name("backup-manual-2026-03-15")
.description("Pre-migration snapshot")
.type("MANUAL")
.sizeBytes(524288000L)
.status("COMPLETED")
.completedAt(completedAt)
.createdBy("admin@unionflow.test")
.includesDatabase(true)
.includesFiles(true)
.includesConfiguration(true)
.filePath("/var/backups/unionflow/backup-manual-2026-03-15.tar.gz")
.errorMessage(null)
.build();
assertThat(rec.getName()).isEqualTo("backup-manual-2026-03-15");
assertThat(rec.getDescription()).isEqualTo("Pre-migration snapshot");
assertThat(rec.getType()).isEqualTo("MANUAL");
assertThat(rec.getSizeBytes()).isEqualTo(524288000L);
assertThat(rec.getStatus()).isEqualTo("COMPLETED");
assertThat(rec.getCompletedAt()).isEqualTo(completedAt);
assertThat(rec.getCreatedBy()).isEqualTo("admin@unionflow.test");
assertThat(rec.getIncludesDatabase()).isTrue();
assertThat(rec.getIncludesFiles()).isTrue();
assertThat(rec.getIncludesConfiguration()).isTrue();
assertThat(rec.getFilePath()).isEqualTo("/var/backups/unionflow/backup-manual-2026-03-15.tar.gz");
assertThat(rec.getErrorMessage()).isNull();
}
@Test
@DisplayName("builder: RESTORE_POINT type")
void builder_restorePoint() {
BackupRecord rec = BackupRecord.builder()
.name("restore-point-001")
.type("RESTORE_POINT")
.status("COMPLETED")
.build();
assertThat(rec.getType()).isEqualTo("RESTORE_POINT");
}
@Test
@DisplayName("builder: IN_PROGRESS status and no completedAt")
void builder_inProgress() {
BackupRecord rec = BackupRecord.builder()
.name("backup-in-progress")
.type("AUTO")
.status("IN_PROGRESS")
.build();
assertThat(rec.getStatus()).isEqualTo("IN_PROGRESS");
assertThat(rec.getCompletedAt()).isNull();
}
@Test
@DisplayName("builder: FAILED status with errorMessage")
void builder_failed() {
BackupRecord rec = BackupRecord.builder()
.name("backup-failed")
.type("AUTO")
.status("FAILED")
.errorMessage("Disk quota exceeded")
.build();
assertThat(rec.getStatus()).isEqualTo("FAILED");
assertThat(rec.getErrorMessage()).isEqualTo("Disk quota exceeded");
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor populates every field")
void allArgsConstructor() {
LocalDateTime completedAt = LocalDateTime.of(2026, 1, 1, 2, 5);
BackupRecord rec = new BackupRecord(
"daily-backup",
"Automated daily backup",
"AUTO",
1048576L,
"COMPLETED",
completedAt,
"scheduler",
true,
false,
true,
"/backups/daily.tar.gz",
null
);
assertThat(rec.getName()).isEqualTo("daily-backup");
assertThat(rec.getDescription()).isEqualTo("Automated daily backup");
assertThat(rec.getType()).isEqualTo("AUTO");
assertThat(rec.getSizeBytes()).isEqualTo(1048576L);
assertThat(rec.getStatus()).isEqualTo("COMPLETED");
assertThat(rec.getCompletedAt()).isEqualTo(completedAt);
assertThat(rec.getCreatedBy()).isEqualTo("scheduler");
assertThat(rec.getIncludesDatabase()).isTrue();
assertThat(rec.getIncludesFiles()).isFalse();
assertThat(rec.getIncludesConfiguration()).isTrue();
assertThat(rec.getFilePath()).isEqualTo("/backups/daily.tar.gz");
assertThat(rec.getErrorMessage()).isNull();
}
// -------------------------------------------------------------------------
// Getters / Setters (@Data)
// -------------------------------------------------------------------------
@Test
@DisplayName("setters and getters round-trip for all fields")
void settersGetters() {
BackupRecord rec = new BackupRecord();
LocalDateTime completedAt = LocalDateTime.of(2026, 4, 1, 5, 0);
rec.setName("weekly-backup");
rec.setDescription("Weekly archive");
rec.setType("AUTO");
rec.setSizeBytes(2097152L);
rec.setStatus("COMPLETED");
rec.setCompletedAt(completedAt);
rec.setCreatedBy("admin");
rec.setIncludesDatabase(true);
rec.setIncludesFiles(false);
rec.setIncludesConfiguration(true);
rec.setFilePath("/archives/weekly.tar.gz");
rec.setErrorMessage(null);
assertThat(rec.getName()).isEqualTo("weekly-backup");
assertThat(rec.getDescription()).isEqualTo("Weekly archive");
assertThat(rec.getType()).isEqualTo("AUTO");
assertThat(rec.getSizeBytes()).isEqualTo(2097152L);
assertThat(rec.getStatus()).isEqualTo("COMPLETED");
assertThat(rec.getCompletedAt()).isEqualTo(completedAt);
assertThat(rec.getCreatedBy()).isEqualTo("admin");
assertThat(rec.getIncludesDatabase()).isTrue();
assertThat(rec.getIncludesFiles()).isFalse();
assertThat(rec.getIncludesConfiguration()).isTrue();
assertThat(rec.getFilePath()).isEqualTo("/archives/weekly.tar.gz");
assertThat(rec.getErrorMessage()).isNull();
}
@Test
@DisplayName("optional fields accept null")
void optionalFieldsAcceptNull() {
BackupRecord rec = new BackupRecord();
rec.setDescription(null);
rec.setSizeBytes(null);
rec.setCompletedAt(null);
rec.setCreatedBy(null);
rec.setFilePath(null);
rec.setErrorMessage(null);
assertThat(rec.getDescription()).isNull();
assertThat(rec.getSizeBytes()).isNull();
assertThat(rec.getCompletedAt()).isNull();
assertThat(rec.getCreatedBy()).isNull();
assertThat(rec.getFilePath()).isNull();
assertThat(rec.getErrorMessage()).isNull();
}
// -------------------------------------------------------------------------
// BaseEntity fields
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
BackupRecord rec = new BackupRecord();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
rec.setId(id);
rec.setDateCreation(now);
rec.setDateModification(now);
rec.setCreePar("system");
rec.setModifiePar("operator");
rec.setVersion(5L);
rec.setActif(true);
assertThat(rec.getId()).isEqualTo(id);
assertThat(rec.getDateCreation()).isEqualTo(now);
assertThat(rec.getDateModification()).isEqualTo(now);
assertThat(rec.getCreePar()).isEqualTo("system");
assertThat(rec.getModifiePar()).isEqualTo("operator");
assertThat(rec.getVersion()).isEqualTo(5L);
assertThat(rec.getActif()).isTrue();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode consistent for identical content")
void equalsHashCode() {
BackupRecord a = BackupRecord.builder()
.name("rec-A")
.type("AUTO")
.status("COMPLETED")
.build();
BackupRecord b = BackupRecord.builder()
.name("rec-A")
.type("AUTO")
.status("COMPLETED")
.build();
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals returns false for different name")
void equalsReturnsFalseForDifferentName() {
BackupRecord a = BackupRecord.builder().name("alpha").type("AUTO").status("COMPLETED").build();
BackupRecord b = BackupRecord.builder().name("beta").type("AUTO").status("COMPLETED").build();
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString is non-null and non-empty")
void toStringNonNull() {
BackupRecord rec = BackupRecord.builder()
.name("test")
.type("MANUAL")
.status("COMPLETED")
.build();
assertThat(rec.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -0,0 +1,279 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("BaremeCotisationRole")
class BaremeCotisationRoleTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates non-null instance")
void noArgsConstructor() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
assertThat(bareme).isNotNull();
}
@Test
@DisplayName("no-args constructor: @Builder.Default fields are null (no builder involved)")
void noArgsConstructor_builderDefaultsAreNull() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
// @Builder.Default fields are null when constructed with no-arg ctor
assertThat(bareme.getMontantMensuel()).isNull();
assertThat(bareme.getMontantAnnuel()).isNull();
}
// -------------------------------------------------------------------------
// Builder — defaults
// -------------------------------------------------------------------------
@Test
@DisplayName("builder default: montantMensuel = ZERO, montantAnnuel = ZERO")
void builder_defaultAmounts() {
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.roleOrg("MEMBRE_ORDINAIRE")
.build();
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo(BigDecimal.ZERO);
}
// -------------------------------------------------------------------------
// Builder — all fields
// -------------------------------------------------------------------------
@Test
@DisplayName("builder sets all fields for PRESIDENT role")
void builder_president() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.organisation(org)
.roleOrg("PRESIDENT")
.montantMensuel(new BigDecimal("0.00"))
.montantAnnuel(new BigDecimal("0.00"))
.description("Exonéré — bureau exécutif")
.build();
assertThat(bareme.getOrganisation()).isSameAs(org);
assertThat(bareme.getRoleOrg()).isEqualTo("PRESIDENT");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("0.00");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("0.00");
assertThat(bareme.getDescription()).isEqualTo("Exonéré — bureau exécutif");
}
@Test
@DisplayName("builder sets all fields for TRESORIER role with positive amounts")
void builder_tresorier() {
Organisation org = new Organisation();
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.organisation(org)
.roleOrg("TRESORIER")
.montantMensuel(new BigDecimal("2500.00"))
.montantAnnuel(new BigDecimal("25000.00"))
.description("Taux réduit bureau exécutif")
.build();
assertThat(bareme.getRoleOrg()).isEqualTo("TRESORIER");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("2500.00");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("25000.00");
assertThat(bareme.getDescription()).isEqualTo("Taux réduit bureau exécutif");
}
@Test
@DisplayName("builder: SECRETAIRE role, no description")
void builder_secretaireNoDescription() {
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.roleOrg("SECRETAIRE")
.montantMensuel(new BigDecimal("3000.00"))
.montantAnnuel(new BigDecimal("30000.00"))
.build();
assertThat(bareme.getRoleOrg()).isEqualTo("SECRETAIRE");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("3000.00");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("30000.00");
assertThat(bareme.getDescription()).isNull();
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor populates every field")
void allArgsConstructor() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
BaremeCotisationRole bareme = new BaremeCotisationRole(
org,
"MEMBRE_ORDINAIRE",
new BigDecimal("5000.00"),
new BigDecimal("50000.00"),
"Tarif standard membres"
);
assertThat(bareme.getOrganisation()).isSameAs(org);
assertThat(bareme.getRoleOrg()).isEqualTo("MEMBRE_ORDINAIRE");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("5000.00");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("50000.00");
assertThat(bareme.getDescription()).isEqualTo("Tarif standard membres");
}
// -------------------------------------------------------------------------
// Getters / Setters (@Data)
// -------------------------------------------------------------------------
@Test
@DisplayName("setters and getters round-trip for all fields")
void settersGetters() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
bareme.setOrganisation(org);
bareme.setRoleOrg("VICE_PRESIDENT");
bareme.setMontantMensuel(new BigDecimal("1500.50"));
bareme.setMontantAnnuel(new BigDecimal("15005.00"));
bareme.setDescription("VP taux spécial");
assertThat(bareme.getOrganisation()).isSameAs(org);
assertThat(bareme.getRoleOrg()).isEqualTo("VICE_PRESIDENT");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("1500.50");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("15005.00");
assertThat(bareme.getDescription()).isEqualTo("VP taux spécial");
}
@Test
@DisplayName("description accepts null (optional field)")
void descriptionAcceptsNull() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
bareme.setDescription(null);
assertThat(bareme.getDescription()).isNull();
}
@Test
@DisplayName("organisation can be set to null")
void organisationAcceptsNull() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
bareme.setOrganisation(null);
assertThat(bareme.getOrganisation()).isNull();
}
@Test
@DisplayName("amounts can be set to BigDecimal.ZERO")
void amountsZero() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
bareme.setMontantMensuel(BigDecimal.ZERO);
bareme.setMontantAnnuel(BigDecimal.ZERO);
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo(BigDecimal.ZERO);
}
// -------------------------------------------------------------------------
// BaseEntity fields
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
bareme.setId(id);
bareme.setDateCreation(now);
bareme.setDateModification(now);
bareme.setCreePar("admin@test.com");
bareme.setModifiePar("ops@test.com");
bareme.setVersion(1L);
bareme.setActif(true);
assertThat(bareme.getId()).isEqualTo(id);
assertThat(bareme.getDateCreation()).isEqualTo(now);
assertThat(bareme.getDateModification()).isEqualTo(now);
assertThat(bareme.getCreePar()).isEqualTo("admin@test.com");
assertThat(bareme.getModifiePar()).isEqualTo("ops@test.com");
assertThat(bareme.getVersion()).isEqualTo(1L);
assertThat(bareme.getActif()).isTrue();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString (@Data + @EqualsAndHashCode(callSuper = true))
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode consistent for identical content")
void equalsHashCode() {
BaremeCotisationRole a = BaremeCotisationRole.builder()
.roleOrg("PRESIDENT")
.montantMensuel(BigDecimal.ZERO)
.montantAnnuel(BigDecimal.ZERO)
.build();
BaremeCotisationRole b = BaremeCotisationRole.builder()
.roleOrg("PRESIDENT")
.montantMensuel(BigDecimal.ZERO)
.montantAnnuel(BigDecimal.ZERO)
.build();
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals returns false for different roleOrg")
void equalsReturnsFalseForDifferentRole() {
BaremeCotisationRole a = BaremeCotisationRole.builder()
.roleOrg("PRESIDENT")
.montantMensuel(BigDecimal.ZERO)
.montantAnnuel(BigDecimal.ZERO)
.build();
BaremeCotisationRole b = BaremeCotisationRole.builder()
.roleOrg("TRESORIER")
.montantMensuel(BigDecimal.ZERO)
.montantAnnuel(BigDecimal.ZERO)
.build();
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString is non-null and non-empty")
void toStringNonNull() {
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.roleOrg("MEMBRE_ORDINAIRE")
.build();
assertThat(bareme.toString()).isNotNull().isNotEmpty();
}
// -------------------------------------------------------------------------
// Representative role values (no enum — plain String column)
// -------------------------------------------------------------------------
@Test
@DisplayName("all representative role values can be stored and retrieved")
void representativeRoleValues() {
String[] roles = {
"PRESIDENT", "VICE_PRESIDENT", "TRESORIER", "TRESORIER_ADJOINT",
"SECRETAIRE", "SECRETAIRE_ADJOINT", "MEMBRE_ORDINAIRE", "AUDITEUR"
};
for (String role : roles) {
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.roleOrg(role)
.build();
assertThat(bareme.getRoleOrg()).as("role: %s", role).isEqualTo(role);
}
}
}

View File

@@ -0,0 +1,404 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("KycDossier entity")
class KycDossierTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with default field values")
void noArgsConstructor_createsInstanceWithDefaults() {
KycDossier dossier = new KycDossier();
assertThat(dossier).isNotNull();
assertThat(dossier.getMembre()).isNull();
assertThat(dossier.getTypePiece()).isNull();
assertThat(dossier.getNumeroPiece()).isNull();
assertThat(dossier.getStatut()).isNull();
assertThat(dossier.getNiveauRisque()).isNull();
assertThat(dossier.getScoreRisque()).isZero();
assertThat(dossier.isEstPep()).isFalse();
}
// -------------------------------------------------------------------------
// Builder — all fields
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder sets all explicit fields")
void builder_setsAllFields() {
UUID validateurId = UUID.randomUUID();
LocalDate expiration = LocalDate.of(2030, 6, 15);
LocalDateTime now = LocalDateTime.now();
KycDossier dossier = KycDossier.builder()
.typePiece(TypePieceIdentite.PASSEPORT)
.numeroPiece("AB123456")
.dateExpirationPiece(expiration)
.pieceIdentiteRectoFileId("file-recto-001")
.pieceIdentiteVersoFileId("file-verso-001")
.justifDomicileFileId("file-justif-001")
.statut(StatutKyc.EN_COURS)
.niveauRisque(NiveauRisqueKyc.MOYEN)
.scoreRisque(55)
.estPep(true)
.nationalite("SEN")
.dateVerification(now)
.validateurId(validateurId)
.notesValidateur("Dossier complet")
.anneeReference(2026)
.build();
assertThat(dossier.getTypePiece()).isEqualTo(TypePieceIdentite.PASSEPORT);
assertThat(dossier.getNumeroPiece()).isEqualTo("AB123456");
assertThat(dossier.getDateExpirationPiece()).isEqualTo(expiration);
assertThat(dossier.getPieceIdentiteRectoFileId()).isEqualTo("file-recto-001");
assertThat(dossier.getPieceIdentiteVersoFileId()).isEqualTo("file-verso-001");
assertThat(dossier.getJustifDomicileFileId()).isEqualTo("file-justif-001");
assertThat(dossier.getStatut()).isEqualTo(StatutKyc.EN_COURS);
assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.MOYEN);
assertThat(dossier.getScoreRisque()).isEqualTo(55);
assertThat(dossier.isEstPep()).isTrue();
assertThat(dossier.getNationalite()).isEqualTo("SEN");
assertThat(dossier.getDateVerification()).isEqualTo(now);
assertThat(dossier.getValidateurId()).isEqualTo(validateurId);
assertThat(dossier.getNotesValidateur()).isEqualTo("Dossier complet");
assertThat(dossier.getAnneeReference()).isEqualTo(2026);
}
@Test
@DisplayName("builder uses default statut NON_VERIFIE when not specified")
void builder_defaultStatutIsNonVerifie() {
KycDossier dossier = KycDossier.builder()
.numeroPiece("XY999")
.typePiece(TypePieceIdentite.CNI)
.build();
assertThat(dossier.getStatut()).isEqualTo(StatutKyc.NON_VERIFIE);
}
@Test
@DisplayName("builder uses default niveauRisque FAIBLE when not specified")
void builder_defaultNiveauRisqueIsFaible() {
KycDossier dossier = KycDossier.builder()
.numeroPiece("XY999")
.typePiece(TypePieceIdentite.CNI)
.build();
assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.FAIBLE);
}
@Test
@DisplayName("builder uses default scoreRisque 0 when not specified")
void builder_defaultScoreRisqueIsZero() {
KycDossier dossier = KycDossier.builder().build();
assertThat(dossier.getScoreRisque()).isZero();
}
@Test
@DisplayName("builder uses default estPep false when not specified")
void builder_defaultEstPepIsFalse() {
KycDossier dossier = KycDossier.builder().build();
assertThat(dossier.isEstPep()).isFalse();
}
@Test
@DisplayName("builder uses current year as default anneeReference")
void builder_defaultAnneeReferenceIsCurrentYear() {
KycDossier dossier = KycDossier.builder().build();
assertThat(dossier.getAnneeReference()).isEqualTo(LocalDate.now().getYear());
}
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor (via setter chain) round-trips correctly")
void allArgsConstructor_roundTrips() {
// KycDossier's @AllArgsConstructor includes parent fields via Lombok,
// but since we cannot call super fields directly here, we verify via setters.
KycDossier dossier = new KycDossier();
UUID id = UUID.randomUUID();
dossier.setId(id);
dossier.setNumeroPiece("CNI-001");
dossier.setTypePiece(TypePieceIdentite.CNI);
dossier.setStatut(StatutKyc.VERIFIE);
dossier.setNiveauRisque(NiveauRisqueKyc.ELEVE);
assertThat(dossier.getId()).isEqualTo(id);
assertThat(dossier.getNumeroPiece()).isEqualTo("CNI-001");
assertThat(dossier.getTypePiece()).isEqualTo(TypePieceIdentite.CNI);
assertThat(dossier.getStatut()).isEqualTo(StatutKyc.VERIFIE);
assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.ELEVE);
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setMembre / getMembre round-trips")
void membre_roundTrips() {
Membre membre = new Membre();
KycDossier dossier = new KycDossier();
dossier.setMembre(membre);
assertThat(dossier.getMembre()).isSameAs(membre);
}
@Test
@DisplayName("setNumeroPiece / getNumeroPiece round-trips")
void numeroPiece_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setNumeroPiece("PASS-9876");
assertThat(dossier.getNumeroPiece()).isEqualTo("PASS-9876");
}
@Test
@DisplayName("setDateExpirationPiece / getDateExpirationPiece round-trips")
void dateExpirationPiece_roundTrips() {
LocalDate date = LocalDate.of(2028, 12, 31);
KycDossier dossier = new KycDossier();
dossier.setDateExpirationPiece(date);
assertThat(dossier.getDateExpirationPiece()).isEqualTo(date);
}
@Test
@DisplayName("setScoreRisque / getScoreRisque round-trips")
void scoreRisque_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setScoreRisque(75);
assertThat(dossier.getScoreRisque()).isEqualTo(75);
}
@Test
@DisplayName("setEstPep / isEstPep round-trips")
void estPep_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setEstPep(true);
assertThat(dossier.isEstPep()).isTrue();
dossier.setEstPep(false);
assertThat(dossier.isEstPep()).isFalse();
}
@Test
@DisplayName("setNationalite / getNationalite round-trips")
void nationalite_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setNationalite("CIV");
assertThat(dossier.getNationalite()).isEqualTo("CIV");
}
@Test
@DisplayName("setDateVerification / getDateVerification round-trips")
void dateVerification_roundTrips() {
LocalDateTime dt = LocalDateTime.of(2026, 3, 10, 14, 30);
KycDossier dossier = new KycDossier();
dossier.setDateVerification(dt);
assertThat(dossier.getDateVerification()).isEqualTo(dt);
}
@Test
@DisplayName("setValidateurId / getValidateurId round-trips")
void validateurId_roundTrips() {
UUID uuid = UUID.randomUUID();
KycDossier dossier = new KycDossier();
dossier.setValidateurId(uuid);
assertThat(dossier.getValidateurId()).isEqualTo(uuid);
}
@Test
@DisplayName("setNotesValidateur / getNotesValidateur round-trips")
void notesValidateur_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setNotesValidateur("Notes de validation");
assertThat(dossier.getNotesValidateur()).isEqualTo("Notes de validation");
}
@Test
@DisplayName("setAnneeReference / getAnneeReference round-trips")
void anneeReference_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setAnneeReference(2025);
assertThat(dossier.getAnneeReference()).isEqualTo(2025);
}
@Test
@DisplayName("file IDs round-trip")
void fileIds_roundTrip() {
KycDossier dossier = new KycDossier();
dossier.setPieceIdentiteRectoFileId("recto-42");
dossier.setPieceIdentiteVersoFileId("verso-42");
dossier.setJustifDomicileFileId("domicile-42");
assertThat(dossier.getPieceIdentiteRectoFileId()).isEqualTo("recto-42");
assertThat(dossier.getPieceIdentiteVersoFileId()).isEqualTo("verso-42");
assertThat(dossier.getJustifDomicileFileId()).isEqualTo("domicile-42");
}
}
// -------------------------------------------------------------------------
// Business method: isPieceExpiree()
// -------------------------------------------------------------------------
@Nested
@DisplayName("isPieceExpiree()")
class IsPieceExpireeTests {
@Test
@DisplayName("returns true when dateExpirationPiece is in the past")
void isPieceExpiree_returnsTrue_whenExpired() {
KycDossier dossier = KycDossier.builder()
.dateExpirationPiece(LocalDate.now().minusDays(1))
.build();
assertThat(dossier.isPieceExpiree()).isTrue();
}
@Test
@DisplayName("returns false when dateExpirationPiece is in the future")
void isPieceExpiree_returnsFalse_whenNotExpired() {
KycDossier dossier = KycDossier.builder()
.dateExpirationPiece(LocalDate.now().plusYears(1))
.build();
assertThat(dossier.isPieceExpiree()).isFalse();
}
@Test
@DisplayName("returns false when dateExpirationPiece is null")
void isPieceExpiree_returnsFalse_whenNull() {
KycDossier dossier = KycDossier.builder().build();
assertThat(dossier.isPieceExpiree()).isFalse();
}
@Test
@DisplayName("returns false when dateExpirationPiece is today")
void isPieceExpiree_returnsFalse_whenToday() {
KycDossier dossier = KycDossier.builder()
.dateExpirationPiece(LocalDate.now())
.build();
// isBefore(now) is false for today
assertThat(dossier.isPieceExpiree()).isFalse();
}
}
// -------------------------------------------------------------------------
// Enum coverage: StatutKyc
// -------------------------------------------------------------------------
@Test
@DisplayName("all StatutKyc values are assignable")
void statutKyc_allValues() {
KycDossier dossier = new KycDossier();
for (StatutKyc statut : StatutKyc.values()) {
dossier.setStatut(statut);
assertThat(dossier.getStatut()).isEqualTo(statut);
}
assertThat(StatutKyc.NON_VERIFIE.getLibelle()).isEqualTo("Non vérifié");
assertThat(StatutKyc.EN_COURS.getLibelle()).isEqualTo("En cours");
assertThat(StatutKyc.VERIFIE.getLibelle()).isEqualTo("Vérifié");
assertThat(StatutKyc.REFUSE.getLibelle()).isEqualTo("Refusé");
}
// -------------------------------------------------------------------------
// Enum coverage: NiveauRisqueKyc
// -------------------------------------------------------------------------
@Test
@DisplayName("all NiveauRisqueKyc values are assignable")
void niveauRisqueKyc_allValues() {
KycDossier dossier = new KycDossier();
for (NiveauRisqueKyc niveau : NiveauRisqueKyc.values()) {
dossier.setNiveauRisque(niveau);
assertThat(dossier.getNiveauRisque()).isEqualTo(niveau);
}
assertThat(NiveauRisqueKyc.FAIBLE.getLibelle()).isEqualTo("Risque faible");
assertThat(NiveauRisqueKyc.MOYEN.getLibelle()).isEqualTo("Risque moyen");
assertThat(NiveauRisqueKyc.ELEVE.getLibelle()).isEqualTo("Risque élevé");
assertThat(NiveauRisqueKyc.CRITIQUE.getLibelle()).isEqualTo("Risque critique");
assertThat(NiveauRisqueKyc.FAIBLE.getScoreMin()).isZero();
assertThat(NiveauRisqueKyc.FAIBLE.getScoreMax()).isEqualTo(39);
assertThat(NiveauRisqueKyc.MOYEN.getScoreMin()).isEqualTo(40);
assertThat(NiveauRisqueKyc.MOYEN.getScoreMax()).isEqualTo(69);
assertThat(NiveauRisqueKyc.ELEVE.getScoreMin()).isEqualTo(70);
assertThat(NiveauRisqueKyc.ELEVE.getScoreMax()).isEqualTo(89);
assertThat(NiveauRisqueKyc.CRITIQUE.getScoreMin()).isEqualTo(90);
assertThat(NiveauRisqueKyc.CRITIQUE.getScoreMax()).isEqualTo(100);
}
// -------------------------------------------------------------------------
// Enum coverage: TypePieceIdentite
// -------------------------------------------------------------------------
@Test
@DisplayName("all TypePieceIdentite values are assignable")
void typePieceIdentite_allValues() {
KycDossier dossier = new KycDossier();
for (TypePieceIdentite type : TypePieceIdentite.values()) {
dossier.setTypePiece(type);
assertThat(dossier.getTypePiece()).isEqualTo(type);
}
assertThat(TypePieceIdentite.CNI.getLibelle()).isEqualTo("Carte Nationale d'Identité");
assertThat(TypePieceIdentite.PASSEPORT.getLibelle()).isEqualTo("Passeport");
assertThat(TypePieceIdentite.TITRE_SEJOUR.getLibelle()).isEqualTo("Titre de séjour");
assertThat(TypePieceIdentite.CARTE_CONSULAIRE.getLibelle()).isEqualTo("Carte consulaire");
assertThat(TypePieceIdentite.PERMIS_CONDUIRE.getLibelle()).isEqualTo("Permis de conduire");
assertThat(TypePieceIdentite.AUTRE.getLibelle()).isEqualTo("Autre pièce officielle");
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
KycDossier dossier = new KycDossier();
dossier.setId(id);
assertThat(dossier.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// equals / hashCode (Lombok @EqualsAndHashCode(callSuper=true))
// -------------------------------------------------------------------------
@Test
@DisplayName("two instances with same id are equal")
void equals_sameId_areEqual() {
UUID id = UUID.randomUUID();
KycDossier a = new KycDossier();
a.setId(id);
a.setNumeroPiece("P1");
KycDossier b = new KycDossier();
b.setId(id);
b.setNumeroPiece("P1");
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("two instances with different ids are not equal")
void equals_differentId_areNotEqual() {
KycDossier a = new KycDossier();
a.setId(UUID.randomUUID());
KycDossier b = new KycDossier();
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
}

View File

@@ -0,0 +1,170 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("MembreSuivi entity")
class MembreSuiviTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with null UUIDs")
void noArgsConstructor_createsInstanceWithNullFields() {
MembreSuivi suivi = new MembreSuivi();
assertThat(suivi).isNotNull();
assertThat(suivi.getFollowerUtilisateurId()).isNull();
assertThat(suivi.getSuiviUtilisateurId()).isNull();
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor sets all fields")
void allArgsConstructor_setsAllFields() {
UUID followerId = UUID.randomUUID();
UUID suiviId = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi(followerId, suiviId);
assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(followerId);
assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(suiviId);
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder sets followerUtilisateurId")
void builder_setsFollowerUtilisateurId() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = MembreSuivi.builder()
.followerUtilisateurId(id)
.build();
assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(id);
}
@Test
@DisplayName("builder sets suiviUtilisateurId")
void builder_setsSuiviUtilisateurId() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = MembreSuivi.builder()
.suiviUtilisateurId(id)
.build();
assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(id);
}
@Test
@DisplayName("builder sets both UUID fields")
void builder_setsBothUuidFields() {
UUID followerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID suiviId = UUID.fromString("22222222-2222-2222-2222-222222222222");
MembreSuivi suivi = MembreSuivi.builder()
.followerUtilisateurId(followerId)
.suiviUtilisateurId(suiviId)
.build();
assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(followerId);
assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(suiviId);
}
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setFollowerUtilisateurId / getFollowerUtilisateurId round-trips")
void followerUtilisateurId_roundTrips() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi();
suivi.setFollowerUtilisateurId(id);
assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(id);
}
@Test
@DisplayName("setSuiviUtilisateurId / getSuiviUtilisateurId round-trips")
void suiviUtilisateurId_roundTrips() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi();
suivi.setSuiviUtilisateurId(id);
assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(id);
}
@Test
@DisplayName("follower and suivi IDs can be different")
void followerAndSuivi_canBeDifferent() {
UUID followerId = UUID.randomUUID();
UUID suiviId = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi();
suivi.setFollowerUtilisateurId(followerId);
suivi.setSuiviUtilisateurId(suiviId);
assertThat(suivi.getFollowerUtilisateurId()).isNotEqualTo(suivi.getSuiviUtilisateurId());
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi();
suivi.setId(id);
assertThat(suivi.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// equals / hashCode
// -------------------------------------------------------------------------
@Test
@DisplayName("two instances with the same field values are equal")
void equals_sameValues_areEqual() {
UUID followerId = UUID.randomUUID();
UUID suiviId = UUID.randomUUID();
MembreSuivi a = new MembreSuivi(followerId, suiviId);
MembreSuivi b = new MembreSuivi(followerId, suiviId);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("two instances with different follower IDs are not equal")
void equals_differentFollowerId_notEqual() {
UUID suiviId = UUID.randomUUID();
MembreSuivi a = new MembreSuivi(UUID.randomUUID(), suiviId);
MembreSuivi b = new MembreSuivi(UUID.randomUUID(), suiviId);
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString contains field values")
void toString_containsFields() {
UUID followerId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
MembreSuivi suivi = MembreSuivi.builder().followerUtilisateurId(followerId).build();
assertThat(suivi.toString()).contains("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
}
}

View File

@@ -0,0 +1,214 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("PaiementObjet entity")
class PaiementObjetTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with null fields")
void noArgsConstructor_createsInstanceWithNullFields() {
PaiementObjet po = new PaiementObjet();
assertThat(po).isNotNull();
assertThat(po.getPaiement()).isNull();
assertThat(po.getTypeObjetCible()).isNull();
assertThat(po.getObjetCibleId()).isNull();
assertThat(po.getMontantApplique()).isNull();
assertThat(po.getDateApplication()).isNull();
assertThat(po.getCommentaire()).isNull();
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder sets all fields")
void builder_setsAllFields() {
Paiement paiement = new Paiement();
UUID objetId = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
PaiementObjet po = PaiementObjet.builder()
.paiement(paiement)
.typeObjetCible("COTISATION")
.objetCibleId(objetId)
.montantApplique(BigDecimal.valueOf(15000))
.dateApplication(now)
.commentaire("Application cotisation mensuelle")
.build();
assertThat(po.getPaiement()).isSameAs(paiement);
assertThat(po.getTypeObjetCible()).isEqualTo("COTISATION");
assertThat(po.getObjetCibleId()).isEqualTo(objetId);
assertThat(po.getMontantApplique()).isEqualByComparingTo("15000");
assertThat(po.getDateApplication()).isEqualTo(now);
assertThat(po.getCommentaire()).isEqualTo("Application cotisation mensuelle");
}
@Test
@DisplayName("builder produces distinct instances")
void builder_producesDistinctInstances() {
PaiementObjet po1 = PaiementObjet.builder().typeObjetCible("ADHESION").build();
PaiementObjet po2 = PaiementObjet.builder().typeObjetCible("EVENEMENT").build();
assertThat(po1).isNotSameAs(po2);
assertThat(po1.getTypeObjetCible()).isNotEqualTo(po2.getTypeObjetCible());
}
}
// -------------------------------------------------------------------------
// All-args constructor via setters
// -------------------------------------------------------------------------
@Test
@DisplayName("all fields set via setters are accessible")
void allFields_setViaSetters() {
Paiement paiement = new Paiement();
UUID objetId = UUID.randomUUID();
LocalDateTime dt = LocalDateTime.of(2026, 3, 15, 9, 0);
PaiementObjet po = new PaiementObjet();
po.setPaiement(paiement);
po.setTypeObjetCible("AIDE");
po.setObjetCibleId(objetId);
po.setMontantApplique(BigDecimal.valueOf(5000, 2));
po.setDateApplication(dt);
po.setCommentaire("Aide urgence");
assertThat(po.getPaiement()).isSameAs(paiement);
assertThat(po.getTypeObjetCible()).isEqualTo("AIDE");
assertThat(po.getObjetCibleId()).isEqualTo(objetId);
assertThat(po.getMontantApplique()).isEqualByComparingTo(BigDecimal.valueOf(5000, 2));
assertThat(po.getDateApplication()).isEqualTo(dt);
assertThat(po.getCommentaire()).isEqualTo("Aide urgence");
}
// -------------------------------------------------------------------------
// Getters / Setters individual
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setPaiement / getPaiement round-trips")
void paiement_roundTrips() {
Paiement paiement = new Paiement();
PaiementObjet po = new PaiementObjet();
po.setPaiement(paiement);
assertThat(po.getPaiement()).isSameAs(paiement);
}
@Test
@DisplayName("setTypeObjetCible / getTypeObjetCible round-trips")
void typeObjetCible_roundTrips() {
PaiementObjet po = new PaiementObjet();
po.setTypeObjetCible("EVENEMENT");
assertThat(po.getTypeObjetCible()).isEqualTo("EVENEMENT");
}
@Test
@DisplayName("setObjetCibleId / getObjetCibleId round-trips")
void objetCibleId_roundTrips() {
UUID id = UUID.randomUUID();
PaiementObjet po = new PaiementObjet();
po.setObjetCibleId(id);
assertThat(po.getObjetCibleId()).isEqualTo(id);
}
@Test
@DisplayName("setMontantApplique / getMontantApplique round-trips")
void montantApplique_roundTrips() {
BigDecimal montant = new BigDecimal("12500.00");
PaiementObjet po = new PaiementObjet();
po.setMontantApplique(montant);
assertThat(po.getMontantApplique()).isEqualByComparingTo(montant);
}
@Test
@DisplayName("setDateApplication / getDateApplication round-trips")
void dateApplication_roundTrips() {
LocalDateTime dt = LocalDateTime.of(2026, 1, 1, 0, 0);
PaiementObjet po = new PaiementObjet();
po.setDateApplication(dt);
assertThat(po.getDateApplication()).isEqualTo(dt);
}
@Test
@DisplayName("setCommentaire / getCommentaire round-trips")
void commentaire_roundTrips() {
PaiementObjet po = new PaiementObjet();
po.setCommentaire("Détails supplémentaires");
assertThat(po.getCommentaire()).isEqualTo("Détails supplémentaires");
}
}
// -------------------------------------------------------------------------
// Various typeObjetCible values (polymorphic usage)
// -------------------------------------------------------------------------
@Test
@DisplayName("typeObjetCible accepts all expected polymorphic types")
void typeObjetCible_acceptsPolymorphicTypes() {
String[] types = {"COTISATION", "ADHESION", "EVENEMENT", "AIDE"};
for (String type : types) {
PaiementObjet po = PaiementObjet.builder().typeObjetCible(type).build();
assertThat(po.getTypeObjetCible()).isEqualTo(type);
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
PaiementObjet po = new PaiementObjet();
po.setId(id);
assertThat(po.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// equals / hashCode
// -------------------------------------------------------------------------
@Test
@DisplayName("two instances with the same UUID id are equal")
void equals_sameId_areEqual() {
UUID id = UUID.randomUUID();
PaiementObjet a = new PaiementObjet();
a.setId(id);
a.setTypeObjetCible("COTISATION");
PaiementObjet b = new PaiementObjet();
b.setId(id);
b.setTypeObjetCible("COTISATION");
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("two instances with different ids are not equal")
void equals_differentIds_notEqual() {
PaiementObjet a = new PaiementObjet();
a.setId(UUID.randomUUID());
PaiementObjet b = new PaiementObjet();
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
}

Some files were not shown because too many files have changed in this diff Show More