Compare commits

..

27 Commits

Author SHA1 Message Date
dahoud
07302f2743 fix(sprint-17 backend): désactivation globale DevServices en test (H2 + OIDC off → aucun container nécessaire)
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
Suite à observation que le précédent fix ciblé `quarkus.keycloak.devservices.enabled=false`
n'empêchait pas le démarrage du container KC (probablement timing build-time vs runtime).

Mode test :
- DataSource : H2 in-memory (lignes 5-8 application-test.properties — pas de Postgres needed)
- OIDC : tenant-enabled=false (pas de KC needed)

Conclusion : aucun DevService nécessaire en test → désactivation globale via
`quarkus.devservices.enabled=false` (couvre tous les services) + garde
`quarkus.keycloak.devservices.enabled=false` en sécurité.

Bénéfice : tests démarrent en 5-10s au lieu de 1-3min (boot containers KC + ryuk).
Mode `quarkus:dev` reste full DevServices (logique, on a besoin de KC en dev).
2026-04-25 17:47:59 +00:00
dahoud
af8d237d01 fix(sprint-17 backend): désactiver Keycloak DevServices en test (OIDC déjà off → KC inutile)
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m9s
Erreur observée :
  ERROR [io.qu.de.ke.KeycloakDevServicesProcessor] Admin token can not be acquired
  due to a client connection timeout
  (~50s boot Docker + timeouts admin token)

Cause : @QuarkusTest charge application-test.properties qui désactive OIDC
(tenant-enabled=false), mais l'extension quarkus-keycloak-* déclenche quand même
le DevService Keycloak qui télécharge/démarre un container KC 26.3.4 et tente
d'obtenir un admin token — qui timeout.

Fix non-appauvrissant : désactiver UNIQUEMENT le DevService KC en mode test.
- Dev mode : DevServices KC reste actif (utile pour quarkus:dev)
- Test mode : OIDC déjà désactivé → KC DevServices = pure perte de temps de boot
- Postgres DevServices reste actif (Hibernate a besoin d'une DB pour les tests JPA)

Ajout :
  quarkus.keycloak.devservices.enabled=false  (test-only via application-test.properties)
2026-04-25 17:34:05 +00:00
dahoud
d52b0f6f2b fix(sprint-17 backend): JwtPropagationFilter @ApplicationScoped — découverte CDI pour @QuarkusTest
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
JwtPropagationFilterTest @QuarkusTest fait @Inject JwtPropagationFilter mais la
classe n'avait aucun scope CDI → UnsatisfiedResolutionException au démarrage du test.

Le commentaire interdisait @Provider (auto-register JAX-RS global qui écraserait le
service account des AdminUserServiceClient/AdminRoleServiceClient). Mais ne disait
rien contre @ApplicationScoped : c'est un scope CDI ≠ provider JAX-RS.

Fix : ajouter @ApplicationScoped (rend découvrable par CDI mais ne provoque PAS
d'enregistrement automatique JAX-RS — l'opt-in reste via @RegisterProvider explicite).
Suppression de l'import jakarta.ws.rs.ext.Provider devenu inutile.

Commentaire de classe enrichi pour clarifier la nuance scope-CDI vs provider-JAX-RS.
2026-04-25 17:21:42 +00:00
dahoud
2826d75aa6 fix(sprint-17 backend): 3 warnings build/dev — Qute -parameters, compiler version, policy-enforcer deprecation
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m52s
3 fixes pour build dev mode propre :

1. Qute @CheckedTemplate (EmailTemplateService.Templates) cassait au démarrage
   → maven-compiler-plugin <parameters>true</parameters> (noms params en bytecode)

2. Maven warning "build.plugins.plugin.version missing"
   → ajout <version>3.13.0</version> au compiler-plugin (cohérent avec api module)

3. Quarkus runtime warning "quarkus.keycloak.policy-enforcer.enable is deprecated"
   → renommé en policy-enforcer.enabled dans application.properties + application-test.properties
   (depuis Quarkus 3.x, propriété renommée pour cohérence avec autres flags enabled)
2026-04-25 17:14:51 +00:00
dahoud
0e264b3c1f fix(sprint-17 backend): KpiShareTokenService.DEFAULT_TTL_SECONDS public (referenced from KpiShareLinkResource in different package)
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m44s
2026-04-25 17:01:54 +00:00
dahoud
ed0d74e124 feat(sprint-17 backend 2026-04-25): Public KPI Sharing — token signé HMAC-SHA256 + endpoints admin/public
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m3s
Transparency réglementaire (Sprint 16.D différé livré en S17). Permet aux autorités
externes (BCEAO, ARTCI, CENTIF, contrôleurs UEMOA) de consulter les KPI agrégés
d'une organisation sans login, via lien signé temporairement.

Architecture sécurité
- KpiShareTokenService : HMAC-SHA256 + base64url, format orgId.expiryMillis.signature
- Vérification time-constant via MessageDigest.isEqual (résistant timing attacks)
- Secret @ConfigProperty unionflow.kpi.share.secret (défaut TTL 7 jours)
- Pas de DB — token autosuffisant, révocable seulement par rotation du secret

KpiPublicService
- snapshotPublic(orgId) : extrait sous-ensemble safe de ComplianceSnapshot
- Audit chaque accès public (méta-traçabilité — qui/quand consulté quoi)

Endpoints
- GET /api/admin/kpi/share-link/{orgId}?ttlSeconds=...
  @RolesAllowed ADMIN_ORGANISATION, PRESIDENT, COMPLIANCE_OFFICER, SUPER_ADMIN
  Retourne {token, ttlSeconds, publicUrl, publicWebPath}
- GET /api/public/kpi?token=...
  @PermitAll (whitelist /api/public/* dans application.properties)
  Retourne KpiPublicSnapshot ou 401/404 si token invalide/expiré ou org introuvable

Bump dépendance api 1.0.9 → 1.0.10 (DTO KpiPublicSnapshot ajouté)

Tests KpiShareTokenService (9 tests)
- Round-trip orgId, ttl 0/<=0 défaut, orgId null exception
- Token expiré (signature fake), tampering signature, tampering orgId
- Malformé (< 3 parts, > 4 parts)
- Null/blank
- Encode/decode round-trip avec caractères spéciaux

ACTION USER : mvn install api 1.0.10 puis tester impl/web.
2026-04-25 16:48:47 +00:00
dahoud
0c46d9bad6 feat(sprint-16 backend 2026-04-25): transparency operations — Métriques Prometheus + Export multi-format + Notifications auto
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m35s
Trois piliers transparency opérationnelle UnionFlow livrés en série :

S16.A — Métriques Prometheus métier (transparency observabilité)
- AuditTrailService.log() incrémente :
  - unionflow.audit.operations{action, entity}
  - unionflow.audit.sod_violations{entity}
- application.properties : quarkus.micrometer.export.prometheus.enabled=true,
  http-server + jvm binders activés
- Endpoint /q/metrics exposé pour scraping Prometheus K8s

S16.B — Export massif audit-trail (transparency réglementaire BCEAO/ARTCI/CENTIF)
- AuditTrailExportService :
  - exportCsv : Apache Commons CSV avec BOM UTF-8 (compat Excel)
  - exportXlsx : POI XSSFWorkbook avec header coloré et auto-size
  - exportPdf : OpenPDF A4 paysage avec table 7 colonnes
- Endpoint GET /api/audit-trail/export?format=csv|xlsx|pdf&scope=...&limit=500
- @RolesAllowed COMPLIANCE_OFFICER, CONTROLEUR_INTERNE, SUPER_ADMIN
- Auto-log de l'export lui-même dans audit (méta-traçabilité)

S16.C — Notifications transparency (pattern CDI Event Observer)
- AuditOperationLoggedEvent record + helper estSensible() (DELETE / PAYMENT_* / BUDGET_APPROVED / AID_REQUEST_APPROVED / EXPORT / VALIDATE / SoD-violation)
- AuditTrailService.log() fire le CDI event après persist
- AuditNotificationService observe @Observes(AFTER_SUCCESS) — notifie via NotificationService existant (DRY, pas de nouveau client mail)
- Sujets/corps localisés français + priorité automatique (HAUTE pour DELETE/PAYMENT_FAILED/SoD, NORMALE pour confirmations, BASSE pour exports)
- Fail-soft : exception notification ne bloque jamais l'audit principal

Tests (10 nouveaux S16.C, 21/21 cumulé audit)
- onAuditOperation × 5 (sensible/non-sensible/SoD/null/userId-null)
- mapping helpers × 4 (sujet × 8 actions, sodPriority, priorité × 6 cas, corps × 2)
2026-04-25 16:28:51 +00:00
dahoud
e4d7c8e4b7 feat(sprint-15 backend 2026-04-25): Live Activity Feed — endpoint /api/audit-trail/recent + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m50s
Transparency opérationnelle UnionFlow — opérations sensibles consultables en temps réel selon scope.

Repository
- findRecent(limit) : N opérations les plus récentes, toutes orgs
- findRecentByOrganisation(orgId, limit) : pour scope ORG
- findRecentByUser(userId, limit) : pour scope SELF
- Tous clamps limit à [1, 500] (sécurité DoS)

Service AuditTrailQueryService
- listerRecentes(scope, orgId, userId, limit) : dispatcher SELF/ORG/ALL avec fallback liste vide si UUID requis manquant ; insensible à la casse

Resource REST AuditTrailOperationResource
- GET /api/audit-trail/recent?scope=SELF&orgId=...&userId=...&limit=50
- Default scope=SELF, limit=50
- @RolesAllowed étendu (MEMBRE inclus pour scope SELF) — un membre peut voir SES propres opérations

Tests (7 nouveaux, 11/11 cumulé service)
- recentes_All / Null defaults to ALL / Org / OrgWithoutId / Self / SelfWithoutUser / CaseInsensitive
2026-04-25 16:08:33 +00:00
dahoud
7c3352ed48 feat(sprint-13.A backend 2026-04-25): OrganisationService.convertToResponse propage referentielComptable + complianceOfficerId
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m21s
Suite à enrichissement OrganisationResponse api 1.0.9. Mapping Entity → Response
maintenant complet pour le front-end (Sprint 10 avait déjà le mapping
Request → Entity, manquait Entity → Response).

Bump dépendance api 1.0.8 → 1.0.9. Quarkus inchangé (3.27.3).

ACTION USER : `mvn install` côté unionflow-server-impl-quarkus pour valider.
2026-04-25 15:22:50 +00:00
dahoud
f267eeebfc fix(sprint-10 backend): @Builder n'expose pas actif() (parent BaseEntity), setter explicite
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m27s
BaseEntity contient actif (Boolean), mais BeneficiaireEffectif et RoleDelegation utilisent
@Builder simple (pas @SuperBuilder) → builder ne propage pas les champs parent.

Solution : .build() puis setActif(true). Pas de @SuperBuilder ici (changement de signature
diffuse, hors-scope Sprint 10).

Tests Sprint 10 backend désormais 12/12 verts (BeneficiaireEffectifService 8 + AuditTrailQueryService 4).
2026-04-25 12:46:22 +00:00
dahoud
241533efa6 feat(sprint-10 backend 2026-04-25): Resources REST UBO + audit trail + délégation + enrich update org
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m30s
Expose les features Sprints 1-2 via API REST. Architecture stricte respectée :
Resource ↔ Service ↔ Repository ↔ Entity ↔ DTO. Mapping centralisé en Service.

Bump dépendance api 1.0.7 → 1.0.8 (DTOs nouveaux)

UBO (Instr. BCEAO 003-03-2025)
- BeneficiaireEffectifService : creer (validation cible obligatoire), mettreAJour (PATCH), desactiver (soft-delete), validation seuilPourcentage (somme cumul ≤ 100, exclusion en mode update), audit trail systématique
- BeneficiaireEffectifResource : CRUD /api/kyc/beneficiaires-effectifs, filtres queryParam (kycDossierId | organisationCibleId | pep), @RolesAllowed COMPLIANCE_OFFICER + ADMIN_ORG + SUPER_ADMIN

Audit trail (CQRS read-side)
- AuditTrailQueryService NEW : 5 méthodes lecture seule (parUtilisateur, historiqueEntite, parOrganisation, violationsSod, operationsFinancieres) + mapping Entity→DTO
- AuditTrailOperationResource : /api/audit-trail/{by-user|by-entity|by-organisation|sod-violations|financial}, parsing dates ISO + fallbacks
- Distinct du AuditTrailService (write-side dans security/) : Single Responsibility appliqué

Délégation rôles (Sprint 2 service réutilisé)
- RoleDelegationService enrichi : creerDepuisRequest (DTO→entité→creer existant), revoquerEtRetourner, listerParOrganisation, toResponse (mapping centralisé)
- RoleDelegationRepository : findByOrganisation
- RoleDelegationResource : POST/DELETE/GET /api/role-delegations

Enrichissement Organisation update (DRY)
- OrganisationService.convertFromUpdateRequest : parseReferentielComptable (fallback ReferentielComptable.defaultFor) + complianceOfficerId
- OrganisationService.mettreAJourOrganisation : propagation conditionnelle des nouveaux champs

Tests (en attente publication api 1.0.8 — install local nécessaire)
- BeneficiaireEffectifServiceTest : 8 tests verifierSeuilPourcentage (OK / limite / dépassement / excludeId / null / inactifs ignorés) + creer + toResponse mapping
- AuditTrailQueryServiceTest : 4 tests parUtilisateur, sodViolations, toResponse mapping, historiqueEntite

ACTION USER : `mvn install` côté unionflow-server-api pour rendre 1.0.8 dispo en m2 local + publier via script/publish-api.sh pour Gitea.
2026-04-25 12:32:41 +00:00
dahoud
4d400dc48d feat(sprint-7 P1-NEW-15 2026-04-25): PI-SPI Readiness check (8 vérifications) + endpoint admin
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Service ops/compliance pour valider l'état d'intégration PI-SPI BCEAO avant activation production.

PispiReadinessService — 8 vérifications structurées
- OAUTH2_CREDENTIALS (BLOCKING) : client_id + client_secret
- API_KEY (BLOCKING) : header X-API-Key obligatoire BCEAO
- MTLS_KEYSTORE (BLOCKING) : path + password + fichier .p12 existant
- MTLS_TRUSTSTORE (WARNING) : configuré + fichier existant (fallback cacerts si absent)
- WEBHOOK_SECRET (BLOCKING) : HMAC-SHA256 webhooks
- BASE_URL (WARNING) : sandbox vs production détecté automatiquement
- TAUX_EUR_XOF (WARNING) : taux récent ≤ 7 jours (sinon obsolète)
- PROVIDER_CONFIGURED (BLOCKING) : PispiAuth.isConfigured() = true (synthèse)

Statut global agrégé
- READY — tous PASS
- DEGRADED — uniquement WARNING en échec (sandbox OK, prod à risque)
- BLOCKED — au moins un BLOCKING en échec (sandbox impossible)

Endpoint /api/admin/pispi/readiness
- @RolesAllowed SUPER_ADMIN, COMPLIANCE_OFFICER
- HTTP 200 si READY/DEGRADED, 503 si BLOCKED
- Réponse JSON : globalStatus, baseUrl, checks détaillés, blockingIssues, warnings

Helpers publics ajoutés (pour readiness sans casser l'encapsulation)
- PispiAuth : hasClientId(), hasClientSecret(), hasApiKey(), keystorePath(), keystorePassword(), truststorePath(), truststorePassword()
- PispiSignatureVerifier : hasWebhookSecret()

Tests (15/15 verts)
- BLOCKED tout vide, READY tout configuré, DEGRADED warnings only
- Checks individuels OAuth2/ApiKey/Keystore (path absent / fichier inexistant / OK), Truststore warning, Webhook, BaseUrl sandbox/prod, Taux récent/obsolète, Provider
2026-04-25 10:48:59 +00:00
dahoud
86842f27af feat(sprint-6 P2-NEW-7 2026-04-25): multi-devise + KYC non-résident diaspora + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m11s
Ouvre UnionFlow à la diaspora UEMOA (France, USA, Canada, UK, Suisse...).

Entités & migration
- Enum Devise (10 valeurs : XOF, XAF, EUR, USD, GBP, CAD, CHF, GHS, NGN, MAD)
  - Zones : UEMOA, CEMAC, CEDEAO, EUROPE, AMERIQUE, MAGHREB
  - DEVISES_INTERNATIONALES : EUR/USD/GBP/CAD/CHF (déclenchent AML international)
- Entité TauxChange (devise_source × devise_cible × date_validite, taux NUMERIC(18,8))
- Repository : trouverExact, trouverPlusRecent (≤ date)
- V49 :
  - Table taux_change (contrainte unicité paire+date, devises distinctes)
  - Seed BCEAO_FIXED EUR↔XOF + taux indicatifs USD/GBP/CAD au 2026-04-25
  - Membres : pays_residence (ISO-3), numero_passeport, numero_fiscal_etranger, est_diaspora, devise_preferee
  - KycDossiers : pays_origine_fonds, justificatif_residence_etrangere, niveau_due_diligence (SIMPLIFIE/STANDARD/RENFORCE)

DeviseConversionService
- Stratégie de résolution : direct → inverse → pivot via XOF → fallback récent ≤ date
- Cache thread-safe (ConcurrentHashMap, TTL 1h)
- TauxIntrouvableException si aucun taux résolvable
- invaliderCache() pour reload après import batch

KycDiasporaService
- validerCoherence : passeport obligatoire si diaspora, pays_residence ≠ UEMOA, format passeport regex
- determinerNiveauDueDiligence (Instr. BCEAO 001-03-2025) :
  - PEP → RENFORCE
  - Diaspora pays sécurisés (UE/G7/Asie) → STANDARD
  - Diaspora FATF grey-list → RENFORCE
  - Diaspora pays inconnu → RENFORCE par prudence
- depasseSeuilAmlInternational : seuil 1000 EUR équivalent, false sur devises locales
- PAYS_UEMOA hardcodé (8 pays), PAYS_GREY_LIST_FATF snapshot 2026-04-25

Tests Sprint 6 (34/34 verts)
- DeviseTest : 5 tests (référence, internationales, zones, libellés)
- DeviseConversionServiceTest : 10 tests (identité, direct, inverse, pivot XOF, fallback récent, cache, invalider, exception, inputs invalides)
- KycDiasporaServiceTest : 19 tests (cohérence valide/sans passeport/pays UEMOA/pays étranger, due diligence PEP/FRA/grey-list/inconnu/UEMOA, seuil EUR/USD avec taux/sans taux/XOF/null)
2026-04-25 10:33:05 +00:00
dahoud
a0b2690c17 feat(sprint-5 P2-NEW-3 2026-04-25): reporting trimestriel ControleurInterne automatisé + scheduler + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m26s
Rapport trimestriel agrégé pour le Contrôleur Interne (source AG + inspections BCEAO/ARTCI).

Entités & migration
- RapportTrimestrielControleurInterne (BaseEntity + JSONB contenu + bytea PDF + hash SHA-256)
- V48 : table rapports_trimestriels_controleur_interne, contraintes, indexes
- Repository : trouverParOrgAnneeTrimestre, listerParOrgAnnee, listerNonSignes

Service RapportTrimestrielService
- genererRapport(orgId, annee, trimestre) : agrège ComplianceSnapshot + DOS CENTIF + formations LBC/FT + anomalies SoD audit trail + demandes aide
- construireJson : structure conformite/activite/alertes
- genererPdf : OpenPDF A4 avec sections 1.Conformité 2.Activité 3.Alertes + bloc signature
- signer(rapportId, signataireId) : calcule SHA-256 du JSON, fige le statut
- archiver(rapportId) : passe SIGNE → ARCHIVE
- Idempotent en DRAFT (régénération possible) ; immuable en SIGNE/ARCHIVE

Resource RapportTrimestrielResource
- GET /api/rapports/trimestriel?orgId&annee — lister
- POST /api/rapports/trimestriel/generer — CONTROLEUR_INTERNE / SUPER_ADMIN
- POST /api/rapports/trimestriel/{id}/signer — CONTROLEUR_INTERNE
- POST /api/rapports/trimestriel/{id}/archiver — CONTROLEUR_INTERNE / PRESIDENT
- GET /api/rapports/trimestriel/{id}/pdf — application/pdf

Scheduler RapportTrimestrielScheduler
- @Scheduled cron 0 17 2 1 1,4,7,10 ? — 1er jan/avr/jul/oct à 02:17
- Génère pour toutes les orgs actives le trimestre précédent
- Override possible via unionflow.reporting.trimestriel.cron
- ConcurrentExecution.SKIP

RoleConstant
- Ajout CONTROLEUR_INTERNE, COMPLIANCE_OFFICER, COMMISSAIRE_COMPTES (utilisés depuis V45)

Tests Sprint 5 (20/20 verts)
- RapportTrimestrielServiceTest : 15 tests (debutTrimestre, finTrimestre, hash SHA-256, JSON alertes, PDF non vide)
- RapportTrimestrielSchedulerTest : 5 tests (trimestrePrecedent — incluant rollover année)
2026-04-25 10:13:07 +00:00
dahoud
b0ee8881fb feat(sprint-4 P1-NEW-8/9 2026-04-25): docker-compose Keycloak 26.6.1 + fix Mockito strict-stubbing test KC
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m25s
P1-NEW-8/9 — Préparation migration Keycloak 23 → 26.6.1 + Organizations GA
- docker-compose.kc26.yml : compose alternatif KC 26.6.1 avec --features=organization
- Bind-mount realm import depuis src/main/resources/keycloak/realms
- Healthcheck readiness probe + KC_HEALTH_ENABLED
- Postgres 15 dédié, volume persistant
- Réf : ARCH_KEYCLOAK_26.md
- Permet validation locale avant cutover (compose dev intact)

Fix tests KeycloakAdminHttpClientTest (4 erreurs Mockito UnnecessaryStubbing)
- Helper mockResponse : lenient() sur body() (lu uniquement sur paths erreur/JSON parsing)
- 9/9 tests passent désormais

État Sprint 4 (déjà livré dans sessions antérieures, validé ici) :
- Entité Organisation.keycloakOrgId + migration V37
- Service MigrerOrganisationsVersKeycloakService (idempotent, 9 tests)
- OrganisationContextResolver (parsing claim 'organization' JWT, 12 tests)
- AdminKeycloakOrganisationResource (endpoint admin migration)
- KeycloakAdminHttpClient (sessions, 9 tests)

Tests Sprint 4 : 36/36 passent (resolver 12 + migration 9 + admin HTTP 9 + context holder 6)

Reste pour migration finale (post-Sprint 4) :
- Validation manuelle docker compose -f docker-compose.kc26.yml up -d
- Bascule production : remplacer compose KC23 par KC26
- Refactoring backend : suppression OrganisationContextFilter + headers custom
2026-04-25 08:58:00 +00:00
dahoud
8b589477ec feat(sprint-3 P1+P2 2026-04-25): compliance dashboard + PEP screening + formations LBC/FT + goAML XML + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m15s
P1-NEW-11 — Tableau de bord conformité
- ComplianceDashboardService : score 0-100 avec 9 indicateurs pondérés (compliance officer, AG annuelle, rapport AIRMS, dirigeants CMU, taux KYC, taux formation LBC/FT, CAC, FOMUS-CI, couverture UBO)
- ComplianceDashboardResource : GET /api/compliance/dashboard

P1-NEW-11 — PEP screening externe
- PepScreeningProvider (interface) + records PepScreeningResult / PepMatch
- InMemoryPepScreeningProvider : fallback dev avec similarité Levenshtein normalisée (seuil 0.80)
- PepScreeningService : cache LRU 5000 entrées, TTL 24h
- Pluggable via @Alternative @Priority pour Youverify / ComplyAdvantage en prod

P1-NEW-12 — Module formation LBC/FT obligatoire annuelle
- FormationLbcFt + ParticipationFormationLbcFt (entités)
- FormationLbcFtService : creer, inscrire, marquerPresent, certifier (score >= 70), estCertifieAnneeCourante
- Repositories : trouverCertificationAnnee, findATenir
- V47 migration : formations_lbcft, participations_formation_lbcft, pep_screening_cache

P2-NEW-4 — goAML XML (anticipation adoption CI)
- GoAmlXmlService : génération XML standard ONUDC (report, transaction, t_person, t_account)
- Reporting person anonymisé ([REDACTED], rôle Compliance Officer)
- POST /api/aml/dos/goaml @RolesAllowed(COMPLIANCE_OFFICER, SUPER_ADMIN)

Tests Sprint 3 (15 tests, 100%) :
- FormationLbcFtTest : 2 tests (builder, override type)
- GoAmlXmlServiceTest : 4 tests (XML non vide, champs clés, anonymisation, country code CIV)
- InMemoryPepScreeningProviderTest : 9 tests (match, nationalité, vide, similarité Levenshtein)
2026-04-25 08:37:06 +00:00
c54092bd78 feat(sprint-2 P0+P1 2026-04-25): DOS CENTIF + Reporting AIRMS + PV OHADA + workflow demande aide v2 + délégation rôles + SYCEBNL donateurs + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m10s
P0-NEW-16 — DOS CENTIF (Lignes directrices CENTIF mars 2025)
  - DosCentifService.genererDosWord() : XWPFDocument structurée 6 sections
  - DosCentifService.genererReleveExcel() : XSSFWorkbook avec opérations atypiques
  - DosCentifResource @RolesAllowed COMPLIANCE_OFFICER : POST /api/aml/dos/{word,excel}
  - Confidentialité absolue (jamais persisté disque, audit trail EXPORT)

P1-NEW-1 — Reporting AIRMS triple
  - RapportAirmsService.genererRapportTriple() : OpenPDF, 3 sections + cover
  - DTOs records : RapportTechnique (effectifs, sinistralité, délais),
    RapportMoral (vie associative, formations, activités),
    RapportFinancier (P&L, ratios prudentiels, trésorerie, fonds dédiés SYCEBNL)
  - Endpoint POST /api/airms/rapports/triple

P1-NEW-2 — Module PV OHADA (AG/CA)
  - V46 : table proces_verbaux (quorum, OJ JSONB, résolutions JSONB, hash SHA-256, signatures)
  - Entité ProcesVerbal + repo ProcesVerbalRepository
  - ProcesVerbalService :
    * quorumRequisDefaut() : 50% AG ord / 66.67% AG extra/CA
    * calculerEtFixerQuorum() : (présents+représentés)/convoqués × 100
    * adopter() : valide quorum, fige hash SHA-256
    * signer() : vérif hash inchangé (immuabilité), signature électronique
    * archiver() : statut ARCHIVE pour conservation OHADA 10 ans

P1-NEW-3 — Workflow demande d'aide v2
  - V46 : extension demandes_aide (etape, animateur_zone, gps_enquete, avis_comite, decision_ca)
  - V46 : extension types_aide (plafond_annuel_membre, plafond_enveloppe_annuelle, justificatifs_requis)
  - DemandeAide enrichie : 5 étapes DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAYE → CLOTURE
  - DemandeAideV2Service :
    * Transitions typées avec checks d'état
    * SoD : approbateur CA ≠ animateur enquête
    * Audit trail enrichi à chaque transition

P1-NEW-5 — Délégation temporaire rôles
  - V46 : table role_delegations (delegant, delegataire, role, dates, statut, motif)
  - Entité RoleDelegation + isActiveAt(instant)
  - RoleDelegationService :
    * creer() : vérif SoD avant création (pas de conflit avec rôles existants délégataire)
    * revoquer() : statut REVOQUEE
    * rolesEffectifs() : directs ∪ délégués actifs
    * marquerExpirees() : scheduler quotidien

P1-NEW-13 — Registre donateurs SYCEBNL
  - V46 : tables donateurs + dons_recus (numéraire/nature/bénévolat/legs)
  - Affectation : LIBRE / FONDS_DEDIE / PROJET_SPECIFIQUE
  - Reçu fiscal (numero_recu, date_emission_recu)
  - Entités Donateur + DonRecu + repos
  - DonRecuRepository.totalEntre() pour reporting AIRMS/SYCEBNL

P1-NEW-14 — Membres honoraires/bienfaiteurs
  - V46 : ALTER membres_organisations.qualite_speciale (HONORAIRE/BIENFAITEUR/FONDATEUR)

Tests Sprint 2 (13 nouveaux, 0 failure) :
  - ProcesVerbalServiceTest (8) : quorum défaut, atteint/non-atteint, hash format/immuabilité/diff
  - RoleDelegationTest (5) : isActiveAt selon période et statut
2026-04-25 01:53:16 +00:00
7099f554fe feat(p0-2026-04-25): mobile SPKI pinning + Play Integrity/App Attest + tests Sprint 1
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m7s
P0-NEW-21 — SPKI Pinning + rotation Firebase Remote Config (mobile)
  - lib/core/security/spki_pinning_service.dart : digest SHA-256 SPKI/cert
  - Liste de pins active depuis Firebase Remote Config (key 'spki_pins')
  - Fallback statique au bundle si Firebase indisponible
  - Multi-pin (leaf + backup + intermediate) pour transitions sans downtime
  - Hosts pinnés : api.lions.dev, security.lions.dev
  - Câblé dans ApiClient._configureSslPinning() (remplace check CN obsolète)

P0-NEW-22 — Play Integrity (Android) + App Attest (iOS) (mobile)
  - lib/core/security/app_device_integrity_service.dart
  - Token attestation court (cache 60s) avec challenge backend
  - Bypass automatique en kDebugMode
  - À compléter avec un Dio interceptor X-Device-Integrity-Token avant go-live

pubspec.yaml :
  - freerasp 7.0.0 → 7.5.1
  - +app_device_integrity 1.1.0
  - +firebase_core 3.6.0 + firebase_remote_config 5.1.3

Tests Sprint 1 (40 tests, 0 failure) :
  - ReferentielComptableTest (6 cas) : defaultFor mapping
  - AmlSeuilsTest (10 cas) : seuils 10M/5M/1M, pays UEMOA, depasseSeuil
  - SoDPermissionCheckerTest (13 cas) : validation distincte, combinations interdites,
    compliance officer eligibility
  - PispiRtpRequestTest (6 cas) : validation contraintes
  - PispiRtpResponseTest (5 cas) : helpers status
2026-04-25 01:24:53 +00:00
144137656f feat(p0-2026-04-25): PI-SPI auth 3-facteurs + RTP + alias
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m32s
P0-NEW-2 — Authentification PI-SPI 3-facteurs (spec sandbox developer.pispi.bceao.int)
  - PispiAuth : OAuth2 + mTLS (PKCS12 keystore + truststore optionnel) + X-API-Key
  - SSLContext TLS 1.3 + KeyManagerFactory + TrustManagerFactory
  - HttpClient mTLS lazy + cache, HTTP/2, timeout 15s/30s
  - isConfigured() pour bascule auto mode mock si secrets absents

P0-NEW-3 — Request To Pay (RTP — pain.013/014)
  - dto/PispiRtpRequest (record validate())
  - dto/PispiRtpResponse avec helpers isAccepted()/isRefused()/...
  - PispiClient.initiateRtp() + getRtpStatus()
  - Cas d'usage : appel cotisation initié par la SFD vers le membre

P0-NEW-4 — Gestion d'alias (téléphone/email → compte SFD)
  - dto/PispiAlias (record + Types : PHONE_NUMBER/EMAIL/NATIONAL_ID/CUSTOM)
  - PispiClient.resolveAlias() + createAlias() + revokeAlias()
  - Cas d'usage : '+22507XXX@unionflow' ou 'cotisation-{slug}@unionflow'

PispiClient harmonisé : baseRequestBuilder() central avec 3 headers obligatoires, gestion 404 sur resolveAlias, timeouts 30s.
2026-04-25 01:18:46 +00:00
d8006c8425 feat(p0-2026-04-25): multi-référentiel comptable + UBO + audit trail + SoD + seuils AML
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
Sprint 1 P0 (consolidation 2026-04-25, ETAT_PROJET_METIER_2026-04-25.md) :

P0-NEW-9/10/11 — Multi-référentiel comptable
  - enum ReferentielComptable (SYSCOHADA / SYCEBNL / PCSFD_UMOA)
  - Organisation.referentielComptable + mapping defaultFor(typeOrganisation)
  - V43 : colonne + check + index + mapping initial des orgs existantes

P0-NEW-13 — Bénéficiaires effectifs (UBO) — Instruction BCEAO 003-03-2025
  - Entité BeneficiaireEffectif + repository
  - V44 : table beneficiaires_effectifs (FK kyc_dossier, UBO + PEP + sanctions)
  - Conservation 10 ans (directive 02/2015/CM/UEMOA)

P0-NEW-14 — Compliance Officer (Instruction BCEAO 001-03-2025)
  - Organisation.complianceOfficerId + V43 colonne + index

P0-NEW-15 — Seuils AML alignés (Instruction BCEAO 002-03-2025)
  - AmlSeuils : 10M FCFA intra-UEMOA / 5M FCFA entrée-sortie / 1M FCFA espèce
  - Liste pays UEMOA ISO 3166-1
  - Méthodes seuilApplicable() / depasseSeuil() / depasseSeuilEspece()

P0-NEW-17/18 — Rôles PRESIDENT + CONTROLEUR_INTERNE + suppléants
  - V45 seed : PRESIDENT, VICE_PRESIDENT, CONTROLEUR_INTERNE, ANIMATEUR_ZONE, SECRETAIRE_ADJOINT, TRESORIER_ADJOINT
  - Catégories GOUVERNANCE / CONTROLE / OPERATIONNEL

P0-NEW-19 — Audit trail enrichi (SYSCOHADA + AUDSCGIE)
  - V45 : table audit_trail_operations (acteur, action, contexte multi-org, payload JSONB, SoD)
  - Entité AuditTrailOperation + AuditTrailOperationRepository
  - AuditTrailService (log avec contexte automatique depuis OrganisationContextHolder)
  - OrganisationContextHolder enrichi (roleActif, currentUserId, currentUserEmail)

P0-NEW-20 — SoD (Separation of Duties) — SYSCOHADA + AUDSCGIE + BCEAO Circulaire 03-2017
  - SoDPermissionChecker.checkValidationDistinct() (4-eyes principle)
  - .checkRoleCombination() (combinaisons interdites : Trésorier+Président, etc.)
  - .checkComplianceOfficerEligibility() (Instruction BCEAO 001-03-2025)
  - SoDCheckResult record avec audit trail automatique

P0-NEW-24 — Champ numero_cmu sur Membre (Loi 2014-131 CI)
  - Membre.numeroCMU + V43 colonne + check format 11 caractères + index
  - Auto-déclaration (pas d'API publique CNAM disponible)

BUILD SUCCESS.
2026-04-25 01:15:25 +00:00
6e9841b3bb fix(disaster-recovery 2/2): restaurer 242 fichiers Java modifiés par a72ab54
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m22s
Suite à la récupération précédente (044ca4b) qui n'avait restauré que les
fichiers SUPPRIMÉS, ce commit restaure les MODIFICATIONS d'entités/services
qui étaient nécessaires pour que les fichiers restaurés compilent.

Restaurés depuis a72ab54^ (= 31330d9 + corrections) :
- Entities : Organisation, FormuleAbonnement, AuditService, MembreOrganisation, SouscriptionOrganisation, etc.
- Services : MigrerOrganisationsVersKeycloakService, ComptabilitePdfService, KycAmlService, AuditService.logKycRisqueEleve, etc.
- Resources : PaiementUnifieResource, etc.

Backend compile désormais (BUILD SUCCESS).
2026-04-25 01:05:08 +00:00
044ca4bd7e fix(disaster-recovery): restaurer 134 fichiers accidentellement supprimés par a72ab54
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Le commit a72ab54 (chore docker Dockerfile racine) a involontairement balayé
des fichiers du commit 31330d9 (PI-SPI, KYC, RLS, mutuelle parts, comptabilité
PDF) lors d'un git add -A trop large.

Restauration de l'intégralité des fichiers depuis a72ab54^ :
- 11 migrations Flyway V32-V42 (parts sociales, SYSCOHADA, Keycloak Org Id, KYC, RLS, Provider défaut, FCM, App DB Roles)
- Package payment/pispi/ complet (PispiAuth, PispiClient, PispiIso20022Mapper, PispiSignatureVerifier, PispiWebhookResource, dto/Pacs008Request, dto/Pacs002Response, PispiPaymentProvider)
- Package payment/{wave,orangemoney,mtnmomo}/* (PaymentProvider impls)
- Package payment/orchestration/ (PaymentOrchestrator, PaymentProviderRegistry)
- Entités KycDossier, mutuelle/parts/* (ComptePartsSociales, TransactionPartsSociales)
- Mappers, repositories, resources associés
- Services KycAmlService, ComptabilitePdfService, ReleveComptePdfService, InteretsEpargneService
- AdminKeycloakOrganisationResource, KycResource, PaiementUnifieResource
- Tests unitaires PI-SPI, KYC, mutuelle parts
2026-04-25 01:00:03 +00:00
b434282000 docs: Quarkus 3.15.1→3.27.3 LTS, Java 17→21, lionsctl -j 21, Dockerfile racine, pré-requis infra
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m9s
2026-04-24 18:05:43 +00:00
a72ab54abd chore(docker): add root Dockerfile pinning ubi8/openjdk-21:1.21 + UID 1001 for lionsctl pipeline
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m2s
2026-04-24 16:19:25 +00:00
fb3a32817b chore(quarkus-327): bump to Quarkus 3.27.3 LTS, make pom autonomous, fix 3 tests (NPE guard, equalsHashCode with shared refs), rename deprecated config keys 2026-04-23 14:45:54 +00:00
dahoud
8cec38f7b3 fix(monitoring): corriger calcul CPU — getProcessCpuLoad au lieu de loadAverage/processors
Le calcul précédent `getSystemLoadAverage() / getAvailableProcessors() * 100`
utilisait :
- getSystemLoadAverage() : charge 1min du NODE Linux hôte entier (12 CPU sur prod VPS)
- getAvailableProcessors() : limite conteneur K8s (1 CPU pour le pod UnionFlow)

Résultat : load 1.5 sur le node → cpuUsage = (1.5 / 1) * 100 = 150% capé à 100%.
Déclenchement constant d'alertes "CPU 100%" (19 faux positifs / 24h sur prod
le 20-21/04/2026) dès que le node fait autre chose que dormir.

Correctif : utilise com.sun.management.OperatingSystemMXBean.getProcessCpuLoad()
qui retourne la charge CPU du process JVM courant (0.0-1.0), conscient du
conteneur. Branche -1 préservée (API non dispo sur certaines JVM).

Tests mis à jour : AlertMonitoringServiceMockStaticCoverageTest injecte
désormais un com.sun.management.OperatingSystemMXBean et mocke
getProcessCpuLoad() (compatible mock-maker-inline déjà configuré).
AlertMonitoringServiceTest conserve sa logique OS-agnostique via des
thresholds extrêmes (-1 toujours / 100 jamais).
2026-04-21 13:38:31 +00:00
dahoud
31330d95e9 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>
2026-04-21 12:40:55 +00:00
472 changed files with 70632 additions and 37332 deletions

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# Dockerfile for unionflow-server-impl-quarkus
# Used by lionsctl pipeline. Expects `mvn clean package -Pprod` to have produced target/quarkus-app/ (fast-jar).
FROM registry.access.redhat.com/ubi8/openjdk-21:1.21
ENV LANGUAGE='en_US:en'
COPY --chown=1001:1001 target/quarkus-app/lib/ /deployments/lib/
COPY --chown=1001:1001 target/quarkus-app/*.jar /deployments/
COPY --chown=1001:1001 target/quarkus-app/app/ /deployments/app/
COPY --chown=1001:1001 target/quarkus-app/quarkus/ /deployments/quarkus/
USER 1001
EXPOSE 8080
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8080/q/health/live || exit 1
ENTRYPOINT [ "java", "-jar", "/deployments/quarkus-run.jar" ]

View File

@@ -1,7 +1,7 @@
# UnionFlow Backend - API REST Quarkus # UnionFlow Backend - API REST Quarkus
![Java](https://img.shields.io/badge/Java-17-blue) ![Java](https://img.shields.io/badge/Java-17-blue)
![Quarkus](https://img.shields.io/badge/Quarkus-3.15.1-red) ![Quarkus](https://img.shields.io/badge/Quarkus-3.27.3_LTS-red)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-blue) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-blue)
![Kafka](https://img.shields.io/badge/Kafka-Enabled-orange) ![Kafka](https://img.shields.io/badge/Kafka-Enabled-orange)
![License](https://img.shields.io/badge/License-Proprietary-red) ![License](https://img.shields.io/badge/License-Proprietary-red)
@@ -64,7 +64,7 @@ Tous les repositories étendent `PanacheRepositoryBase<Entity, UUID>` pour :
| Composant | Version | Usage | | Composant | Version | Usage |
|-----------|---------|-------| |-----------|---------|-------|
| **Java** | 17 (LTS) | Langage | | **Java** | 17 (LTS) | Langage |
| **Quarkus** | 3.15.1 | Framework application | | **Quarkus** | 3.27.3 LTS | Framework application |
| **Hibernate ORM (Panache)** | 6.4+ | Persistence | | **Hibernate ORM (Panache)** | 6.4+ | Persistence |
| **PostgreSQL** | 15 | Base de données | | **PostgreSQL** | 15 | Base de données |
| **Flyway** | 9.22+ | Migrations DB | | **Flyway** | 9.22+ | Migrations DB |
@@ -482,7 +482,7 @@ src/test/java/
lionsctl pipeline \ lionsctl pipeline \
-u https://git.lions.dev/lionsdev/unionflow-server-impl-quarkus \ -u https://git.lions.dev/lionsdev/unionflow-server-impl-quarkus \
-b main \ -b main \
-j 17 \ -j 21 \
-e production \ -e production \
-c k1 \ -c k1 \
-p prod -p prod
@@ -490,12 +490,19 @@ lionsctl pipeline \
# Étapes : # Étapes :
# 1. Clone repo Git # 1. Clone repo Git
# 2. mvn clean package -Pprod # 2. mvn clean package -Pprod
# 3. docker build + push registry.lions.dev # 3. docker build -f Dockerfile (racine, fast-jar, ubi8/openjdk-21:1.21, UID 1001)
# 4. kubectl apply -f k8s/ # 4. push registry.lions.dev
# 5. Health check # 5. kubectl apply (Deployment + Service + Ingress)
# 6. Email notification # 6. Health check
# 7. Email notification
``` ```
**Pré-requis infrastructure** avant pipeline (migration Helm → lionsctl pipeline) :
- Secret K8s `unionflow-server-impl-quarkus-db-secret` (clés `QUARKUS_DATASOURCE_USERNAME` + `QUARKUS_DATASOURCE_PASSWORD`)
- DB PostgreSQL `unionflow` (override `QUARKUS_DATASOURCE_JDBC_URL` sur le deployment car lionsctl nomme la DB comme l'app)
- Deployment Helm existant supprimé au préalable (selector immutable)
- Service selector à repatcher après pipeline (retirer les labels `app.kubernetes.io/*`)
### Fichiers Kubernetes ### Fichiers Kubernetes
**Localisation** : `src/main/kubernetes/` **Localisation** : `src/main/kubernetes/`

73
docker-compose.kc26.yml Normal file
View File

@@ -0,0 +1,73 @@
version: '3.8'
# Compose alternatif Keycloak 26.6.1 avec feature Organizations native (GA depuis 26.0).
# Usage : docker compose -f docker-compose.kc26.yml up -d
# But : valider la migration KC23 → KC26 + Organizations en local, sans toucher au compose dev.
#
# Une fois la migration validée, basculer ce contenu en production et supprimer la stack KC23.
#
# Réf : ARCH_KEYCLOAK_26.md
services:
postgres-keycloak:
image: postgres:15-alpine
container_name: kc26-postgres
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- kc26_postgres_data:/var/lib/postgresql/data
networks:
- kc26-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
keycloak:
image: quay.io/keycloak/keycloak:26.6.1
container_name: kc26-server
command:
- start-dev
- --features=organization
- --http-port=8180
- --import-realm
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
ports:
- "8180:8180"
volumes:
- ./src/main/resources/keycloak/realms:/opt/keycloak/data/import:ro
depends_on:
postgres-keycloak:
condition: service_healthy
networks:
- kc26-net
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q 'UP'"]
interval: 15s
timeout: 5s
retries: 8
start_period: 60s
restart: unless-stopped
volumes:
kc26_postgres_data:
driver: local
networks:
kc26-net:
driver: bridge

34
pom.xml
View File

@@ -4,14 +4,9 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.lions.unionflow</groupId> <groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-parent</artifactId>
<version>1.0.6</version>
<relativePath/> <!-- Force resolution from Maven repo (Gitea); enables standalone clones -->
</parent>
<artifactId>unionflow-server-impl-quarkus</artifactId> <artifactId>unionflow-server-impl-quarkus</artifactId>
<version>1.0.7</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>UnionFlow Server Implementation (Quarkus)</name> <name>UnionFlow Server Implementation (Quarkus)</name>
@@ -23,9 +18,13 @@
<maven.compiler.release>21</maven.compiler.release> <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.20.0</quarkus.platform.version> <quarkus.platform.version>3.27.3</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>
<lombok.version>1.18.38</lombok.version>
<!-- Overrides BOM : Docker Desktop 29.x compat -->
<testcontainers.version>1.21.4</testcontainers.version>
<docker-java.version>3.4.2</docker-java.version>
<!-- Jacoco --> <!-- Jacoco -->
<jacoco.version>0.8.12</jacoco.version> <jacoco.version>0.8.12</jacoco.version>
@@ -40,6 +39,20 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Lombok : pas dans Quarkus BOM -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -48,14 +61,14 @@
<dependency> <dependency>
<groupId>dev.lions.unionflow</groupId> <groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-server-api</artifactId> <artifactId>unionflow-server-api</artifactId>
<version>1.0.6</version> <version>1.0.10</version>
</dependency> </dependency>
<!-- Lions User Manager API (pour DTOs et client Keycloak) --> <!-- Lions User Manager API (pour DTOs et client Keycloak) -->
<dependency> <dependency>
<groupId>dev.lions.user.manager</groupId> <groupId>dev.lions.user.manager</groupId>
<artifactId>lions-user-manager-server-api</artifactId> <artifactId>lions-user-manager-server-api</artifactId>
<version>1.0.0</version> <version>1.1.0</version>
</dependency> </dependency>
<!-- Quarkus Core --> <!-- Quarkus Core -->
@@ -321,7 +334,10 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration> <configuration>
<!-- Quarkus Qute @CheckedTemplate exige les noms de paramètres en bytecode -->
<parameters>true</parameters>
<annotationProcessorPaths> <annotationProcessorPaths>
<path> <path>
<groupId>org.mapstruct</groupId> <groupId>org.mapstruct</groupId>

View File

@@ -2,11 +2,11 @@ package dev.lions.unionflow.server.client;
import io.quarkus.oidc.runtime.OidcJwtCallerPrincipal; import io.quarkus.oidc.runtime.OidcJwtCallerPrincipal;
import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.client.ClientRequestContext; import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter; import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
@@ -20,9 +20,15 @@ import java.io.IOException;
* qui utilisent AdminServiceTokenHeadersFactory (service account). Le filtre global * qui utilisent AdminServiceTokenHeadersFactory (service account). Le filtre global
* écraserait le token de service account avec le JWT utilisateur → 401 sur LUM. * écraserait le token de service account avec le JWT utilisateur → 401 sur LUM.
* *
* <p>La propagation JWT est assurée par {@link OidcTokenPropagationHeadersFactory} * <p>{@code @ApplicationScoped} est requis pour la découverte CDI (tests {@code @QuarkusTest}
* qui {@code @Inject} le filter). Cela ne provoque PAS d'enregistrement automatique JAX-RS
* — l'opt-in se fait via {@code @RegisterProvider(JwtPropagationFilter.class)} sur les
* REST clients qui le souhaitent.
*
* <p>La propagation JWT par défaut est assurée par {@link OidcTokenPropagationHeadersFactory}
* sur les clients qui en ont besoin ({@code @RegisterClientHeaders}). * sur les clients qui en ont besoin ({@code @RegisterClientHeaders}).
*/ */
@ApplicationScoped
public class JwtPropagationFilter implements ClientRequestFilter { public class JwtPropagationFilter implements ClientRequestFilter {
private static final Logger LOG = Logger.getLogger(JwtPropagationFilter.class); private static final Logger LOG = Logger.getLogger(JwtPropagationFilter.class);

View File

@@ -0,0 +1,104 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* Entrée d'audit trail enrichi (SYSCOHADA + AUDSCGIE OHADA).
*
* <p>Trace les opérations financières, le lifecycle membres, les changements de configuration,
* avec le contexte multi-org (rôle actif + organisation active) + vérifications de séparation des
* pouvoirs (SoD).
*
* <p>Cette entité ne dérive PAS de {@link BaseEntity} car elle représente un enregistrement
* immuable d'historique : ses propres champs d'audit ({@code operationAt}, {@code userId}) sont
* la donnée à tracer.
*
* @since 2026-04-25 — exigences SYSCOHADA + Instruction BCEAO 003-03-2025 (audit KYC)
*/
@Entity
@Table(name = "audit_trail_operations")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuditTrailOperation {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", updatable = false, nullable = false)
private UUID id;
// Acteur
@NotNull
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "user_email", length = 255)
private String userEmail;
@Column(name = "role_actif", length = 50)
private String roleActif;
@Column(name = "organisation_active_id")
private UUID organisationActiveId;
// Action
@NotBlank
@Column(name = "action_type", nullable = false, length = 50)
private String actionType;
@NotBlank
@Column(name = "entity_type", nullable = false, length = 100)
private String entityType;
@Column(name = "entity_id")
private UUID entityId;
@Column(name = "description", length = 500)
private String description;
// Contexte
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
@Column(name = "request_id")
private UUID requestId;
// Données (JSONB)
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload_avant", columnDefinition = "jsonb")
private String payloadAvant;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload_apres", columnDefinition = "jsonb")
private String payloadApres;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "metadata", columnDefinition = "jsonb")
private String metadata;
// SoD
@Column(name = "sod_check_passed")
private Boolean sodCheckPassed;
@Column(name = "sod_violations", length = 500)
private String sodViolations;
@NotNull
@Column(name = "operation_at", nullable = false)
@Builder.Default
private LocalDateTime operationAt = LocalDateTime.now();
}

View File

@@ -0,0 +1,168 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Bénéficiaire effectif (UBO — Ultimate Beneficial Owner) lié à un dossier KYC.
*
* <p>Implémente l'obligation introduite par l'<strong>Instruction BCEAO 003-03-2025 du 18 mars
* 2025</strong> : identification, vérification et connaissance du client par les institutions
* financières — vérification systématique des bénéficiaires effectifs obligatoire (approche par
* les risques).
*
* <p>Un bénéficiaire effectif est, selon la directive UEMOA et le GAFI/FATF, toute personne
* physique qui :
*
* <ul>
* <li>détient au moins <strong>25 %</strong> du capital ou des droits de vote d'une personne
* morale ;
* <li>OU exerce un contrôle effectif (de fait ou de droit) sur la gestion de l'entité ;
* <li>OU est bénéficiaire ultime d'une opération suspecte structurée.
* </ul>
*
* <p>Ces enregistrements doivent être conservés <strong>10 ans</strong> après la clôture de la
* relation d'affaires (directive 02/2015/CM/UEMOA).
*
* @since 2026-04-25 — Instruction BCEAO 003-03-2025 (KYC + UBO)
*/
@Entity
@Table(
name = "beneficiaires_effectifs",
indexes = {
@Index(name = "idx_ubo_kyc_dossier", columnList = "kyc_dossier_id"),
@Index(name = "idx_ubo_organisation_cible", columnList = "organisation_cible_id"),
@Index(name = "idx_ubo_pays", columnList = "pays_residence")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class BeneficiaireEffectif extends BaseEntity {
/** Dossier KYC auquel ce bénéficiaire effectif est rattaché. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "kyc_dossier_id", nullable = false)
private KycDossier kycDossier;
/**
* Organisation cible dont cette personne est bénéficiaire effectif (utile en cas de KYC client
* personne morale — la chaîne de contrôle UBO peut traverser plusieurs entités).
*/
@Column(name = "organisation_cible_id")
private UUID organisationCibleId;
/** Lien vers le membre UnionFlow correspondant si applicable (UBO interne au système). */
@Column(name = "membre_id")
private UUID membreId;
// Identité
@NotBlank
@Column(name = "nom", nullable = false, length = 100)
private String nom;
@NotBlank
@Column(name = "prenoms", nullable = false, length = 200)
private String prenoms;
@NotNull
@Column(name = "date_naissance", nullable = false)
private LocalDate dateNaissance;
@Column(name = "lieu_naissance", length = 200)
private String lieuNaissance;
@NotBlank
@Column(name = "nationalite", nullable = false, length = 3)
private String nationalite; // ISO 3166-1 alpha-3
@Column(name = "pays_residence", length = 3)
private String paysResidence;
// Pièce d'identité
@Enumerated(EnumType.STRING)
@Column(name = "type_piece_identite", length = 30)
private TypePieceIdentite typePieceIdentite;
@Column(name = "numero_piece_identite", length = 50)
private String numeroPieceIdentite;
@Column(name = "date_expiration_piece")
private LocalDate dateExpirationPiece;
// Contrôle
/**
* Pourcentage de détention en capital (0-100). Si {@code >= 25} → UBO direct selon GAFI.
* Peut être null si le contrôle est exercé autrement (mandat, accord d'actionnaires).
*/
@DecimalMin("0.00")
@DecimalMax("100.00")
@Column(name = "pourcentage_capital", precision = 5, scale = 2)
private BigDecimal pourcentageCapital;
/** Pourcentage des droits de vote (0-100). */
@DecimalMin("0.00")
@DecimalMax("100.00")
@Column(name = "pourcentage_droits_vote", precision = 5, scale = 2)
private BigDecimal pourcentageDroitsVote;
/**
* Nature du contrôle exercé : DETENTION_CAPITAL, DROITS_VOTE, CONTROLE_DE_FAIT,
* BENEFICIAIRE_ULTIME, MANDAT_REPRESENTATION.
*/
@NotBlank
@Column(name = "nature_controle", nullable = false, length = 50)
private String natureControle;
// Politique d'exposition (PEP)
@Column(name = "est_pep", nullable = false)
@Builder.Default
private boolean estPep = false;
@Column(name = "pep_categorie", length = 100)
private String pepCategorie;
@Column(name = "pep_pays", length = 3)
private String pepPays;
@Column(name = "pep_fonction", length = 200)
private String pepFonction;
// Sanctions / vigilance
@Column(name = "presence_listes_sanctions", nullable = false)
@Builder.Default
private boolean presenceListesSanctions = false;
@Column(name = "details_listes_sanctions", length = 1000)
private String detailsListesSanctions;
// Vérification
@Column(name = "verifie_par_id")
private UUID verifieParId;
@Column(name = "date_verification")
private java.time.LocalDateTime dateVerification;
@Column(name = "source_verification", length = 200)
private String sourceVerification;
@Column(name = "notes", length = 2000)
private String notes;
}

View File

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

View File

@@ -76,6 +76,53 @@ public class DemandeAide extends BaseEntity {
@Column(name = "documents_fournis") @Column(name = "documents_fournis")
private String documentsFournis; private String documentsFournis;
// ========================================================
// Workflow v2 (P1-NEW-3, 2026-04-25) — DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAYE → CLOTURE
// ========================================================
/** Étape actuelle dans le workflow v2 (DEPOSE par défaut). */
@Column(name = "etape", length = 30)
@Builder.Default
private String etape = "DEPOSE";
/** Animateur de zone responsable de l'enquête sociale (étape ENQUETE). */
@Column(name = "animateur_zone_id")
private java.util.UUID animateurZoneId;
/** Rapport rédigé par l'animateur après visite (étape ENQUETE). */
@Column(name = "rapport_enquete_sociale", columnDefinition = "TEXT")
private String rapportEnqueteSociale;
@Column(name = "date_enquete")
private LocalDateTime dateEnquete;
/** Géolocalisation GPS de l'enquête (preuve de visite terrain). */
@Column(name = "gps_enquete_lat", precision = 10, scale = 7)
private java.math.BigDecimal gpsEnqueteLat;
@Column(name = "gps_enquete_lon", precision = 10, scale = 7)
private java.math.BigDecimal gpsEnqueteLon;
/** Avis du comité social ou commission solidarité (étape AVIS_COMITE). */
@Column(name = "avis_comite_social", columnDefinition = "TEXT")
private String avisComiteSocial;
@Column(name = "date_avis_comite")
private LocalDateTime dateAvisComite;
/** Lien vers le PV CA dans lequel la décision a été votée (étape DECISION_CA). */
@Column(name = "decision_ca_id")
private java.util.UUID decisionCaId;
@Column(name = "date_decision_ca")
private LocalDateTime dateDecisionCa;
@Column(name = "date_paie")
private LocalDateTime datePaie;
@Column(name = "reference_paiement", length = 100)
private String referencePaiement;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); // Appelle le onCreate de BaseEntity super.onCreate(); // Appelle le onCreate de BaseEntity

View File

@@ -0,0 +1,58 @@
package dev.lions.unionflow.server.entity;
import java.util.Set;
/**
* Devises supportées par UnionFlow.
*
* <p>UnionFlow vise prioritairement la zone UEMOA (XOF/XAF) mais s'ouvre à la diaspora
* (EUR/USD/GBP/CAD). Le {@link ZoneDevise} permet de discriminer pour les règles
* AML (transferts internationaux, due diligence renforcée).
*
* @since 2026-04-25 (P2-NEW-7)
*/
public enum Devise {
// Zone UEMOA / CEMAC
XOF("Franc CFA Ouest", ZoneDevise.UEMOA),
XAF("Franc CFA Centrale", ZoneDevise.CEMAC),
// Diaspora — Europe / Amérique
EUR("Euro", ZoneDevise.EUROPE),
USD("Dollar US", ZoneDevise.AMERIQUE),
GBP("Livre Sterling", ZoneDevise.EUROPE),
CAD("Dollar Canadien", ZoneDevise.AMERIQUE),
CHF("Franc Suisse", ZoneDevise.EUROPE),
// CEDEAO non-UEMOA (pour intégrations futures)
GHS("Cédi Ghanéen", ZoneDevise.CEDEAO),
NGN("Naira Nigérian", ZoneDevise.CEDEAO),
// Maghreb
MAD("Dirham Marocain", ZoneDevise.MAGHREB);
private final String libelle;
private final ZoneDevise zone;
Devise(String libelle, ZoneDevise zone) {
this.libelle = libelle;
this.zone = zone;
}
public String libelle() { return libelle; }
public ZoneDevise zone() { return zone; }
/** Devise de référence UnionFlow / BCEAO. */
public static Devise reference() { return XOF; }
/** Devises pour lesquelles un transfert depuis/vers UEMOA déclenche AML renforcé. */
public static final Set<Devise> DEVISES_INTERNATIONALES = Set.of(EUR, USD, GBP, CAD, CHF);
public boolean estInternationale() {
return DEVISES_INTERNATIONALES.contains(this);
}
public enum ZoneDevise {
UEMOA, CEMAC, CEDEAO, EUROPE, AMERIQUE, MAGHREB
}
}

View File

@@ -0,0 +1,86 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Don reçu (numéraire, nature, bénévolat, legs) — comptabilisé selon SYCEBNL :
*
* <ul>
* <li>NUMERAIRE → Crédit 755 (Dons et libéralités)
* <li>NATURE → valorisation obligatoire au prix marché, idem 755
* <li>BENEVOLAT → valorisation possible en notes annexes
* <li>LEGS → Crédit 756 ou poste dédié selon nature
* <li>FONDS_DEDIE → Crédit 19 (Fonds dédiés non utilisés, à reverser si finalité non remplie)
* </ul>
*
* @since 2026-04-25 (P1-NEW-13)
*/
@Entity
@Table(name = "dons_recus", indexes = {
@Index(name = "idx_don_org", columnList = "organisation_id"),
@Index(name = "idx_don_donateur", columnList = "donateur_id"),
@Index(name = "idx_don_date", columnList = "date_don")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class DonRecu extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "donateur_id")
private Donateur donateur;
@NotBlank
@Column(name = "type_don", nullable = false, length = 20)
private String typeDon; // NUMERAIRE, NATURE, BENEVOLAT, LEGS
@Column(name = "montant_xof", precision = 15, scale = 2)
private BigDecimal montantXof;
@Column(name = "valorisation_xof", precision = 15, scale = 2)
private BigDecimal valorisationXof;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@NotNull
@Column(name = "date_don", nullable = false)
private LocalDate dateDon;
@NotBlank
@Column(name = "affectation", nullable = false, length = 50)
@Builder.Default
private String affectation = "LIBRE"; // LIBRE, FONDS_DEDIE, PROJET_SPECIFIQUE
@Column(name = "fonds_dedie_id")
private UUID fondsDedieId;
@Column(name = "projet_id")
private UUID projetId;
@Column(name = "recu_emis", nullable = false)
@Builder.Default
private boolean recuEmis = false;
@Column(name = "numero_recu", length = 50)
private String numeroRecu;
@Column(name = "date_emission_recu")
private LocalDate dateEmissionRecu;
}

View File

@@ -0,0 +1,53 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Donateur — registre obligatoire pour les entités relevant de SYCEBNL (associations, ONG,
* mutuelles sociales).
*
* @since 2026-04-25 (P1-NEW-13)
*/
@Entity
@Table(name = "donateurs", indexes = {
@Index(name = "idx_donateur_org", columnList = "organisation_id"),
@Index(name = "idx_donateur_type", columnList = "type_donateur")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Donateur extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotBlank
@Column(name = "type_donateur", nullable = false, length = 20)
private String typeDonateur; // PERSONNE_PHYSIQUE, PERSONNE_MORALE, ANONYME
@Column(name = "nom_prenoms", length = 255)
private String nomPrenoms;
@Column(name = "raison_sociale", length = 255)
private String raisonSociale;
@Column(name = "pays", length = 3)
private String pays;
@Column(name = "email", length = 255)
private String email;
@Column(name = "telephone", length = 20)
private String telephone;
}

View File

@@ -0,0 +1,75 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Session de formation LBC/FT (lutte contre le blanchiment de capitaux et le financement du
* terrorisme).
*
* <p>Obligation annuelle posée par l'<strong>Instruction BCEAO 001-03-2025 du 18 mars 2025</strong>
* pour le compliance officer + les dirigeants + les membres exposés (trésorier, secrétaire,
* commissaires aux comptes).
*
* @since 2026-04-25 (P1-NEW-12)
*/
@Entity
@Table(name = "formations_lbcft", indexes = {
@Index(name = "idx_formation_org_annee", columnList = "organisation_id,annee_reference"),
@Index(name = "idx_formation_date", columnList = "date_session")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class FormationLbcFt extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotBlank
@Column(name = "titre", nullable = false, length = 255)
private String titre;
@NotBlank
@Column(name = "type_formation", nullable = false, length = 30)
@Builder.Default
private String typeFormation = "STANDARD"; // STANDARD, AVANCE, COMPLIANCE_OFFICER, DIRIGEANT
@Column(name = "contenu", columnDefinition = "TEXT")
private String contenu;
@Column(name = "intervenant", length = 255)
private String intervenant;
@Column(name = "duree_heures", precision = 4, scale = 1, nullable = false)
@Builder.Default
private BigDecimal dureeHeures = new BigDecimal("4.0");
@NotNull
@Column(name = "date_session", nullable = false)
private LocalDateTime dateSession;
@Column(name = "lieu", length = 255)
private String lieu;
@NotNull
@Column(name = "annee_reference", nullable = false)
private Integer anneeReference;
@NotBlank
@Column(name = "statut", nullable = false, length = 20)
@Builder.Default
private String statut = "PLANIFIEE"; // PLANIFIEE, EN_COURS, TERMINEE, ANNULEE
}

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
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();
/** Pays d'origine des fonds (ISO-3) — anti-blanchiment transferts internationaux. */
@Size(max = 3)
@Column(name = "pays_origine_fonds", length = 3)
private String paysOrigineFonds;
/** URL/chemin justificatif domicile étranger (facture EDF/British Gas/etc.) pour non-résidents. */
@Size(max = 500)
@Column(name = "justificatif_residence_etrangere", length = 500)
private String justificatifResidenceEtrangere;
/**
* Niveau de due diligence (Instr. BCEAO 001-03-2025) :
* <ul>
* <li>SIMPLIFIE — risque faible, opérations limitées</li>
* <li>STANDARD — défaut</li>
* <li>RENFORCE — non-résidents, PEP, FATF grey-list</li>
* </ul>
*/
@Size(max = 20)
@Column(name = "niveau_due_diligence", nullable = false, length = 20)
@Builder.Default
private String niveauDueDiligence = "STANDARD";
public boolean isPieceExpiree() {
return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now());
}
}

View File

@@ -59,6 +59,52 @@ 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;
/**
* Numéro CMU (Couverture Maladie Universelle) Côte d'Ivoire — auto-déclaré par le membre.
*
* <p>Obligatoire pour les organisations de type {@code MUTUELLE_SANTE} (Loi 2014-131
* exige enrôlement CMU comme préalable à toute mutuelle complémentaire). Format CNAM :
* 11 caractères alphanumériques. La vérification de la validité se fait manuellement
* (admin) faute d'API publique CNAM disponible au 2026-04-25.
*
* @since 2026-04-25 — passage CMU à cotisation obligatoire 1er jan 2026
*/
@Pattern(regexp = "^[A-Z0-9]{11}$|^$", message = "Le numéro CMU doit faire 11 caractères alphanumériques majuscules")
@Column(name = "numero_cmu", length = 11)
private String numeroCMU;
/**
* Pays de résidence (ISO-3, ex: FRA, USA, CAN). Différent de {@code nationalite} :
* un Ivoirien (CIV) résidant en France a paysResidence=FRA. NULL ou CIV = résident UEMOA.
*
* @since 2026-04-25 (P2-NEW-7)
*/
@Pattern(regexp = "^[A-Z]{3}$|^$", message = "Pays résidence doit être un code ISO-3")
@Column(name = "pays_residence", length = 3)
private String paysResidence;
/** Numéro de passeport pour non-résidents (CNI insuffisante hors UEMOA). */
@Column(name = "numero_passeport", length = 50)
private String numeroPasseport;
/** NIF/SSN/SIN — reporting fiscal accord bilatéral CI ↔ pays résidence. */
@Column(name = "numero_fiscal_etranger", length = 50)
private String numeroFiscalEtranger;
/** TRUE si le membre est diaspora (résidence ≠ UEMOA). */
@Builder.Default
@Column(name = "est_diaspora", nullable = false)
private Boolean estDiaspora = false;
/** Devise préférée pour affichages et notifications (XOF par défaut). */
@Builder.Default
@Column(name = "devise_preferee", nullable = false, length = 3)
private String devisePreferee = "XOF";
@Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)") @Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)")
@Column(name = "telephone_wave", length = 20) @Column(name = "telephone_wave", length = 20)
private String telephoneWave; private String telephoneWave;

View File

@@ -8,6 +8,7 @@ import java.time.LocalDate;
import java.time.Period; import java.time.Period;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@@ -201,10 +202,38 @@ 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;
/**
* Référentiel comptable applicable à cette organisation.
*
* <p>Détermine quel plan comptable est appliqué et quels états financiers sont générés
* (bilan, compte de résultat, annexes). Mappage par défaut depuis {@code typeOrganisation}
* via {@link ReferentielComptable#defaultFor(String)} ; l'admin peut overrider manuellement.
*
* @since 2026-04-25 — découverte SYCEBNL (11ᵉ Acte uniforme OHADA en vigueur 1er jan 2024)
*/
@Enumerated(EnumType.STRING)
@Column(name = "referentiel_comptable", nullable = false, length = 20)
@Builder.Default
private ReferentielComptable referentielComptable = ReferentielComptable.SYSCOHADA;
/**
* UUID du membre désigné comme Compliance Officer de l'organisation (rôle obligatoire selon
* Instruction BCEAO 001-03-2025). Doit être rattaché à la direction générale, distinct du
* trésorier (séparation des pouvoirs).
*
* @since 2026-04-25 — Instruction BCEAO 001-03-2025 (LBC/FT)
*/
@Column(name = "compliance_officer_id")
private UUID complianceOfficerId;
// Relations // Relations
/** Adhésions des membres à cette organisation */ /** Adhésions des membres à cette organisation */

View File

@@ -0,0 +1,56 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Participation d'un membre à une session de formation LBC/FT.
*
* @since 2026-04-25 (P1-NEW-12)
*/
@Entity
@Table(name = "participations_formation_lbcft",
uniqueConstraints = @UniqueConstraint(columnNames = {"formation_id", "membre_id"}),
indexes = {
@Index(name = "idx_participation_membre", columnList = "membre_id"),
@Index(name = "idx_participation_statut", columnList = "statut_participation")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ParticipationFormationLbcFt extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "formation_id", nullable = false)
private FormationLbcFt formation;
@NotNull
@Column(name = "membre_id", nullable = false)
private UUID membreId;
@NotBlank
@Column(name = "statut_participation", nullable = false, length = 20)
@Builder.Default
private String statutParticipation = "INSCRIT"; // INSCRIT, PRESENT, ABSENT, CERTIFIE
@Column(name = "date_certification")
private LocalDateTime dateCertification;
@Column(name = "numero_certificat", length = 100)
private String numeroCertificat;
@Column(name = "score_quiz", precision = 5, scale = 2)
private BigDecimal scoreQuiz;
}

View File

@@ -0,0 +1,144 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* Procès-verbal d'AG ou de CA conforme OHADA AUDSCGIE.
*
* <p>Structure obligatoire selon l'Acte Uniforme OHADA Sociétés Coopératives (15 décembre 2010,
* applicable depuis 15 mai 2011) + AUDSCGIE révisé 30 janvier 2014 :
*
* <ul>
* <li>Date, lieu, heures d'ouverture et clôture
* <li>Quorum calculé (selon convoqués / présents / représentés)
* <li>Ordre du jour structuré
* <li>Résolutions votées avec décompte (pour / contre / abstentions / adoptée)
* <li>Signatures président + secrétaire
* <li>Archivage immuable au siège (hash SHA-256 pour intégrité)
* </ul>
*
* @since 2026-04-25 (P1-NEW-2)
*/
@Entity
@Table(name = "proces_verbaux")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ProcesVerbal extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotBlank
@Column(name = "type_seance", nullable = false, length = 20)
private String typeSeance; // AG_CONSTITUTIVE, AG_ORDINAIRE, AG_EXTRAORDINAIRE, CA, BUREAU
@NotBlank
@Column(name = "titre", nullable = false, length = 255)
private String titre;
@Column(name = "numero_seance", length = 50)
private String numeroSeance;
// Convocation
@NotNull
@Column(name = "date_convocation", nullable = false)
private LocalDateTime dateConvocation;
@Column(name = "mode_convocation", length = 50)
private String modeConvocation;
// Tenue
@NotNull
@Column(name = "date_seance", nullable = false)
private LocalDateTime dateSeance;
@Column(name = "lieu", length = 255)
private String lieu;
@Column(name = "heure_ouverture")
private LocalTime heureOuverture;
@Column(name = "heure_cloture")
private LocalTime heureCloture;
// Quorum
@Column(name = "nombre_convoques", nullable = false)
@Builder.Default
private int nombreConvoques = 0;
@Column(name = "nombre_presents", nullable = false)
@Builder.Default
private int nombrePresents = 0;
@Column(name = "nombre_representes", nullable = false)
@Builder.Default
private int nombreRepresentes = 0;
@Column(name = "quorum_atteint", nullable = false)
@Builder.Default
private boolean quorumAtteint = false;
@Column(name = "quorum_requis_pct", precision = 5, scale = 2)
private BigDecimal quorumRequisPct;
@Column(name = "quorum_calcule_pct", precision = 5, scale = 2)
private BigDecimal quorumCalculePct;
// Présidence
@Column(name = "president_seance_id")
private UUID presidentSeanceId;
@Column(name = "secretaire_seance_id")
private UUID secretaireSeanceId;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "scrutateurs_ids", columnDefinition = "jsonb")
private String scrutateursIds; // JSON array of UUIDs
// Contenu
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "ordre_du_jour", columnDefinition = "jsonb", nullable = false)
private String ordreDuJour; // JSON: [{numero, intitule, type}]
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "resolutions", columnDefinition = "jsonb", nullable = false)
private String resolutions; // JSON: [{numero, intitule, votesPour, votesContre, votesAbstention, adoptee}]
@Column(name = "deliberations", columnDefinition = "TEXT")
private String deliberations;
// Signature & archivage
@NotBlank
@Column(name = "statut", nullable = false, length = 30)
@Builder.Default
private String statut = "BROUILLON"; // BROUILLON, ADOPTE, SIGNE, ARCHIVE
@Column(name = "hash_sha256", length = 64)
private String hashSha256;
@Column(name = "date_signature")
private LocalDateTime dateSignature;
@Column(name = "signature_president", length = 500)
private String signaturePresident;
@Column(name = "signature_secretaire", length = 500)
private String signatureSecretaire;
}

View File

@@ -0,0 +1,97 @@
package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* Rapport trimestriel agrégé généré par/pour le Contrôleur Interne d'une organisation.
*
* <p>Source documentaire pour :
* <ul>
* <li>Présentation lors des AG (rapport moral / financier / technique)</li>
* <li>Inspections BCEAO Instruction 001-03-2025 (LBC/FT)</li>
* <li>Audits ARTCI Décision 2025-1312 (DPO / sécurité données)</li>
* </ul>
*
* <p>Cycle de vie : {@code DRAFT} → {@code SIGNE} (hash SHA-256 calculé) → {@code ARCHIVE}.
*
* @since 2026-04-25 (P2-NEW-3)
*/
@Entity
@Table(name = "rapports_trimestriels_controleur_interne",
uniqueConstraints = @UniqueConstraint(
name = "uq_rapport_trim_org_annee_trim",
columnNames = {"organisation_id", "annee", "trimestre"}),
indexes = {
@Index(name = "idx_rapport_trim_org", columnList = "organisation_id"),
@Index(name = "idx_rapport_trim_annee_trim", columnList = "annee,trimestre"),
@Index(name = "idx_rapport_trim_statut", columnList = "statut")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class RapportTrimestrielControleurInterne extends BaseEntity {
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@Min(2024)
@Max(2099)
@Column(name = "annee", nullable = false)
private Integer annee;
@Min(1)
@Max(4)
@Column(name = "trimestre", nullable = false)
private Integer trimestre;
@Column(name = "date_generation", nullable = false)
private LocalDateTime dateGeneration;
@Builder.Default
@Column(name = "statut", nullable = false, length = 20)
private String statut = "DRAFT";
@Builder.Default
@Min(0)
@Max(100)
@Column(name = "score_conformite", nullable = false)
private Integer scoreConformite = 0;
/** Snapshot agrégé en JSON (compliance score, DOS count, KYC %, etc.). */
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "contenu_jsonb", columnDefinition = "jsonb")
private JsonNode contenuJsonb;
/** PDF généré par OpenPDF. Null avant génération. */
@Column(name = "pdf_bytes")
private byte[] pdfBytes;
/** UUID du membre Contrôleur Interne signataire. */
@Column(name = "signataire_id")
private UUID signataireId;
@Column(name = "date_signature")
private LocalDateTime dateSignature;
/** Hash SHA-256 du contenu_jsonb calculé à la signature — immuable ensuite. */
@Column(name = "hash_sha256", length = 64)
private String hashSha256;
}

View File

@@ -0,0 +1,68 @@
package dev.lions.unionflow.server.entity;
/**
* Référentiel comptable applicable à une {@link Organisation}.
*
* <p>OHADA dispose désormais de plusieurs référentiels selon la nature de l'entité :
*
* <ul>
* <li>{@link #SYSCOHADA} — Système Comptable OHADA révisé (1er jan 2018) pour entités
* commerciales/coopératives à but lucratif.
* <li>{@link #SYCEBNL} — Système Comptable OHADA des Entités à But Non Lucratif (11ᵉ Acte
* uniforme, entré en vigueur <strong>1er jan 2024</strong>) pour mutuelles sociales,
* associations, ONG, fondations, syndicats, projets de développement.
* <li>{@link #PCSFD_UMOA} — Plan Comptable des Systèmes Financiers Décentralisés UMOA pour SFD
* soumis Commission Bancaire UMOA (article 44, encours ≥ 2 milliards FCFA = catégorie III).
* </ul>
*
* <p>Le mapping par défaut depuis {@code Organisation.typeOrganisation} se trouve dans
* {@link #defaultFor(String)}. L'admin peut overrider manuellement (cas hybrides).
*
* <p>Voir : {@code unionflow/docs/COMPLIANCE_OHADA_SYCEBNL.md} et {@code
* unionflow/docs/COMPLIANCE_OHADA_SYSCOHADA.md}.
*
* @since 2026-04-25
*/
public enum ReferentielComptable {
/** Système Comptable OHADA révisé (entités commerciales / coopératives lucratives). */
SYSCOHADA,
/**
* Système Comptable OHADA des Entités à But Non Lucratif (mutuelles sociales, associations,
* ONG, fondations, Lions Clubs, syndicats). Acte uniforme entré en vigueur 1er janvier 2024.
*/
SYCEBNL,
/**
* Plan Comptable des Systèmes Financiers Décentralisés UMOA. Pour SFD article 44 (encours ≥ 2
* Md FCFA = catégorie III, commissaire aux comptes obligatoire agréé OHADA, sélection soumise
* approbation Commission Bancaire UMOA).
*/
PCSFD_UMOA;
/**
* Retourne le référentiel par défaut suggéré pour un {@code typeOrganisation}. L'admin peut
* overrider manuellement à la création/édition d'une organisation.
*
* @param typeOrganisation valeur de {@link Organisation#getTypeOrganisation()}
* @return référentiel par défaut, jamais null (fallback {@link #SYSCOHADA})
*/
public static ReferentielComptable defaultFor(String typeOrganisation) {
if (typeOrganisation == null) {
return SYSCOHADA;
}
return switch (typeOrganisation.toUpperCase()) {
case "MUTUELLE_SANTE",
"ASSOCIATION",
"LIONS_CLUB",
"ONG",
"FONDATION",
"SYNDICAT",
"ORDRE_PROFESSIONNEL",
"PROJET_DEVELOPPEMENT" ->
SYCEBNL;
case "SFD_TIER_1", "SFD_CATEGORIE_III" -> PCSFD_UMOA;
default -> SYSCOHADA;
};
}
}

View File

@@ -0,0 +1,77 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Délégation temporaire d'un rôle.
*
* <p>Cas d'usage : trésorier en congé délègue son rôle au trésorier adjoint pour 2 semaines.
*
* <p>Le {@code PermissionChecker} consulte cette table pour calculer le rôle effectif :
* <strong>roles directs roles délégués actifs</strong> (statut=ACTIVE et dateFin > now).
*
* @since 2026-04-25 (P1-NEW-5)
*/
@Entity
@Table(name = "role_delegations", indexes = {
@Index(name = "idx_delegation_org", columnList = "organisation_id"),
@Index(name = "idx_delegation_delegataire", columnList = "delegataire_user_id")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class RoleDelegation extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotNull
@Column(name = "delegant_user_id", nullable = false)
private UUID delegantUserId;
@NotNull
@Column(name = "delegataire_user_id", nullable = false)
private UUID delegataireUserId;
@NotBlank
@Column(name = "role_delegue", nullable = false, length = 50)
private String roleDelegue;
@NotNull
@Column(name = "date_debut", nullable = false)
private LocalDateTime dateDebut;
@NotNull
@Column(name = "date_fin", nullable = false)
private LocalDateTime dateFin;
@Column(name = "motif", length = 500)
private String motif;
@NotBlank
@Column(name = "statut", nullable = false, length = 20)
@Builder.Default
private String statut = "ACTIVE"; // ACTIVE, EXPIREE, REVOQUEE
@Column(name = "date_revocation")
private LocalDateTime dateRevocation;
/** Vrai si la délégation est active à l'instant donné. */
public boolean isActiveAt(LocalDateTime instant) {
return "ACTIVE".equals(statut)
&& dateDebut != null && !dateDebut.isAfter(instant)
&& dateFin != null && dateFin.isAfter(instant);
}
}

View File

@@ -0,0 +1,62 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.DecimalMin;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Taux de change quotidien entre deux {@link Devise}s.
*
* <p>Source : BCEAO (officiel UEMOA), ECB, Fixer.io ou import manuel. Conservation
* historique pour audit et conversions rétroactives.
*
* @since 2026-04-25 (P2-NEW-7)
*/
@Entity
@Table(name = "taux_change",
uniqueConstraints = @UniqueConstraint(
name = "uq_taux_change_paire_date",
columnNames = {"devise_source", "devise_cible", "date_validite"}),
indexes = {
@Index(name = "idx_taux_change_paire", columnList = "devise_source,devise_cible"),
@Index(name = "idx_taux_change_date_validite", columnList = "date_validite")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class TauxChange extends BaseEntity {
@Enumerated(EnumType.STRING)
@Column(name = "devise_source", nullable = false, length = 3)
private Devise deviseSource;
@Enumerated(EnumType.STRING)
@Column(name = "devise_cible", nullable = false, length = 3)
private Devise deviseCible;
/** 1 unité de {@code deviseSource} = {@code taux} unités de {@code deviseCible}. */
@DecimalMin(value = "0.00000001", inclusive = false)
@Column(name = "taux", nullable = false, precision = 18, scale = 8)
private BigDecimal taux;
@Column(name = "date_validite", nullable = false)
private LocalDate dateValidite;
@Builder.Default
@Column(name = "source", nullable = false, length = 50)
private String source = "BCEAO";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,240 @@
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 javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.FileInputStream;
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.security.KeyStore;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
/**
* Authentification PI-SPI à 3 facteurs : OAuth2 + mTLS + API Key.
*
* <p>Conforme à la spec sandbox developer.pispi.bceao.int (vérifiée 2026-04-25). Les 3 facteurs
* sont systématiquement présents sur tous les appels API Business :
*
* <ul>
* <li><strong>OAuth2 client_credentials</strong> — clientId + clientSecret pour récupérer un
* Bearer token, mis en cache jusqu'à expiration ({@code expires_in - 60s}).
* <li><strong>mTLS (mutual TLS)</strong> — certificat client (PKCS12) présenté pendant la
* handshake TLS. Configuré via {@link SSLContext} sur le {@link HttpClient}.
* <li><strong>API Key</strong> — header {@code X-API-Key} ajouté sur chaque requête (géré par
* {@link PispiClient}, exposé par {@link #getApiKey()}).
* </ul>
*
* <p>Configuration ({@code application.properties}) :
*
* <pre>{@code
* pispi.api.base-url=https://sandbox.pispi.bceao.int/business-api/v1
* pispi.api.client-id=<clientId BCEAO>
* pispi.api.client-secret=<clientSecret BCEAO>
* pispi.api.api-key=<X-API-Key BCEAO>
* pispi.api.tls.keystore-path=/secrets/pispi-client.p12
* pispi.api.tls.keystore-password=<password>
* pispi.api.tls.truststore-path=/secrets/pispi-truststore.p12 # optionnel
* pispi.api.tls.truststore-password=<password> # optionnel
* }</pre>
*
* <p>En l'absence de credentials (mode dev sans sandbox), {@link #isConfigured()} renvoie
* {@code false} et {@link PispiPaymentProvider} bascule en mode mock.
*
* @since 2026-04-25 — auth 3-facteurs ajoutée (OAuth2 seul auparavant)
*/
@Slf4j
@ApplicationScoped
public class PispiAuth {
@ConfigProperty(name = "pispi.api.client-id")
Optional<String> clientIdOpt;
@ConfigProperty(name = "pispi.api.client-secret")
Optional<String> clientSecretOpt;
@ConfigProperty(name = "pispi.api.api-key")
Optional<String> apiKeyOpt;
@ConfigProperty(name = "pispi.api.tls.keystore-path")
Optional<String> keystorePathOpt;
@ConfigProperty(name = "pispi.api.tls.keystore-password")
Optional<String> keystorePasswordOpt;
@ConfigProperty(name = "pispi.api.tls.truststore-path")
Optional<String> truststorePathOpt;
@ConfigProperty(name = "pispi.api.tls.truststore-password")
Optional<String> truststorePasswordOpt;
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
String clientId;
String clientSecret;
String apiKey;
private HttpClient mtlsClient;
private String cachedToken;
private Instant cacheExpiry;
@jakarta.annotation.PostConstruct
void init() {
clientId = clientIdOpt.orElse("");
clientSecret = clientSecretOpt.orElse("");
apiKey = apiKeyOpt.orElse("");
// Le client mTLS est construit lazy au premier usage (évite échec au boot si secrets absents)
}
/**
* @return true si tous les facteurs (OAuth2 + mTLS + API Key) sont configurés et que le
* provider peut effectivement appeler la production. False = mode mock auto.
*/
public boolean isConfigured() {
return !clientId.isEmpty()
&& !clientSecret.isEmpty()
&& !apiKey.isEmpty()
&& keystorePathOpt.isPresent()
&& keystorePasswordOpt.isPresent();
}
/** Header {@code X-API-Key} à ajouter sur chaque requête API Business. */
public String getApiKey() {
return apiKey;
}
// ── Readiness inspection helpers (P1-NEW-15) ────────────────────────────
public boolean hasClientId() { return clientId != null && !clientId.isEmpty(); }
public boolean hasClientSecret() { return clientSecret != null && !clientSecret.isEmpty(); }
public boolean hasApiKey() { return apiKey != null && !apiKey.isEmpty(); }
public java.util.Optional<String> keystorePath() {
return keystorePathOpt.filter(s -> !s.isBlank());
}
public java.util.Optional<String> keystorePassword() {
return keystorePasswordOpt.filter(s -> !s.isBlank());
}
public java.util.Optional<String> truststorePath() {
return truststorePathOpt.filter(s -> !s.isBlank());
}
public java.util.Optional<String> truststorePassword() {
return truststorePasswordOpt.filter(s -> !s.isBlank());
}
/** Base URL configurée (sandbox ou production). */
public String getBaseUrl() {
return baseUrl;
}
/**
* Retourne un {@link HttpClient} configuré avec mTLS (keystore client + truststore optionnel).
* Construction lazy + cache instance unique.
*/
public synchronized HttpClient getMtlsHttpClient() throws PaymentException {
if (mtlsClient != null) {
return mtlsClient;
}
try {
SSLContext sslContext = buildSSLContext();
mtlsClient = HttpClient.newBuilder()
.sslContext(sslContext)
.connectTimeout(Duration.ofSeconds(15))
.version(HttpClient.Version.HTTP_2)
.build();
return mtlsClient;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Impossible d'initialiser le client mTLS PI-SPI : " + e.getMessage(), 503, e);
}
}
/**
* Construit le {@link SSLContext} avec keystore client (PKCS12) + truststore optionnel.
* Si truststore absent, utilise le truststore Java par défaut (cacerts JDK).
*/
private SSLContext buildSSLContext() throws Exception {
if (keystorePathOpt.isEmpty() || keystorePasswordOpt.isEmpty()) {
throw new IllegalStateException(
"Keystore PI-SPI non configuré (pispi.api.tls.keystore-path / -password)");
}
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(keystorePathOpt.get())) {
keyStore.load(fis, keystorePasswordOpt.get().toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, keystorePasswordOpt.get().toCharArray());
TrustManagerFactory tmf = null;
if (truststorePathOpt.isPresent() && truststorePasswordOpt.isPresent()) {
KeyStore trustStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(truststorePathOpt.get())) {
trustStore.load(fis, truststorePasswordOpt.get().toCharArray());
}
tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trustStore);
}
SSLContext sslContext = SSLContext.getInstance("TLSv1.3");
sslContext.init(kmf.getKeyManagers(),
tmf != null ? tmf.getTrustManagers() : null,
null);
return sslContext;
}
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")
.header("X-API-Key", apiKey)
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// Le endpoint OAuth2 utilise déjà mTLS — utiliser le client mTLS si configuré,
// sinon le client par défaut (mode dégradé / dev sans certif)
HttpClient client = isConfigured() ? getMtlsHttpClient() : HttpClient.newHttpClient();
HttpResponse<String> response = client.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, mTLS={})",
expiresIn - 60, isConfigured());
return cachedToken;
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur OAuth2 PI-SPI : " + e.getMessage(), 503, e);
}
}
}

View File

@@ -0,0 +1,321 @@
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 dev.lions.unionflow.server.payment.pispi.dto.PispiAlias;
import dev.lions.unionflow.server.payment.pispi.dto.PispiRtpRequest;
import dev.lions.unionflow.server.payment.pispi.dto.PispiRtpResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.json.JsonReader;
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.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
/**
* Client Business API PI-SPI (Plateforme Interopérable Système Paiement Instantané UEMOA).
*
* <p>Endpoints couverts :
*
* <ul>
* <li><strong>POST /transactions/initiate</strong> — initiation paiement pacs.008
* <li><strong>GET /transactions/{id}</strong> — statut transaction pacs.002
* <li><strong>POST /rtp/request</strong> — Request To Pay (pain.013) — appel cotisation
* <li><strong>GET /rtp/{id}</strong> — statut RTP (pain.014)
* <li><strong>POST /aliases</strong> — créer un alias téléphone/email → compte
* <li><strong>GET /aliases/{value}</strong> — résoudre un alias
* <li><strong>DELETE /aliases/{id}</strong> — révoquer un alias
* </ul>
*
* <p>Toutes les requêtes utilisent l'auth 3-facteurs ({@link PispiAuth}) :
*
* <ol>
* <li>Bearer token OAuth2 (header {@code Authorization})
* <li>mTLS avec certif client (configuré sur le {@link HttpClient})
* <li>API Key (header {@code X-API-Key})
* </ol>
*
* @since 2026-04-25 — RTP + alias + auth 3-facteurs ajoutés
*/
@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("");
}
// ================================================================
// Paiements ISO 20022 (pacs.008 / pacs.002)
// ================================================================
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 = baseRequestBuilder(URI.create(baseUrl + "/transactions/initiate"), token)
.header("Content-Type", "application/xml")
.POST(HttpRequest.BodyPublishers.ofString(xmlBody))
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "initiatePayment");
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 = baseRequestBuilder(URI.create(baseUrl + "/transactions/" + transactionId), token)
.GET()
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "getStatus");
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);
}
}
// ================================================================
// Request To Pay (RTP) — appels de cotisation
// ================================================================
/**
* Initie un Request To Pay (pain.013) — la SFD demande un paiement au débiteur. Cas d'usage
* principal : appel de cotisation envoyé en push vers le membre.
*/
public PispiRtpResponse initiateRtp(PispiRtpRequest request) throws PaymentException {
request.validate();
try {
String token = pispiAuth.getAccessToken();
DateTimeFormatter iso = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
String body = Json.createObjectBuilder()
.add("rtpId", request.rtpId())
.add("creditorInstitutionCode", nullSafe(request.creditorInstitutionCode()))
.add("creditorAccountNumber", nullSafe(request.creditorAccountNumber()))
.add("creditorName", nullSafe(request.creditorName()))
.add("debtorAlias", request.debtorAlias())
.add("amount", request.amount())
.add("currency", request.currency())
.add("purpose", nullSafe(request.purpose()))
.add("description", nullSafe(request.description()))
.add("requestedExecutionDate",
request.requestedExecutionDate() != null
? request.requestedExecutionDate().format(iso) : "")
.add("expiryDate",
request.expiryDate() != null ? request.expiryDate().format(iso) : "")
.build()
.toString();
log.debug("PI-SPI initiateRtp rtpId={} amount={}", request.rtpId(), request.amount());
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/rtp/request"), token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "initiateRtp");
return parseRtpResponse(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de l'initiation RTP PI-SPI : " + e.getMessage(), 503, e);
}
}
public PispiRtpResponse getRtpStatus(String rtpId) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/rtp/" + rtpId), token)
.GET()
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "getRtpStatus");
return parseRtpResponse(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de la récupération du statut RTP PI-SPI : " + e.getMessage(), 503, e);
}
}
// ================================================================
// Alias (téléphone/email → compte)
// ================================================================
/** Résout un alias (ex: "+22507XXXXXXXX@unionflow") en informations de compte SFD. */
public Optional<PispiAlias> resolveAlias(String aliasValue) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
String encoded = URLEncoder.encode(aliasValue, StandardCharsets.UTF_8);
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/aliases/" + encoded), token)
.GET()
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 404) {
return Optional.empty();
}
checkStatus(response, "resolveAlias");
return Optional.of(parseAlias(response.body()));
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de la résolution d'alias PI-SPI : " + e.getMessage(), 503, e);
}
}
/** Enregistre un nouvel alias (ex: créer "+225XXX@unionflow" à l'inscription d'un membre). */
public PispiAlias createAlias(PispiAlias alias) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
String body = Json.createObjectBuilder()
.add("aliasType", alias.aliasType())
.add("aliasValue", alias.aliasValue())
.add("institutionCode", nullSafe(alias.institutionCode()))
.add("accountNumber", nullSafe(alias.accountNumber()))
.add("accountHolderName", nullSafe(alias.accountHolderName()))
.build()
.toString();
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/aliases"), token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "createAlias");
return parseAlias(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de la création d'alias PI-SPI : " + e.getMessage(), 503, e);
}
}
/** Révoque un alias (ex: à la radiation d'un membre). */
public void revokeAlias(String aliasId) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/aliases/" + aliasId), token)
.DELETE()
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 204) {
checkStatus(response, "revokeAlias");
}
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de la révocation d'alias PI-SPI : " + e.getMessage(), 503, e);
}
}
// ================================================================
// Helpers
// ================================================================
/** Construit le builder de requête HTTP avec auth 3-facteurs (Bearer + API Key + Institution). */
private HttpRequest.Builder baseRequestBuilder(URI uri, String bearerToken) {
return HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + bearerToken)
.header("X-API-Key", pispiAuth.getApiKey())
.header("X-Institution-Code", institutionCode)
.header("Accept", "application/json, application/xml");
}
/** Retourne le client HTTP à utiliser : mTLS si configuré, sinon par défaut (mode dev). */
private HttpClient httpClient() throws PaymentException {
return pispiAuth.isConfigured() ? pispiAuth.getMtlsHttpClient() : HttpClient.newHttpClient();
}
private void checkStatus(HttpResponse<String> response, String operation) throws PaymentException {
int status = response.statusCode();
if (status >= 400) {
throw new PaymentException("PISPI",
"PI-SPI " + operation + " HTTP " + status + " : " + response.body(), status);
}
}
private PispiAlias parseAlias(String json) {
try (JsonReader reader = Json.createReader(new StringReader(json))) {
JsonObject obj = reader.readObject();
return new PispiAlias(
obj.getString("aliasId", null),
obj.getString("aliasType", null),
obj.getString("aliasValue", null),
obj.getString("institutionCode", null),
obj.getString("accountNumber", null),
obj.getString("accountHolderName", null),
obj.getString("status", null));
}
}
private PispiRtpResponse parseRtpResponse(String json) {
try (JsonReader reader = Json.createReader(new StringReader(json))) {
JsonObject obj = reader.readObject();
String responseAt = obj.getString("responseAt", null);
return new PispiRtpResponse(
obj.getString("rtpId", null),
obj.getString("status", null),
obj.getString("reasonCode", null),
obj.getString("reasonDescription", null),
responseAt != null ? LocalDateTime.parse(responseAt) : null,
obj.getString("settledTransactionId", null));
}
}
private static String nullSafe(String s) {
return s == null ? "" : s;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
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("");
}
/** Readiness helper (P1-NEW-15) — TRUE si webhook secret HMAC est configuré. */
public boolean hasWebhookSecret() {
return webhookSecret != null && !webhookSecret.isEmpty();
}
public boolean isIpAllowed(String ip) {
if (allowedIps == null || allowedIps.isBlank()) {
return true;
}
return Arrays.asList(allowedIps.split(",")).stream()
.map(String::trim)
.anyMatch(allowed -> allowed.equals(ip));
}
public boolean verifySignature(String rawBody, Map<String, String> headers) throws PaymentException {
if (webhookSecret == null || webhookSecret.isBlank()) {
return true;
}
// Recherche insensible à la casse
String receivedSignature = headers.entrySet().stream()
.filter(e -> "X-PISPI-Signature".equalsIgnoreCase(e.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
if (receivedSignature == null) {
throw new PaymentException("PISPI", "Signature PI-SPI absente", 401);
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256"));
String computed = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes()));
if (!MessageDigest.isEqual(computed.getBytes(), receivedSignature.getBytes())) {
throw new PaymentException("PISPI", "Signature PI-SPI invalide", 401);
}
return true;
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de la vérification de signature : " + e.getMessage(), 500, e);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
package dev.lions.unionflow.server.payment.pispi.dto;
/**
* Alias PI-SPI mappant un identifiant lisible (téléphone, email) vers un compte SFD.
*
* <p>Permet aux membres de payer leur cotisation via une adresse simple type {@code
* +22507XXXXXXXX@unionflow} ou {@code cotisation-{orgSlug}@unionflow} sans avoir à connaître les
* détails techniques du compte bénéficiaire.
*
* <p>Référence : {@code https://developer.pispi.bceao.int/guides/alias-gerer}.
*
* @since 2026-04-25
*/
public record PispiAlias(
String aliasId,
String aliasType, // PHONE_NUMBER, EMAIL, NATIONAL_ID, CUSTOM
String aliasValue, // ex: "+22507123456" ou "cotisation-mutuelle-x@unionflow"
String institutionCode, // code BIC/IBAN-like de la SFD bénéficiaire
String accountNumber, // numéro de compte SFD
String accountHolderName,
String status // ACTIVE, PENDING, REVOKED
) {
/** Types d'alias supportés par PI-SPI. */
public static final class Types {
public static final String PHONE_NUMBER = "PHONE_NUMBER";
public static final String EMAIL = "EMAIL";
public static final String NATIONAL_ID = "NATIONAL_ID";
public static final String CUSTOM = "CUSTOM";
private Types() {}
}
}

View File

@@ -0,0 +1,61 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* Request To Pay (RTP) — message ISO 20022 {@code pain.013.001} mappé vers la Business API
* PI-SPI.
*
* <p>Permet à une institution (SFD UnionFlow) d'<strong>initier une demande de paiement</strong>
* vers un membre, plutôt que d'attendre que le membre pousse le paiement. Cas d'usage parfait
* pour les <strong>appels de cotisation</strong> :
*
* <ol>
* <li>La SFD émet un RTP avec le montant et l'échéance ;
* <li>Le membre reçoit la notification dans son app Mobile Money / banque ;
* <li>Il valide ou refuse en un clic ;
* <li>Si validé → flux pacs.008 standard, mais initié par le débiteur sans saisie manuelle.
* </ol>
*
* <p>La réponse est un message {@code pain.014.001} indiquant le statut (ACCEPTED / REFUSED /
* EXPIRED) — modélisé par {@link PispiRtpResponse}.
*
* <p>Référence : {@code https://developer.pispi.bceao.int/guides/rtp-overview}.
*
* @since 2026-04-25
*/
public record PispiRtpRequest(
String rtpId, // identifiant unique de la demande RTP
String creditorInstitutionCode, // SFD UnionFlow (créancier)
String creditorAccountNumber,
String creditorName,
String debtorAlias, // tel/email du débiteur (résolu via l'API alias)
BigDecimal amount, // montant FCFA
String currency, // toujours "XOF" en UEMOA
String purpose, // ex: "COTISATION_OCT_2026"
String description, // ex: "Cotisation mensuelle octobre 2026"
LocalDateTime requestedExecutionDate,
LocalDateTime expiryDate // au-delà : RTP expiré, débiteur ne peut plus accepter
) {
/** Validation minimale avant envoi. */
public void validate() {
if (rtpId == null || rtpId.isBlank()) {
throw new IllegalArgumentException("RTP id manquant");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Montant RTP doit être positif");
}
if (debtorAlias == null || debtorAlias.isBlank()) {
throw new IllegalArgumentException("Alias débiteur manquant");
}
if (currency == null || !"XOF".equals(currency)) {
throw new IllegalArgumentException("Seul XOF est supporté en UEMOA");
}
if (expiryDate != null && requestedExecutionDate != null
&& expiryDate.isBefore(requestedExecutionDate)) {
throw new IllegalArgumentException("Date expiration avant date exécution demandée");
}
}
}

View File

@@ -0,0 +1,35 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import java.time.LocalDateTime;
/**
* Réponse à un Request To Pay (RTP) — message ISO 20022 {@code pain.014.001} mappé vers la
* Business API PI-SPI.
*
* @since 2026-04-25
*/
public record PispiRtpResponse(
String rtpId,
String status, // ACCEPTED, REFUSED, EXPIRED, PENDING
String reasonCode, // code BCEAO si REFUSED (DUPL, FOCR, FRAD, RR01-RR06, etc.)
String reasonDescription,
LocalDateTime responseAt,
String settledTransactionId // si ACCEPTED → ID de la transaction pacs.008 générée
) {
public boolean isAccepted() {
return "ACCEPTED".equals(status);
}
public boolean isRefused() {
return "REFUSED".equals(status);
}
public boolean isPending() {
return "PENDING".equals(status);
}
public boolean isExpired() {
return "EXPIRED".equals(status);
}
}

View File

@@ -0,0 +1,42 @@
package dev.lions.unionflow.server.payment.pispi.readiness;
import dev.lions.unionflow.server.payment.pispi.readiness.PispiReadinessService.ReadinessReport;
import dev.lions.unionflow.server.payment.pispi.readiness.PispiReadinessService.ReadinessStatus;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
/**
* Endpoint d'inspection PI-SPI Readiness — usage ops/compliance avant activation production.
*
* <p>Status HTTP miroir du {@link ReadinessStatus} :
* <ul>
* <li>200 — READY (tout configuré, prêt pour prod)</li>
* <li>200 — DEGRADED (warnings non bloquants — sandbox OK, prod à risque)</li>
* <li>503 — BLOCKED (au moins un blocage critique — sandbox impossible)</li>
* </ul>
*
* @since 2026-04-25 (P1-NEW-15)
*/
@Path("/api/admin/pispi/readiness")
@Produces(MediaType.APPLICATION_JSON)
@Authenticated
public class PispiReadinessResource {
@Inject PispiReadinessService readinessService;
@GET
@RolesAllowed({"SUPER_ADMIN", "COMPLIANCE_OFFICER"})
public Response getReadiness() {
ReadinessReport report = readinessService.verifierReadiness();
int status = report.globalStatus() == ReadinessStatus.BLOCKED
? Response.Status.SERVICE_UNAVAILABLE.getStatusCode()
: Response.Status.OK.getStatusCode();
return Response.status(status).entity(report).build();
}
}

View File

@@ -0,0 +1,226 @@
package dev.lions.unionflow.server.payment.pispi.readiness;
import dev.lions.unionflow.server.entity.Devise;
import dev.lions.unionflow.server.payment.pispi.PispiAuth;
import dev.lions.unionflow.server.payment.pispi.PispiSignatureVerifier;
import dev.lions.unionflow.server.repository.TauxChangeRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
/**
* Service de vérification des pré-requis PI-SPI BCEAO avant activation production.
*
* <p>Permet à l'équipe ops/compliance de valider en un coup d'œil que tous les facteurs
* sont en place avant l'activation de l'intégration PI-SPI sandbox/production.
*
* <p>8 vérifications réalisées :
* <ul>
* <li>OAuth2 client credentials (client_id + client_secret)</li>
* <li>X-API-Key API Business</li>
* <li>mTLS keystore présent (path + password)</li>
* <li>Truststore présent (optionnel mais recommandé)</li>
* <li>Webhook signature secret (HMAC-SHA256)</li>
* <li>Base URL configurée (sandbox vs production)</li>
* <li>Taux de change EUR/XOF récent (≤ 7 jours)</li>
* <li>Provider PI-SPI globalement configuré ({@link PispiAuth#isConfigured()})</li>
* </ul>
*
* @since 2026-04-25 (P1-NEW-15)
*/
@ApplicationScoped
public class PispiReadinessService {
private static final Logger LOG = Logger.getLogger(PispiReadinessService.class);
@Inject PispiAuth pispiAuth;
@Inject PispiSignatureVerifier signatureVerifier;
@Inject TauxChangeRepository tauxChangeRepository;
@ConfigProperty(name = "pispi.api.base-url",
defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
/**
* Exécute tous les checks et retourne un rapport structuré.
*
* @return rapport synthèse + détail par check
*/
public ReadinessReport verifierReadiness() {
List<CheckResult> checks = new ArrayList<>();
checks.add(verifierOAuth2());
checks.add(verifierApiKey());
checks.add(verifierMtlsKeystore());
checks.add(verifierTruststore());
checks.add(verifierWebhookSecret());
checks.add(verifierBaseUrl());
checks.add(verifierTauxEurXof());
checks.add(verifierProviderConfigured());
ReadinessStatus globalStatus = computeGlobalStatus(checks);
List<String> blocking = checks.stream()
.filter(c -> c.severity() == Severity.BLOCKING && c.status() == CheckStatus.FAIL)
.map(c -> c.name() + "" + c.message())
.toList();
List<String> warnings = checks.stream()
.filter(c -> c.severity() == Severity.WARNING && c.status() == CheckStatus.FAIL)
.map(c -> c.name() + "" + c.message())
.toList();
LOG.infof("PI-SPI Readiness : %s — blocking=%d, warnings=%d",
globalStatus, blocking.size(), warnings.size());
return new ReadinessReport(globalStatus, baseUrl, checks, blocking, warnings);
}
// ────────────────────────────────────────────────────────────
// Checks
// ────────────────────────────────────────────────────────────
CheckResult verifierOAuth2() {
boolean ok = pispiAuth.hasClientId() && pispiAuth.hasClientSecret();
return ok
? CheckResult.pass("OAUTH2_CREDENTIALS", Severity.BLOCKING,
"client_id et client_secret configurés")
: CheckResult.fail("OAUTH2_CREDENTIALS", Severity.BLOCKING,
"Manquant : pispi.api.client-id et/ou pispi.api.client-secret");
}
CheckResult verifierApiKey() {
return pispiAuth.hasApiKey()
? CheckResult.pass("API_KEY", Severity.BLOCKING,
"X-API-Key configurée")
: CheckResult.fail("API_KEY", Severity.BLOCKING,
"Manquant : pispi.api.api-key (header X-API-Key obligatoire BCEAO)");
}
CheckResult verifierMtlsKeystore() {
var pathOpt = pispiAuth.keystorePath();
var pwdOpt = pispiAuth.keystorePassword();
if (pathOpt.isEmpty() || pwdOpt.isEmpty()) {
return CheckResult.fail("MTLS_KEYSTORE", Severity.BLOCKING,
"Manquant : pispi.api.tls.keystore-path et/ou keystore-password (PKCS12 client cert)");
}
String path = pathOpt.get();
if (!Files.exists(Path.of(path))) {
return CheckResult.fail("MTLS_KEYSTORE", Severity.BLOCKING,
"Keystore introuvable au chemin : " + path);
}
return CheckResult.pass("MTLS_KEYSTORE", Severity.BLOCKING,
"Keystore PKCS12 présent : " + path);
}
CheckResult verifierTruststore() {
var pathOpt = pispiAuth.truststorePath();
if (pathOpt.isEmpty()) {
return CheckResult.fail("MTLS_TRUSTSTORE", Severity.WARNING,
"Truststore non configuré — fallback sur cacerts JVM (acceptable mais non recommandé)");
}
String path = pathOpt.get();
if (!Files.exists(Path.of(path))) {
return CheckResult.fail("MTLS_TRUSTSTORE", Severity.WARNING,
"Truststore introuvable au chemin : " + path);
}
return CheckResult.pass("MTLS_TRUSTSTORE", Severity.WARNING,
"Truststore présent : " + path);
}
CheckResult verifierWebhookSecret() {
return signatureVerifier.hasWebhookSecret()
? CheckResult.pass("WEBHOOK_SECRET", Severity.BLOCKING,
"Webhook HMAC secret configuré")
: CheckResult.fail("WEBHOOK_SECRET", Severity.BLOCKING,
"Manquant : pispi.webhook.secret (signature HMAC-SHA256 webhooks)");
}
CheckResult verifierBaseUrl() {
if (baseUrl == null || baseUrl.isBlank()) {
return CheckResult.fail("BASE_URL", Severity.BLOCKING,
"Base URL PI-SPI non configurée");
}
boolean isSandbox = baseUrl.contains("sandbox") || baseUrl.contains("dev");
String env = isSandbox ? "SANDBOX" : "PRODUCTION";
return CheckResult.pass("BASE_URL", Severity.WARNING,
"Base URL : " + baseUrl + " (" + env + ")");
}
CheckResult verifierTauxEurXof() {
LocalDate dateLimite = LocalDate.now().minusDays(7);
var taux = tauxChangeRepository.trouverPlusRecent(Devise.EUR, Devise.XOF, LocalDate.now());
if (taux.isEmpty()) {
return CheckResult.fail("TAUX_EUR_XOF", Severity.WARNING,
"Aucun taux EUR/XOF disponible — conversion impossible (parité fixe BCEAO devrait être seedée)");
}
if (taux.get().getDateValidite().isBefore(dateLimite)) {
return CheckResult.fail("TAUX_EUR_XOF", Severity.WARNING,
"Taux EUR/XOF obsolète (" + taux.get().getDateValidite() + ") — synchroniser");
}
return CheckResult.pass("TAUX_EUR_XOF", Severity.WARNING,
"Taux EUR/XOF récent : " + taux.get().getTaux() + " (date " + taux.get().getDateValidite() + ")");
}
CheckResult verifierProviderConfigured() {
return pispiAuth.isConfigured()
? CheckResult.pass("PROVIDER_CONFIGURED", Severity.BLOCKING,
"PispiAuth.isConfigured() = true — provider en mode RÉEL")
: CheckResult.fail("PROVIDER_CONFIGURED", Severity.BLOCKING,
"PispiAuth.isConfigured() = false — provider en mode MOCK (un facteur manque)");
}
// ────────────────────────────────────────────────────────────
// Status global
// ────────────────────────────────────────────────────────────
private ReadinessStatus computeGlobalStatus(List<CheckResult> checks) {
boolean anyBlockingFail = checks.stream()
.anyMatch(c -> c.severity() == Severity.BLOCKING && c.status() == CheckStatus.FAIL);
if (anyBlockingFail) return ReadinessStatus.BLOCKED;
boolean anyWarning = checks.stream()
.anyMatch(c -> c.severity() == Severity.WARNING && c.status() == CheckStatus.FAIL);
if (anyWarning) return ReadinessStatus.DEGRADED;
return ReadinessStatus.READY;
}
// ────────────────────────────────────────────────────────────
// DTOs
// ────────────────────────────────────────────────────────────
public enum ReadinessStatus { READY, DEGRADED, BLOCKED }
public enum CheckStatus { PASS, FAIL }
public enum Severity { BLOCKING, WARNING }
public record ReadinessReport(
ReadinessStatus globalStatus,
String baseUrl,
List<CheckResult> checks,
List<String> blockingIssues,
List<String> warnings
) {}
public record CheckResult(
String name,
CheckStatus status,
Severity severity,
String message
) {
public static CheckResult pass(String name, Severity severity, String message) {
return new CheckResult(name, CheckStatus.PASS, severity, message);
}
public static CheckResult fail(String name, Severity severity, String message) {
return new CheckResult(name, CheckStatus.FAIL, severity, message);
}
}
}

View File

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

View File

@@ -0,0 +1,67 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.AuditTrailOperation;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Repository Panache pour l'audit trail enrichi.
*
* @since 2026-04-25
*/
@ApplicationScoped
public class AuditTrailOperationRepository
implements PanacheRepositoryBase<AuditTrailOperation, UUID> {
/** Opérations d'un utilisateur dans une fenêtre temporelle. */
public List<AuditTrailOperation> findByUserBetween(UUID userId, LocalDateTime from, LocalDateTime to) {
return list("userId = ?1 AND operationAt BETWEEN ?2 AND ?3 ORDER BY operationAt DESC",
userId, from, to);
}
/** Opérations sur une entité spécifique (ex: pour bouton "voir l'historique"). */
public List<AuditTrailOperation> findByEntity(String entityType, UUID entityId) {
return list("entityType = ?1 AND entityId = ?2 ORDER BY operationAt DESC",
entityType, entityId);
}
/** Opérations dans le contexte d'une organisation. */
public List<AuditTrailOperation> findByOrganisationActive(UUID organisationActiveId) {
return list("organisationActiveId = ?1 ORDER BY operationAt DESC", organisationActiveId);
}
/** Violations SoD détectées (alertes compliance officer). */
public List<AuditTrailOperation> findSoDViolations() {
return list("sodCheckPassed = false ORDER BY operationAt DESC");
}
/** Opérations financières (paiements, budgets, écritures comptables) pour reporting AIRMS. */
public List<AuditTrailOperation> findFinancialOperations(UUID organisationId, LocalDateTime from, LocalDateTime to) {
return list(
"organisationActiveId = ?1 AND operationAt BETWEEN ?2 AND ?3 "
+ "AND actionType IN ('PAYMENT_INITIATED', 'PAYMENT_CONFIRMED', 'PAYMENT_FAILED', "
+ "'BUDGET_APPROVED', 'AID_REQUEST_APPROVED') "
+ "ORDER BY operationAt DESC",
organisationId, from, to);
}
/** N opérations les plus récentes — toutes organisations confondues (Live Feed). */
public List<AuditTrailOperation> findRecent(int limit) {
return find("ORDER BY operationAt DESC").page(0, Math.max(1, Math.min(limit, 500))).list();
}
/** N opérations les plus récentes pour une organisation. */
public List<AuditTrailOperation> findRecentByOrganisation(UUID organisationId, int limit) {
return find("organisationActiveId = ?1 ORDER BY operationAt DESC", organisationId)
.page(0, Math.max(1, Math.min(limit, 500))).list();
}
/** N opérations les plus récentes d'un utilisateur. */
public List<AuditTrailOperation> findRecentByUser(UUID userId, int limit) {
return find("userId = ?1 ORDER BY operationAt DESC", userId)
.page(0, Math.max(1, Math.min(limit, 500))).list();
}
}

View File

@@ -0,0 +1,37 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.BeneficiaireEffectif;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/**
* Repository Panache pour les bénéficiaires effectifs (UBO).
*
* @since 2026-04-25
*/
@ApplicationScoped
public class BeneficiaireEffectifRepository
implements PanacheRepositoryBase<BeneficiaireEffectif, UUID> {
/** Bénéficiaires effectifs liés à un dossier KYC. */
public List<BeneficiaireEffectif> findByKycDossier(UUID kycDossierId) {
return list("kycDossier.id", kycDossierId);
}
/** Bénéficiaires effectifs liés à une organisation cible (chaîne de contrôle UBO). */
public List<BeneficiaireEffectif> findByOrganisationCible(UUID organisationCibleId) {
return list("organisationCibleId", organisationCibleId);
}
/** UBOs identifiés comme PEP. */
public List<BeneficiaireEffectif> findPep() {
return list("estPep", true);
}
/** UBOs présents sur des listes de sanctions. */
public List<BeneficiaireEffectif> findOnSanctionsLists() {
return list("presenceListesSanctions", true);
}
}

View File

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

View File

@@ -0,0 +1,43 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.DonRecu;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/** Repository des dons reçus (numéraire / nature / bénévolat / legs). */
@ApplicationScoped
public class DonRecuRepository implements PanacheRepositoryBase<DonRecu, UUID> {
public List<DonRecu> findByOrganisation(UUID organisationId) {
return list("organisationId = ?1 ORDER BY dateDon DESC", organisationId);
}
public List<DonRecu> findByDonateur(UUID donateurId) {
return list("donateur.id = ?1 ORDER BY dateDon DESC", donateurId);
}
public List<DonRecu> findEntre(UUID organisationId, LocalDate from, LocalDate to) {
return list("organisationId = ?1 AND dateDon BETWEEN ?2 AND ?3 ORDER BY dateDon DESC",
organisationId, from, to);
}
/** Total des dons reçus par organisation et période (pour reporting AIRMS / SYCEBNL). */
public BigDecimal totalEntre(UUID organisationId, LocalDate from, LocalDate to) {
Object result = getEntityManager()
.createQuery(
"SELECT COALESCE(SUM(COALESCE(d.montantXof, d.valorisationXof, 0)), 0) "
+ "FROM DonRecu d "
+ "WHERE d.organisationId = :org "
+ "AND d.dateDon BETWEEN :from AND :to "
+ "AND d.actif = true")
.setParameter("org", organisationId)
.setParameter("from", from)
.setParameter("to", to)
.getSingleResult();
return result instanceof BigDecimal bd ? bd : BigDecimal.ZERO;
}
}

View File

@@ -0,0 +1,16 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Donateur;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/** Repository des donateurs (registre obligatoire SYCEBNL). */
@ApplicationScoped
public class DonateurRepository implements PanacheRepositoryBase<Donateur, UUID> {
public List<Donateur> findByOrganisation(UUID organisationId) {
return list("organisationId = ?1 ORDER BY nomPrenoms, raisonSociale", organisationId);
}
}

View File

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

View File

@@ -0,0 +1,21 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.FormationLbcFt;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/** Repository des sessions de formation LBC/FT. */
@ApplicationScoped
public class FormationLbcFtRepository implements PanacheRepositoryBase<FormationLbcFt, UUID> {
public List<FormationLbcFt> findByOrganisationAndAnnee(UUID organisationId, int annee) {
return list("organisationId = ?1 AND anneeReference = ?2 ORDER BY dateSession DESC",
organisationId, annee);
}
public List<FormationLbcFt> findATenir(UUID organisationId) {
return list("organisationId = ?1 AND statut = 'PLANIFIEE' ORDER BY dateSession", organisationId);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ParticipationFormationLbcFt;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Repository des participations aux formations LBC/FT. */
@ApplicationScoped
public class ParticipationFormationLbcFtRepository
implements PanacheRepositoryBase<ParticipationFormationLbcFt, UUID> {
public List<ParticipationFormationLbcFt> findByFormation(UUID formationId) {
return list("formation.id = ?1", formationId);
}
public List<ParticipationFormationLbcFt> findByMembre(UUID membreId) {
return list("membreId = ?1 ORDER BY dateCertification DESC", membreId);
}
/** Vérifie si un membre est certifié pour une année donnée. */
public Optional<ParticipationFormationLbcFt> trouverCertificationAnnee(UUID membreId, int annee) {
return find(
"membreId = ?1 AND formation.anneeReference = ?2 AND statutParticipation = 'CERTIFIE'",
membreId, annee).firstResultOptional();
}
}

View File

@@ -0,0 +1,25 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ProcesVerbal;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/** Repository des procès-verbaux AG/CA. */
@ApplicationScoped
public class ProcesVerbalRepository implements PanacheRepositoryBase<ProcesVerbal, UUID> {
public List<ProcesVerbal> findByOrganisation(UUID organisationId) {
return list("organisationId = ?1 ORDER BY dateSeance DESC", organisationId);
}
public List<ProcesVerbal> findByOrganisationAndType(UUID organisationId, String typeSeance) {
return list("organisationId = ?1 AND typeSeance = ?2 ORDER BY dateSeance DESC",
organisationId, typeSeance);
}
public List<ProcesVerbal> findBrouillons(UUID organisationId) {
return list("organisationId = ?1 AND statut = 'BROUILLON'", organisationId);
}
}

View File

@@ -0,0 +1,28 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.RapportTrimestrielControleurInterne;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Repository des rapports trimestriels Contrôleur Interne. */
@ApplicationScoped
public class RapportTrimestrielControleurInterneRepository
implements PanacheRepositoryBase<RapportTrimestrielControleurInterne, UUID> {
public Optional<RapportTrimestrielControleurInterne> trouverParOrgAnneeTrimestre(
UUID orgId, int annee, int trimestre) {
return find("organisationId = ?1 AND annee = ?2 AND trimestre = ?3",
orgId, annee, trimestre).firstResultOptional();
}
public List<RapportTrimestrielControleurInterne> listerParOrgAnnee(UUID orgId, int annee) {
return list("organisationId = ?1 AND annee = ?2 ORDER BY trimestre", orgId, annee);
}
public List<RapportTrimestrielControleurInterne> listerNonSignes() {
return list("statut = 'DRAFT' ORDER BY dateGeneration");
}
}

View File

@@ -0,0 +1,32 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.RoleDelegation;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/** Repository des délégations temporaires de rôle. */
@ApplicationScoped
public class RoleDelegationRepository implements PanacheRepositoryBase<RoleDelegation, UUID> {
/** Délégations actives reçues par un user dans une organisation à l'instant donné. */
public List<RoleDelegation> findActiveByDelegataire(UUID userId, UUID organisationId,
LocalDateTime now) {
return list(
"delegataireUserId = ?1 AND organisationId = ?2 "
+ "AND statut = 'ACTIVE' AND dateDebut <= ?3 AND dateFin > ?3",
userId, organisationId, now);
}
/** Toutes les délégations expirées encore en statut ACTIVE → à nettoyer par scheduler. */
public List<RoleDelegation> findExpired(LocalDateTime now) {
return list("statut = 'ACTIVE' AND dateFin <= ?1", now);
}
/** Liste paginable / filtrable par organisation pour vue admin (Sprint 10). */
public List<RoleDelegation> findByOrganisation(UUID organisationId) {
return list("organisationId = ?1 ORDER BY dateDebut DESC", organisationId);
}
}

View File

@@ -0,0 +1,27 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Devise;
import dev.lions.unionflow.server.entity.TauxChange;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
/** Repository des taux de change historisés. */
@ApplicationScoped
public class TauxChangeRepository implements PanacheRepositoryBase<TauxChange, UUID> {
/** Taux exact pour une paire à une date donnée. */
public Optional<TauxChange> trouverExact(Devise source, Devise cible, LocalDate date) {
return find("deviseSource = ?1 AND deviseCible = ?2 AND dateValidite = ?3",
source, cible, date).firstResultOptional();
}
/** Taux le plus récent pour une paire (≤ date donnée). */
public Optional<TauxChange> trouverPlusRecent(Devise source, Devise cible, LocalDate dateMax) {
return find("deviseSource = ?1 AND deviseCible = ?2 AND dateValidite <= ?3 "
+ "ORDER BY dateValidite DESC", source, cible, dateMax)
.firstResultOptional();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,155 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse;
import dev.lions.unionflow.server.service.audit.AuditTrailQueryService;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Endpoints REST de lecture de l'audit trail enrichi (Sprint 1, exposé Sprint 10).
*
* <p>Cible : compliance officer / contrôleur interne / SUPER_ADMIN. Seule la lecture
* est exposée — les écritures sont produites automatiquement par les services métier
* via {@code AuditTrailService}.
*
* @since 2026-04-25 (Sprint 10)
*/
@Path("/api/audit-trail")
@Produces(MediaType.APPLICATION_JSON)
@Authenticated
public class AuditTrailOperationResource {
@Inject AuditTrailQueryService queryService;
@Inject dev.lions.unionflow.server.service.audit.AuditTrailExportService exportService;
@GET
@Path("/by-user/{userId}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> parUtilisateur(
@PathParam("userId") UUID userId,
@QueryParam("from") String from,
@QueryParam("to") String to) {
LocalDateTime fromDt = parseDateTime(from, LocalDateTime.now().minusDays(30));
LocalDateTime toDt = parseDateTime(to, LocalDateTime.now());
return queryService.rechercherParUtilisateur(userId, fromDt, toDt);
}
@GET
@Path("/by-entity/{type}/{id}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> historique(
@PathParam("type") String entityType,
@PathParam("id") UUID entityId) {
return queryService.historiqueEntite(entityType, entityId);
}
@GET
@Path("/by-organisation/{orgId}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> parOrganisation(@PathParam("orgId") UUID orgId) {
return queryService.rechercherParOrganisation(orgId);
}
@GET
@Path("/sod-violations")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> violationsSod() {
return queryService.violationsSod();
}
@GET
@Path("/financial/{orgId}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "TRESORIER", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> operationsFinancieres(
@PathParam("orgId") UUID orgId,
@QueryParam("from") String from,
@QueryParam("to") String to) {
LocalDateTime fromDt = parseDateTime(from, LocalDateTime.now().minusDays(90));
LocalDateTime toDt = parseDateTime(to, LocalDateTime.now());
return queryService.operationsFinancieres(orgId, fromDt, toDt);
}
/**
* Live Activity Feed (Sprint 15) — N opérations les plus récentes (transparency).
*
* <p>Scopes :
* <ul>
* <li>ALL — toutes orgs (restreint compliance/contrôleurs/super-admin)</li>
* <li>ORG — organisation indiquée (admin org / président / officers)</li>
* <li>SELF (défaut) — opérations de l'utilisateur indiqué (n'importe quel rôle peut voir les siennes)</li>
* </ul>
*
* <p>Limit clampé à [1, 500] côté repository (sécurité contre DoS).
*/
@GET
@Path("/recent")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION",
"PRESIDENT", "TRESORIER", "MEMBRE", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> recent(
@QueryParam("scope") @jakarta.ws.rs.DefaultValue("SELF") String scope,
@QueryParam("orgId") UUID orgId,
@QueryParam("userId") UUID userId,
@QueryParam("limit") @jakarta.ws.rs.DefaultValue("50") int limit) {
return queryService.listerRecentes(scope, orgId, userId, limit);
}
/**
* Export massif audit (Sprint 16.B) — formats CSV / XLSX / PDF pour BCEAO/ARTCI/CENTIF.
*/
@GET
@Path("/export")
@jakarta.ws.rs.Produces("application/octet-stream")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public jakarta.ws.rs.core.Response export(
@QueryParam("format") @jakarta.ws.rs.DefaultValue("csv") String format,
@QueryParam("scope") @jakarta.ws.rs.DefaultValue("ALL") String scope,
@QueryParam("orgId") UUID orgId,
@QueryParam("userId") UUID userId,
@QueryParam("limit") @jakarta.ws.rs.DefaultValue("500") int limit) {
String fmt = format == null ? "csv" : format.toLowerCase();
byte[] payload;
String mediaType;
String extension;
switch (fmt) {
case "xlsx" -> {
payload = exportService.exportXlsx(scope, orgId, userId, limit);
mediaType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
extension = "xlsx";
}
case "pdf" -> {
payload = exportService.exportPdf(scope, orgId, userId, limit);
mediaType = "application/pdf";
extension = "pdf";
}
default -> {
payload = exportService.exportCsv(scope, orgId, userId, limit);
mediaType = "text/csv";
extension = "csv";
}
}
String filename = String.format("audit-trail-%s-%s.%s",
scope.toLowerCase(), java.time.LocalDate.now(), extension);
return jakarta.ws.rs.core.Response.ok(payload, mediaType)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
}
private LocalDateTime parseDateTime(String input, LocalDateTime fallback) {
if (input == null || input.isBlank()) return fallback;
try {
return LocalDateTime.parse(input);
} catch (Exception e) {
return fallback;
}
}
}

View File

@@ -0,0 +1,83 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.kyc.request.CreateBeneficiaireEffectifRequest;
import dev.lions.unionflow.server.api.dto.kyc.request.UpdateBeneficiaireEffectifRequest;
import dev.lions.unionflow.server.api.dto.kyc.response.BeneficiaireEffectifResponse;
import dev.lions.unionflow.server.service.kyc.BeneficiaireEffectifService;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
/**
* Endpoints REST des Bénéficiaires Effectifs (UBO) — Instr. BCEAO 003-03-2025.
*
* <p>Cette Resource ne fait QUE le mapping HTTP ↔ Service. Toute la logique
* métier (validation pourcentages, audit trail, persistence) est dans
* {@link BeneficiaireEffectifService}.
*
* @since 2026-04-25 (Sprint 10)
*/
@Path("/api/kyc/beneficiaires-effectifs")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Authenticated
public class BeneficiaireEffectifResource {
@Inject BeneficiaireEffectifService service;
@GET
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public List<BeneficiaireEffectifResponse> lister(
@QueryParam("kycDossierId") UUID kycDossierId,
@QueryParam("organisationCibleId") UUID organisationCibleId,
@QueryParam("pep") Boolean pep) {
if (Boolean.TRUE.equals(pep)) return service.listerPep();
if (kycDossierId != null) return service.listerParKycDossier(kycDossierId);
if (organisationCibleId != null) return service.listerParOrganisationCible(organisationCibleId);
return List.of();
}
@GET
@Path("/{id}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public BeneficiaireEffectifResponse trouverParId(@PathParam("id") UUID id) {
return service.trouverParId(id);
}
@POST
@RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public Response creer(@Valid CreateBeneficiaireEffectifRequest request) {
BeneficiaireEffectifResponse created = service.creer(request);
return Response.status(Response.Status.CREATED).entity(created).build();
}
@PUT
@Path("/{id}")
@RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public BeneficiaireEffectifResponse mettreAJour(
@PathParam("id") UUID id, @Valid UpdateBeneficiaireEffectifRequest request) {
return service.mettreAJour(id, request);
}
@DELETE
@Path("/{id}")
@RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public Response desactiver(@PathParam("id") UUID id) {
service.desactiver(id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,42 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.security.OrganisationContextHolder;
import dev.lions.unionflow.server.service.compliance.ComplianceDashboardService;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.UUID;
/** Endpoint du tableau de bord de conformité (P1-NEW-7). */
@Path("/api/compliance/dashboard")
@Authenticated
public class ComplianceDashboardResource {
@Inject ComplianceDashboardService service;
@Inject OrganisationContextHolder context;
@GET
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed({"PRESIDENT", "TRESORIER", "COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE",
"ADMIN_ORGANISATION", "SUPER_ADMIN"})
public ComplianceDashboardService.ComplianceSnapshot snapshotCurrent() {
UUID orgId = context.getOrganisationId();
if (orgId == null) {
throw new IllegalStateException("Aucune organisation active dans le contexte");
}
return service.snapshot(orgId);
}
@GET
@Path("/{organisationId}")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed({"SUPER_ADMIN"})
public ComplianceDashboardService.ComplianceSnapshot snapshotOf(@PathParam("organisationId") UUID orgId) {
return service.snapshot(orgId);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.service.centif.DosCentifService;
import dev.lions.unionflow.server.service.centif.DosCentifService.DosCentifData;
import dev.lions.unionflow.server.service.centif.GoAmlXmlService;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
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 java.io.IOException;
/**
* Endpoint de génération des Déclarations d'Opérations Suspectes (DOS) pour la CENTIF.
*
* <p><strong>Accès restreint</strong> : seul un {@code COMPLIANCE_OFFICER} peut générer une DOS
* (rôle issu de l'Instruction BCEAO 001-03-2025). Les fichiers ne sont jamais persistés sur
* disque — streaming download direct.
*
* <p>L'export est tracé dans {@code audit_trail_operations} (action_type {@code EXPORT}).
*
* @since 2026-04-25 (P0-NEW-16)
*/
@Path("/api/aml/dos")
@Authenticated
public class DosCentifResource {
@Inject DosCentifService dosService;
@Inject GoAmlXmlService goAmlService;
/**
* Génère la DOS au format Word (.docx).
*
* @param data corps JSON {@link DosCentifData}
* @return fichier Word streamé
*/
@POST
@Path("/word")
@Consumes(MediaType.APPLICATION_JSON)
@Produces("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
@RolesAllowed({"COMPLIANCE_OFFICER", "SUPER_ADMIN"})
public Response genererWord(DosCentifData data) throws IOException {
byte[] bytes = dosService.genererDosWord(data);
String filename = "DOS_" + data.numeroDosInterne() + ".docx";
return Response.ok(bytes)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
}
/**
* Génère le relevé d'opérations atypiques au format Excel (.xlsx).
*
* @param data corps JSON {@link DosCentifData}
* @return fichier Excel streamé
*/
@POST
@Path("/excel")
@Consumes(MediaType.APPLICATION_JSON)
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@RolesAllowed({"COMPLIANCE_OFFICER", "SUPER_ADMIN"})
public Response genererExcel(DosCentifData data) throws IOException {
byte[] bytes = dosService.genererReleveExcel(data);
String filename = "Releve_Operations_" + data.numeroDosInterne() + ".xlsx";
return Response.ok(bytes)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
}
/**
* Génère la DOS au format goAML XML (standard ONUDC) — anticipation adoption CI.
* @since P2-NEW-4
*/
@POST
@Path("/goaml")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_XML)
@RolesAllowed({"COMPLIANCE_OFFICER", "SUPER_ADMIN"})
public Response genererGoAml(DosCentifData data) throws IOException {
byte[] bytes = goAmlService.genererXml(data);
String filename = "DOS_goAML_" + data.numeroDosInterne() + ".xml";
return Response.ok(bytes)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
}
}

View File

@@ -0,0 +1,52 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.security.AuditTrailService;
import dev.lions.unionflow.server.service.compliance.KpiShareTokenService;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import java.util.UUID;
/**
* Génération de liens KPI signés (Sprint 17) — admin uniquement.
*
* <p>Crée un token signé HMAC-SHA256 valide pendant {@code ttlSeconds} (défaut 7 jours)
* permettant à une autorité externe de consulter les KPI agrégés sans login.
*
* @since 2026-04-25 (Sprint 17)
*/
@Path("/api/admin/kpi/share-link")
@Produces(MediaType.APPLICATION_JSON)
@Authenticated
public class KpiShareLinkResource {
@Inject KpiShareTokenService tokenService;
@Inject AuditTrailService auditTrail;
@GET
@Path("/{orgId}")
@RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "COMPLIANCE_OFFICER", "SUPER_ADMIN"})
public Map<String, Object> generer(
@PathParam("orgId") UUID orgId,
@QueryParam("ttlSeconds") Long ttlSeconds) {
long ttl = ttlSeconds == null ? KpiShareTokenService.DEFAULT_TTL_SECONDS : ttlSeconds;
String token = tokenService.generer(orgId, ttl);
auditTrail.logSimple("KpiShareLink", orgId, "CREATE",
"Lien KPI public généré (TTL " + ttl + "s)");
return Map.of(
"token", token,
"ttlSeconds", ttl,
"publicUrl", "/api/public/kpi?token=" + token,
"publicWebPath", "/pages/public/kpi?token=" + token);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot;
import dev.lions.unionflow.server.service.compliance.KpiPublicService;
import dev.lions.unionflow.server.service.compliance.KpiShareTokenService;
import io.quarkus.security.PermissionsAllowed;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Endpoint public KPI (Sprint 17) — consommation par autorités externes via token signé.
*
* <p>Pas de @Authenticated : accès anonyme avec token signé HMAC-SHA256. Toute requête
* (succès ou échec) est tracée dans l'audit trail pour transparency.
*
* @since 2026-04-25 (Sprint 17)
*/
@Path("/api/public/kpi")
@Produces(MediaType.APPLICATION_JSON)
@PermitAll
public class PublicKpiResource {
private static final Logger LOG = Logger.getLogger(PublicKpiResource.class);
@Inject KpiShareTokenService tokenService;
@Inject KpiPublicService kpiService;
@GET
public Response consulter(@QueryParam("token") String token) {
if (token == null || token.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(java.util.Map.of("error", "token query param requis"))
.build();
}
var orgIdOpt = tokenService.verifier(token);
if (orgIdOpt.isEmpty()) {
LOG.warnf("PublicKpi: token invalide ou expiré");
return Response.status(Response.Status.UNAUTHORIZED)
.entity(java.util.Map.of("error", "Token invalide ou expiré"))
.build();
}
UUID orgId = orgIdOpt.get();
try {
KpiPublicSnapshot snap = kpiService.snapshotPublic(orgId, "PUBLIC_TOKEN");
return Response.ok(snap).build();
} catch (IllegalArgumentException e) {
LOG.warnf("PublicKpi: org %s introuvable", orgId);
return Response.status(Response.Status.NOT_FOUND)
.entity(java.util.Map.of("error", "Organisation introuvable"))
.build();
} catch (Exception e) {
LOG.errorf(e, "PublicKpi: erreur snapshot org=%s", orgId);
return Response.serverError()
.entity(java.util.Map.of("error", "Erreur interne"))
.build();
}
}
}

View File

@@ -0,0 +1,40 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.service.airms.RapportAirmsService;
import dev.lions.unionflow.server.service.airms.RapportAirmsService.RapportAirmsData;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
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 java.io.IOException;
/**
* Endpoint de génération du rapport AIRMS triple PDF (technique/moral/financier).
*
* @since 2026-04-25 (P1-NEW-1)
*/
@Path("/api/airms/rapports")
@Authenticated
public class RapportAirmsResource {
@Inject RapportAirmsService service;
@POST
@Path("/triple")
@Consumes(MediaType.APPLICATION_JSON)
@Produces("application/pdf")
@RolesAllowed({"PRESIDENT", "TRESORIER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public Response genererTriple(RapportAirmsData data) throws IOException {
byte[] pdf = service.genererRapportTriple(data);
String filename = "Rapport_AIRMS_" + data.organisationDenomination().replaceAll("[^a-zA-Z0-9]", "_")
+ "_" + data.exerciceAnnee() + ".pdf";
return Response.ok(pdf)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
}
}

View File

@@ -0,0 +1,88 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.entity.RapportTrimestrielControleurInterne;
import dev.lions.unionflow.server.repository.RapportTrimestrielControleurInterneRepository;
import dev.lions.unionflow.server.security.OrganisationContextHolder;
import dev.lions.unionflow.server.service.reporting.RapportTrimestrielService;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
/**
* Endpoints du Reporting trimestriel ControleurInterne (P2-NEW-3).
*
* <p>Accès restreint au {@code CONTROLEUR_INTERNE} de l'organisation et au {@code SUPER_ADMIN}.
* La sélection de l'organisation active passe par le filtre {@link OrganisationContextHolder}
* (header {@code X-Active-Organisation-Id}) ou via paramètre {@code orgId} pour SUPER_ADMIN.
*/
@Path("/api/rapports/trimestriel")
@Produces(MediaType.APPLICATION_JSON)
@Authenticated
public class RapportTrimestrielResource {
@Inject RapportTrimestrielService service;
@Inject RapportTrimestrielControleurInterneRepository repository;
@Inject OrganisationContextHolder orgContext;
@GET
@RolesAllowed({"CONTROLEUR_INTERNE", "PRESIDENT", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public List<RapportTrimestrielControleurInterne> lister(
@QueryParam("orgId") UUID orgId,
@QueryParam("annee") Integer annee) {
UUID effectiveOrg = orgId != null ? orgId : orgContext.getOrganisationId();
int effectiveAnnee = annee != null ? annee : java.time.Year.now().getValue();
return repository.listerParOrgAnnee(effectiveOrg, effectiveAnnee);
}
@POST
@Path("/generer")
@RolesAllowed({"CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public RapportTrimestrielControleurInterne generer(
@QueryParam("orgId") UUID orgId,
@QueryParam("annee") int annee,
@QueryParam("trimestre") int trimestre) {
UUID effectiveOrg = orgId != null ? orgId : orgContext.getOrganisationId();
return service.genererRapport(effectiveOrg, annee, trimestre);
}
@POST
@Path("/{id}/signer")
@RolesAllowed({"CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public RapportTrimestrielControleurInterne signer(
@PathParam("id") UUID id, @QueryParam("signataireId") UUID signataireId) {
return service.signer(id, signataireId);
}
@POST
@Path("/{id}/archiver")
@RolesAllowed({"CONTROLEUR_INTERNE", "PRESIDENT", "SUPER_ADMIN"})
public RapportTrimestrielControleurInterne archiver(@PathParam("id") UUID id) {
return service.archiver(id);
}
@GET
@Path("/{id}/pdf")
@Produces("application/pdf")
@RolesAllowed({"CONTROLEUR_INTERNE", "PRESIDENT", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public Response telechargerPdf(@PathParam("id") UUID id) {
RapportTrimestrielControleurInterne r = repository.findById(id);
if (r == null || r.getPdfBytes() == null) {
throw new NotFoundException("Rapport ou PDF introuvable : " + id);
}
String filename = String.format("rapport-trim-%d-T%d.pdf", r.getAnnee(), r.getTrimestre());
return Response.ok(r.getPdfBytes())
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.delegation.request.CreateRoleDelegationRequest;
import dev.lions.unionflow.server.api.dto.delegation.response.RoleDelegationResponse;
import dev.lions.unionflow.server.service.delegation.RoleDelegationService;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Endpoints REST des délégations de rôle (Sprint 10 — service Sprint 2).
*
* <p>La Resource ne fait que mapper HTTP ↔ {@link RoleDelegationService}. La
* logique SoD, validation de dates, audit trail est dans le Service.
*
* <p>Le set des rôles du délégataire est passé via {@code SecurityIdentity} pour
* vérification SoD : le client web/mobile fournit cette info via header (à terme).
*
* @since 2026-04-25 (Sprint 10)
*/
@Path("/api/role-delegations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Authenticated
public class RoleDelegationResource {
@Inject RoleDelegationService service;
@Inject SecurityIdentity securityIdentity;
@GET
@Path("/organisation/{orgId}")
@RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN", "COMPLIANCE_OFFICER"})
public List<RoleDelegationResponse> listerParOrganisation(@PathParam("orgId") UUID orgId) {
return service.listerParOrganisation(orgId);
}
@POST
@RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN"})
public Response creer(
@Valid CreateRoleDelegationRequest request,
@QueryParam("rolesDelegataire") String rolesDelegataireCsv) {
Set<String> rolesDelegataire = parseRoles(rolesDelegataireCsv);
RoleDelegationResponse created = service.creerDepuisRequest(request, rolesDelegataire);
return Response.status(Response.Status.CREATED).entity(created).build();
}
@DELETE
@Path("/{id}")
@RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN"})
public RoleDelegationResponse revoquer(@PathParam("id") UUID id) {
return service.revoquerEtRetourner(id);
}
private Set<String> parseRoles(String csv) {
if (csv == null || csv.isBlank()) return Set.of();
return Set.of(csv.split(","));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
package dev.lions.unionflow.server.scheduler;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.service.reporting.RapportTrimestrielService;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.LocalDate;
import java.time.YearMonth;
import org.jboss.logging.Logger;
/**
* Génération automatique des rapports trimestriels du Contrôleur Interne au début de chaque
* trimestre (1er janvier / avril / juillet / octobre à 02:17), pour le trimestre précédent.
*
* <p>Override possible via configuration : {@code unionflow.reporting.trimestriel.cron}.
*
* @since 2026-04-25 (P2-NEW-3)
*/
@ApplicationScoped
public class RapportTrimestrielScheduler {
private static final Logger LOG = Logger.getLogger(RapportTrimestrielScheduler.class);
@Inject RapportTrimestrielService service;
@Inject OrganisationRepository organisationRepository;
/**
* Cron Quarkus 6-fields : sec min hour dayOfMonth month dayOfWeek.
* 1er jan/avr/jul/oct à 02:17. Minute non ronde pour étaler la charge sur la flotte.
*/
@Scheduled(
cron = "${unionflow.reporting.trimestriel.cron:0 17 2 1 1,4,7,10 ?}",
identity = "rapport-trimestriel-controleur-interne",
concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
@Transactional
void genererRapportsTrimestrePrecedent() {
Trimestre tp = trimestrePrecedent(LocalDate.now());
LOG.infof("[Scheduler] Démarrage génération rapports trimestriels — %d/T%d",
tp.annee, tp.trimestre);
int succes = 0;
int erreurs = 0;
for (Organisation org : organisationRepository.list("actif = true")) {
try {
service.genererRapport(org.getId(), tp.annee, tp.trimestre);
succes++;
} catch (IllegalStateException e) {
// Rapport déjà SIGNE/ARCHIVE — normal en cas de relance
LOG.debugf("Rapport %d/T%d org=%s déjà finalisé : %s",
tp.annee, tp.trimestre, org.getId(), e.getMessage());
} catch (Exception e) {
erreurs++;
LOG.errorf(e, "Échec génération rapport %d/T%d pour org=%s",
tp.annee, tp.trimestre, org.getId());
}
}
LOG.infof("[Scheduler] Rapports trimestriels %d/T%d : %d succès, %d erreurs",
tp.annee, tp.trimestre, succes, erreurs);
}
/** Calcule (année, trimestre) du trimestre précédent à partir d'une date donnée. */
static Trimestre trimestrePrecedent(LocalDate date) {
YearMonth ymPrev = YearMonth.from(date).minusMonths(1);
int trimestre = (ymPrev.getMonthValue() - 1) / 3 + 1;
return new Trimestre(ymPrev.getYear(), trimestre);
}
record Trimestre(int annee, int trimestre) {}
}

View File

@@ -0,0 +1,36 @@
package dev.lions.unionflow.server.security;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Événement CDI émis après chaque écriture audit trail (Sprint 16.C).
*
* <p>Permet aux observers ({@code AuditNotificationService}) de réagir sans coupler le
* service d'écriture aux services de notification. Pattern Observer via {@code @Observes}.
*
* @since 2026-04-25 (Sprint 16.C — transparency)
*/
public record AuditOperationLoggedEvent(
UUID operationId,
UUID userId,
String userEmail,
UUID organisationActiveId,
String actionType,
String entityType,
UUID entityId,
String description,
Boolean sodCheckPassed,
LocalDateTime operationAt
) {
/** Renvoie true si cette opération mérite une notification (sensible). */
public boolean estSensible() {
if (Boolean.FALSE.equals(sodCheckPassed)) return true;
if (actionType == null) return false;
return switch (actionType) {
case "DELETE", "PAYMENT_INITIATED", "PAYMENT_CONFIRMED", "PAYMENT_FAILED",
"BUDGET_APPROVED", "AID_REQUEST_APPROVED", "EXPORT", "VALIDATE" -> true;
default -> false;
};
}
}

View File

@@ -0,0 +1,126 @@
package dev.lions.unionflow.server.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.lions.unionflow.server.entity.AuditTrailOperation;
import dev.lions.unionflow.server.repository.AuditTrailOperationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Service d'audit trail enrichi (SYSCOHADA + AUDSCGIE OHADA + Instruction BCEAO 003-03-2025).
*
* <p>Enregistre toutes les opérations sensibles (financières, lifecycle membres, configurations)
* avec leur contexte multi-org complet (rôle actif, organisation active, vérifications SoD).
*
* <p>Usage typique dans un service métier :
*
* <pre>{@code
* @Inject AuditTrailService auditTrail;
*
* public Cotisation enregistrerPaiement(...) {
* Cotisation c = ...
* auditTrail.log("Cotisation", c.getId(), "PAYMENT_CONFIRMED",
* "Paiement confirmé via " + provider, c);
* return c;
* }
* }</pre>
*
* @since 2026-04-25
*/
@ApplicationScoped
public class AuditTrailService {
private static final Logger LOG = Logger.getLogger(AuditTrailService.class);
@Inject AuditTrailOperationRepository repository;
@Inject OrganisationContextHolder context;
@Inject io.micrometer.core.instrument.MeterRegistry registry;
@Inject jakarta.enterprise.event.Event<AuditOperationLoggedEvent> auditEvent;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Enregistre une entrée d'audit trail à partir du contexte courant.
*
* @param entityType nom de l'entité (ex: "Cotisation", "Membre", "EcritureComptable")
* @param entityId UUID de l'entité ciblée (peut être null pour actions globales)
* @param actionType type d'action (cf. CHECK SQL : CREATE, UPDATE, DELETE, APPROVE, ...)
* @param description description libre courte (≤ 500 caractères)
* @param payloadApres entité après modification (sérialisée JSON, peut être null)
*/
@Transactional
public void log(String entityType, UUID entityId, String actionType, String description,
Object payloadApres) {
log(entityType, entityId, actionType, description, null, payloadApres, null, null, null);
}
/**
* Enregistre une entrée d'audit trail avec snapshot avant/après et résultat SoD.
*/
@Transactional
public void log(String entityType, UUID entityId, String actionType, String description,
Object payloadAvant, Object payloadApres, Object metadata,
Boolean sodCheckPassed, String sodViolations) {
try {
AuditTrailOperation entry = AuditTrailOperation.builder()
.userId(context.getCurrentUserId())
.userEmail(context.getCurrentUserEmail())
.roleActif(context.getRoleActif())
.organisationActiveId(context.getOrganisationId())
.actionType(actionType)
.entityType(entityType)
.entityId(entityId)
.description(description)
.payloadAvant(toJson(payloadAvant))
.payloadApres(toJson(payloadApres))
.metadata(toJson(metadata))
.sodCheckPassed(sodCheckPassed)
.sodViolations(sodViolations)
.operationAt(LocalDateTime.now())
.build();
repository.persist(entry);
// Sprint 16.A — Métriques Prometheus métier (transparency)
registry.counter("unionflow.audit.operations",
"action", actionType == null ? "UNKNOWN" : actionType,
"entity", entityType == null ? "UNKNOWN" : entityType).increment();
if (Boolean.FALSE.equals(sodCheckPassed)) {
registry.counter("unionflow.audit.sod_violations",
"entity", entityType == null ? "UNKNOWN" : entityType).increment();
}
// Sprint 16.C — Fire CDI event pour observers (notifications, etc.)
auditEvent.fire(new AuditOperationLoggedEvent(
entry.getId(), entry.getUserId(), entry.getUserEmail(),
entry.getOrganisationActiveId(), actionType, entityType, entityId,
description, sodCheckPassed, entry.getOperationAt()));
} catch (Exception e) {
// Fail-soft : l'audit trail ne doit jamais bloquer une opération métier.
// Les violations sont loguées et peuvent être détectées via les logs applicatifs.
LOG.errorf(e,
"Audit trail log failed: entityType=%s entityId=%s actionType=%s description=%s",
entityType, entityId, actionType, description);
}
}
/** Variante sans payload — pour les actions simples (LOGIN, LOGOUT, EXPORT...). */
@Transactional
public void logSimple(String entityType, UUID entityId, String actionType, String description) {
log(entityType, entityId, actionType, description, null);
}
private String toJson(Object o) {
if (o == null) return null;
try {
return objectMapper.writeValueAsString(o);
} catch (Exception e) {
LOG.warnf("Audit trail JSON serialization failed for %s : %s",
o.getClass().getSimpleName(), e.getMessage());
return null;
}
}
}

View File

@@ -28,6 +28,39 @@ public class OrganisationContextHolder {
private Organisation organisation; private Organisation organisation;
private boolean resolved = false; private boolean resolved = false;
/** Rôle actif sélectionné par le user pour cette requête (header X-Active-Role). */
private String roleActif;
/** UUID de l'utilisateur courant (sub du JWT). */
private UUID currentUserId;
/** Email de l'utilisateur courant (claim email du JWT). */
private String currentUserEmail;
public String getRoleActif() {
return roleActif;
}
public void setRoleActif(String roleActif) {
this.roleActif = roleActif;
}
public UUID getCurrentUserId() {
return currentUserId;
}
public void setCurrentUserId(UUID currentUserId) {
this.currentUserId = currentUserId;
}
public String getCurrentUserEmail() {
return currentUserEmail;
}
public void setCurrentUserEmail(String currentUserEmail) {
this.currentUserEmail = currentUserEmail;
}
public UUID getOrganisationId() { public UUID getOrganisationId() {
return organisationId; return organisationId;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,9 @@ public final class RoleConstant {
public static final String SECRETAIRE = "SECRETAIRE"; public static final String SECRETAIRE = "SECRETAIRE";
public static final String TRESORIER = "TRESORIER"; public static final String TRESORIER = "TRESORIER";
public static final String MODERATEUR = "MODERATEUR"; public static final String MODERATEUR = "MODERATEUR";
public static final String CONTROLEUR_INTERNE = "CONTROLEUR_INTERNE";
public static final String COMPLIANCE_OFFICER = "COMPLIANCE_OFFICER";
public static final String COMMISSAIRE_COMPTES = "COMMISSAIRE_COMPTES";
// ── Rôles membres ───────────────────────────────────────────────────────── // ── Rôles membres ─────────────────────────────────────────────────────────
public static final String MEMBRE = "MEMBRE"; public static final String MEMBRE = "MEMBRE";

View File

@@ -0,0 +1,150 @@
package dev.lions.unionflow.server.security;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Set;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Vérificateur de séparation des pouvoirs (Separation of Duties — SoD).
*
* <p>Implémente les règles SoD exigées par :
*
* <ul>
* <li><strong>SYSCOHADA / AUDCIF</strong> — traçabilité écritures comptables (chaque opération
* = pièce datée identifiable conservée), contrôle interne obligatoire ;
* <li><strong>AUDSCGIE OHADA</strong> — séparation CA décide / gérant exécute / Commissaire aux
* comptes contrôle ;
* <li><strong>BCEAO Circulaire 03-2017/CB/C</strong> — contrôle interne SFD UMOA (gouvernance,
* gestion risques, conformité, audit interne) ;
* <li><strong>Instruction BCEAO 001-03-2025</strong> — Compliance Officer rattaché DG, distinct
* trésorier/président.
* </ul>
*
* <p>Les règles principales :
*
* <ol>
* <li>Un même utilisateur ne peut pas <strong>créer une dépense ET la valider</strong> (4-eyes
* principle pour engagement → ordonnancement → paiement).
* <li>Le <strong>Compliance Officer</strong> ne peut pas être simultanément trésorier ou
* président.
* <li>Un <strong>contrôleur interne</strong> ne peut pas être engagé dans des décisions
* opérationnelles qu'il devra contrôler.
* <li>Un membre exclu/radié ne peut plus exercer aucun rôle.
* </ol>
*
* @since 2026-04-25
*/
@ApplicationScoped
public class SoDPermissionChecker {
private static final Logger LOG = Logger.getLogger(SoDPermissionChecker.class);
@Inject AuditTrailService auditTrail;
/**
* Vérifie qu'un utilisateur peut valider une opération qu'un autre a créée.
*
* <p>Règle fondamentale : <strong>celui qui crée ne peut pas valider</strong> (4-eyes).
*
* @param creatorUserId UUID du créateur de l'opération (de la dépense, du paiement, etc.)
* @param validatorUserId UUID de l'utilisateur qui tente de valider
* @param entityType nom de l'entité (pour audit trail)
* @param entityId UUID de l'entité ciblée
* @return {@link SoDCheckResult#PASS} si la validation est autorisée ; sinon {@link
* SoDCheckResult#VIOLATION}
*/
public SoDCheckResult checkValidationDistinct(UUID creatorUserId, UUID validatorUserId,
String entityType, UUID entityId) {
if (creatorUserId == null || validatorUserId == null) {
return SoDCheckResult.PASS; // pas de contexte = on laisse passer (audit doit alerter)
}
if (creatorUserId.equals(validatorUserId)) {
String violation = String.format(
"SoD VIOLATION: même utilisateur (%s) a créé ET valide cette opération sur %s/%s",
validatorUserId, entityType, entityId);
LOG.warn(violation);
auditTrail.log(entityType, entityId, "SOD_OVERRIDE",
"Tentative de validation par le créateur",
null, null, null, false, violation);
return new SoDCheckResult(false, violation);
}
return SoDCheckResult.PASS;
}
/**
* Vérifie qu'un utilisateur n'a pas une combinaison de rôles incompatibles.
*
* <p>Combinaisons interdites par défaut :
*
* <ul>
* <li>{@code TRESORIER} + {@code PRESIDENT} (cumul interdit pour engagements financiers)
* <li>{@code TRESORIER} + {@code CONTROLEUR_INTERNE} (auto-contrôle impossible)
* <li>{@code PRESIDENT} + {@code CONTROLEUR_INTERNE} (juge et partie)
* <li>{@code COMMISSAIRE_COMPTES} + tout autre rôle interne (indépendance OHADA)
* </ul>
*
* @param userId UUID de l'utilisateur
* @param userRoles ensemble des rôles actuels de l'utilisateur dans l'organisation active
* @return résultat de la vérification
*/
public SoDCheckResult checkRoleCombination(UUID userId, Set<String> userRoles) {
if (userRoles == null || userRoles.size() <= 1) {
return SoDCheckResult.PASS;
}
// Conflit Commissaire aux comptes (indépendance absolue OHADA)
if (userRoles.contains("COMMISSAIRE_COMPTES") && userRoles.size() > 1) {
String violation = "SoD: COMMISSAIRE_COMPTES doit être indépendant (aucun cumul) — user " + userId;
return new SoDCheckResult(false, violation);
}
// Conflits trésorier
if (userRoles.contains("TRESORIER") && userRoles.contains("PRESIDENT")) {
String violation = "SoD: cumul TRESORIER + PRESIDENT interdit (engagement + ordonnancement) — user " + userId;
return new SoDCheckResult(false, violation);
}
if (userRoles.contains("TRESORIER") && userRoles.contains("CONTROLEUR_INTERNE")) {
String violation = "SoD: cumul TRESORIER + CONTROLEUR_INTERNE interdit (auto-contrôle) — user " + userId;
return new SoDCheckResult(false, violation);
}
if (userRoles.contains("PRESIDENT") && userRoles.contains("CONTROLEUR_INTERNE")) {
String violation = "SoD: cumul PRESIDENT + CONTROLEUR_INTERNE interdit (juge et partie) — user " + userId;
return new SoDCheckResult(false, violation);
}
return SoDCheckResult.PASS;
}
/**
* Vérifie que le Compliance Officer désigné n'est pas en conflit (Instruction BCEAO
* 001-03-2025 : rattaché DG, distinct du trésorier).
*/
public SoDCheckResult checkComplianceOfficerEligibility(UUID complianceOfficerId, Set<String> userRoles) {
if (complianceOfficerId == null || userRoles == null) {
return SoDCheckResult.PASS;
}
if (userRoles.contains("TRESORIER")) {
return new SoDCheckResult(false,
"SoD: Compliance Officer ne peut pas cumuler le rôle TRESORIER (Instruction BCEAO 001-03-2025)");
}
if (userRoles.contains("COMMISSAIRE_COMPTES")) {
return new SoDCheckResult(false,
"SoD: Compliance Officer ne peut pas cumuler COMMISSAIRE_COMPTES (indépendance)");
}
return SoDCheckResult.PASS;
}
/** Résultat d'un check SoD : pass ou violation avec motif. */
public record SoDCheckResult(boolean passed, String violationReason) {
public static final SoDCheckResult PASS = new SoDCheckResult(true, null);
public static final SoDCheckResult VIOLATION =
new SoDCheckResult(false, "Violation SoD générique");
public boolean isViolation() {
return !passed;
}
}
}

View File

@@ -82,12 +82,15 @@ public class AlertMonitoringService {
*/ */
private void checkCpuThreshold(AlertConfiguration config) { private void checkCpuThreshold(AlertConfiguration config) {
try { try {
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); // getProcessCpuLoad() renvoie la charge CPU de CE process JVM (0.0-1.0),
double loadAvg = osBean.getSystemLoadAverage(); // ce qui est correct en conteneur K8s/Docker.
int processors = osBean.getAvailableProcessors(); // getSystemLoadAverage() renvoie la charge du NODE entier (hôte Linux),
// divisée par availableProcessors() limité par le conteneur (ex: 1),
// Calculer l'utilisation CPU en pourcentage // ce qui produit des faux positifs dès que le node est actif.
double cpuUsage = loadAvg < 0 ? 0.0 : Math.min(100.0, (loadAvg / processors) * 100.0); com.sun.management.OperatingSystemMXBean osBean =
(com.sun.management.OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
double processCpuLoad = osBean.getProcessCpuLoad();
double cpuUsage = processCpuLoad < 0 ? 0.0 : Math.min(100.0, processCpuLoad * 100.0);
lastCpuUsage = cpuUsage; lastCpuUsage = cpuUsage;
int threshold = config.getCpuThresholdPercent(); int threshold = config.getCpuThresholdPercent();

View File

@@ -0,0 +1,93 @@
package dev.lions.unionflow.server.service;
import java.math.BigDecimal;
/**
* Seuils AML/LBC-FT applicables aux transactions UEMOA (BCEAO Instruction 002-03-2025 du 18 mars
* 2025).
*
* <p>Ces seuils déclenchent :
*
* <ul>
* <li>Diligence renforcée KYC (vérification UBO, source des fonds, finalité)
* <li>Génération automatique d'une {@code AlerteAml} pour évaluation par le Compliance Officer
* <li>Potentiellement, une déclaration de soupçon (DOS) à la CENTIF si confirmation
* </ul>
*
* <p>Référence : <a
* href="https://www.bceao.int/sites/default/files/2025-04/Instruction%20n%C2%B0001-03-2025%20du%2018%20mars%2025%20portant%20modalit%C3%A9s%20de%20mise%20en%20oeuvre%20par%20les%20IF%20de%20leurs%20obligations%20en%20mati%C3%A8re%20de%20lutte%20contre%20le%20blanchiment%20de%20capitaux.pdf">Instruction
* BCEAO 001-03-2025</a>.
*
* @since 2026-04-25
*/
public final class AmlSeuils {
private AmlSeuils() {}
/**
* Seuil intra-UEMOA (entre deux pays UMOA) au-delà duquel la transaction est soumise à
* surveillance et obligations de déclaration : <strong>10 000 000 FCFA</strong>.
*
* <p>Source : Instruction BCEAO 002-03-2025 du 18 mars 2025.
*/
public static final BigDecimal SEUIL_INTRA_UEMOA_FCFA = new BigDecimal("10000000");
/**
* Seuil entrée/sortie territoire UEMOA (transaction transfrontalière hors UEMOA) déclenchant
* surveillance renforcée : <strong>5 000 000 FCFA</strong>.
*
* <p>Source : Instruction BCEAO 002-03-2025 du 18 mars 2025.
*/
public static final BigDecimal SEUIL_ENTREE_SORTIE_UEMOA_FCFA = new BigDecimal("5000000");
/**
* Seuil unique par opération espèce (paiement direct cash) déclenchant identification
* renforcée : <strong>1 000 000 FCFA</strong>. Dérivé de la pratique GAFI (USD 15 000
* équivalent).
*/
public static final BigDecimal SEUIL_OPERATION_ESPECE_FCFA = new BigDecimal("1000000");
/**
* Seuil cumulé sur 7 jours glissants pour détection de structuration (smurfing) :
* <strong>5 000 000 FCFA</strong> (8 fois le seuil unique). Configurable.
*/
public static final BigDecimal SEUIL_CUMUL_HEBDO_STRUCTURATION_FCFA = new BigDecimal("5000000");
/** Pays UEMOA (ISO 3166-1 alpha-3). */
public static final java.util.Set<String> PAYS_UEMOA = java.util.Set.of(
"BEN", // Bénin
"BFA", // Burkina Faso
"CIV", // Côte d'Ivoire
"GNB", // Guinée-Bissau
"MLI", // Mali
"NER", // Niger
"SEN", // Sénégal
"TGO" // Togo
);
/** Détermine le seuil applicable à une transaction selon l'origine et la destination. */
public static BigDecimal seuilApplicable(String paysOrigine, String paysDestination) {
if (paysOrigine == null || paysDestination == null) {
return SEUIL_ENTREE_SORTIE_UEMOA_FCFA; // par défaut le plus restrictif
}
boolean origineUemoa = PAYS_UEMOA.contains(paysOrigine.toUpperCase());
boolean destinationUemoa = PAYS_UEMOA.contains(paysDestination.toUpperCase());
if (origineUemoa && destinationUemoa) {
return SEUIL_INTRA_UEMOA_FCFA;
}
return SEUIL_ENTREE_SORTIE_UEMOA_FCFA;
}
/** True si la transaction dépasse le seuil applicable. */
public static boolean depasseSeuil(BigDecimal montant, String paysOrigine, String paysDestination) {
if (montant == null) return false;
return montant.compareTo(seuilApplicable(paysOrigine, paysDestination)) > 0;
}
/** True si la transaction dépasse le seuil opération espèce. */
public static boolean depasseSeuilEspece(BigDecimal montant) {
if (montant == null) return false;
return montant.compareTo(SEUIL_OPERATION_ESPECE_FCFA) > 0;
}
}

View File

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

View File

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

View File

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

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