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:
@@ -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*
|
|
||||||
@@ -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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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 l’injection 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 l’injection 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 ci‑dessous 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 d’intégration (filtre + requête REST) |
|
|
||||||
| `dev.lions.unionflow.server.mapper` (racine + sous-packages) | 35–95 % | 21–64 % | 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 d’inté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 s’appuyant 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 l’ordre `mapJsonException`.*
|
|
||||||
@@ -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
|
|
||||||
@@ -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%)
|
|
||||||
@@ -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
35
pom.xml
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
112
src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
Normal file
112
src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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 $$;
|
||||||
@@ -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 $$;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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.';
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
42
src/main/resources/templates/email/bienvenue.html
Normal file
42
src/main/resources/templates/email/bienvenue.html
Normal 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>
|
||||||
@@ -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>
|
||||||
45
src/main/resources/templates/email/rappelCotisation.html
Normal file
45
src/main/resources/templates/email/rappelCotisation.html
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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 ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user