Compare commits

..

6 Commits

Author SHA1 Message Date
34056b7c03 fix: 3 tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 2m46s
- DashboardWebSocketEndpoint: null-check on onMessage to avoid NPE when client sends null payload
- ConversationParticipantTest.equalsHashCode: share Conversation+Membre instances (random UUIDs inside newConversation/newMembre were defeating equals)
- MessageTest.equalsHashCode: same fix pattern
2026-04-23 13:01:50 +00:00
e503c6c0a1 fix: empty parent.relativePath to allow standalone clones (CI builds from registry)
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m7s
2026-04-23 11:24:53 +00:00
2c1c3b6e40 chore: bump unionflow-server-api to 1.0.6 (adds ville/pays to OrganisationSummaryResponse)
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m27s
2026-04-23 02:19:26 +00:00
9ab825c5e0 ci: use lionsctl-ci image; drop actions/checkout dependency
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m55s
2026-04-22 21:38:54 +00:00
2b48bc18b3 ci: enable lionsctl pipeline via lionsctl-ci image
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 34s
2026-04-22 19:42:43 +00:00
dahoud
00602d963b ci: ajouter workflow Gitea Actions (lionsctl pipeline auto-deploy sur push main)
Some checks failed
CI/CD Lions Pipeline / Build + Push + Deploy (push) Failing after 22s
2026-04-22 16:00:51 +00:00
472 changed files with 37332 additions and 70632 deletions

View File

@@ -1,22 +0,0 @@
# 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
![Java](https://img.shields.io/badge/Java-17-blue)
![Quarkus](https://img.shields.io/badge/Quarkus-3.27.3_LTS-red)
![Quarkus](https://img.shields.io/badge/Quarkus-3.15.1-red)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-blue)
![Kafka](https://img.shields.io/badge/Kafka-Enabled-orange)
![License](https://img.shields.io/badge/License-Proprietary-red)
@@ -64,7 +64,7 @@ Tous les repositories étendent `PanacheRepositoryBase<Entity, UUID>` pour :
| Composant | Version | Usage |
|-----------|---------|-------|
| **Java** | 17 (LTS) | Langage |
| **Quarkus** | 3.27.3 LTS | Framework application |
| **Quarkus** | 3.15.1 | Framework application |
| **Hibernate ORM (Panache)** | 6.4+ | Persistence |
| **PostgreSQL** | 15 | Base de données |
| **Flyway** | 9.22+ | Migrations DB |
@@ -482,7 +482,7 @@ src/test/java/
lionsctl pipeline \
-u https://git.lions.dev/lionsdev/unionflow-server-impl-quarkus \
-b main \
-j 21 \
-j 17 \
-e production \
-c k1 \
-p prod
@@ -490,19 +490,12 @@ lionsctl pipeline \
# Étapes :
# 1. Clone repo Git
# 2. mvn clean package -Pprod
# 3. docker build -f Dockerfile (racine, fast-jar, ubi8/openjdk-21:1.21, UID 1001)
# 4. push registry.lions.dev
# 5. kubectl apply (Deployment + Service + Ingress)
# 6. Health check
# 7. Email notification
# 3. docker build + push registry.lions.dev
# 4. kubectl apply -f k8s/
# 5. Health check
# 6. 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
**Localisation** : `src/main/kubernetes/`

View File

@@ -1,73 +0,0 @@
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

36
pom.xml
View File

@@ -4,9 +4,14 @@
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>
<groupId>dev.lions.unionflow</groupId>
<parent>
<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>
<version>1.0.7</version>
<packaging>jar</packaging>
<name>UnionFlow Server Implementation (Quarkus)</name>
@@ -18,13 +23,9 @@
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<quarkus.platform.version>3.27.3</quarkus.platform.version>
<quarkus.platform.version>3.20.0</quarkus.platform.version>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.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.version>0.8.12</jacoco.version>
@@ -39,20 +40,6 @@
<type>pom</type>
<scope>import</scope>
</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>
</dependencyManagement>
@@ -61,14 +48,14 @@
<dependency>
<groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-server-api</artifactId>
<version>1.0.10</version>
<version>1.0.6</version>
</dependency>
<!-- Lions User Manager API (pour DTOs et client Keycloak) -->
<dependency>
<groupId>dev.lions.user.manager</groupId>
<artifactId>lions-user-manager-server-api</artifactId>
<version>1.1.0</version>
<version>1.0.0</version>
</dependency>
<!-- Quarkus Core -->
@@ -334,10 +321,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<!-- Quarkus Qute @CheckedTemplate exige les noms de paramètres en bytecode -->
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<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.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
@@ -20,15 +20,9 @@ import java.io.IOException;
* qui utilisent AdminServiceTokenHeadersFactory (service account). Le filtre global
* écraserait le token de service account avec le JWT utilisateur → 401 sur LUM.
*
* <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}
* <p>La propagation JWT est assurée par {@link OidcTokenPropagationHeadersFactory}
* sur les clients qui en ont besoin ({@code @RegisterClientHeaders}).
*/
@ApplicationScoped
public class JwtPropagationFilter implements ClientRequestFilter {
private static final Logger LOG = Logger.getLogger(JwtPropagationFilter.class);

View File

@@ -1,104 +0,0 @@
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

@@ -1,168 +0,0 @@
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) */
@NotNull
@Min(value = 1, message = "La classe comptable doit être entre 1 et 9")
@Max(value = 9, message = "La classe comptable doit être entre 1 et 9")
@Min(value = 1, message = "La classe comptable doit être entre 1 et 7")
@Max(value = 7, message = "La classe comptable doit être entre 1 et 7")
@Column(name = "classe_comptable", nullable = false)
private Integer classeComptable;
@@ -85,11 +85,6 @@ public class CompteComptable extends BaseEntity {
@Column(name = "description", length = 500)
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 */
@JsonIgnore
@OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY)

View File

@@ -76,53 +76,6 @@ public class DemandeAide extends BaseEntity {
@Column(name = "documents_fournis")
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
protected void onCreate() {
super.onCreate(); // Appelle le onCreate de BaseEntity

View File

@@ -1,58 +0,0 @@
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

@@ -1,86 +0,0 @@
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

@@ -1,53 +0,0 @@
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

@@ -1,75 +0,0 @@
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,10 +110,6 @@ public class FormuleAbonnement extends BaseEntity {
@Column(name = "max_admins")
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() {
return maxMembres == null;
}

View File

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

View File

@@ -1,135 +0,0 @@
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,52 +59,6 @@ public class Membre extends BaseEntity {
@Column(name = "telephone", length = 20)
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)")
@Column(name = "telephone_wave", length = 20)
private String telephoneWave;

View File

@@ -8,7 +8,6 @@ import java.time.LocalDate;
import java.time.Period;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -202,38 +201,10 @@ public class Organisation extends BaseEntity {
@Column(name = "categorie_type", length = 50)
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") */
@Column(name = "modules_actifs", length = 1000)
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
/** Adhésions des membres à cette organisation */

View File

@@ -1,56 +0,0 @@
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

@@ -1,144 +0,0 @@
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

@@ -1,97 +0,0 @@
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

@@ -1,68 +0,0 @@
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

@@ -1,77 +0,0 @@
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

@@ -1,62 +0,0 @@
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

@@ -1,68 +0,0 @@
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

@@ -1,78 +0,0 @@
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

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

View File

@@ -1,15 +0,0 @@
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

@@ -1,15 +0,0 @@
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

@@ -1,71 +0,0 @@
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

@@ -1,73 +0,0 @@
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

@@ -1,93 +0,0 @@
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

@@ -1,47 +0,0 @@
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

@@ -1,240 +0,0 @@
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

@@ -1,321 +0,0 @@
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

@@ -1,70 +0,0 @@
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

@@ -1,114 +0,0 @@
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

@@ -1,78 +0,0 @@
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

@@ -1,71 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,96 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -1,61 +0,0 @@
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

@@ -1,35 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -1,226 +0,0 @@
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

@@ -1,140 +0,0 @@
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

@@ -1,67 +0,0 @@
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

@@ -1,37 +0,0 @@
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,30 +76,6 @@ public class CompteComptableRepository implements PanacheRepositoryBase<CompteCo
return find("typeCompte = ?1 AND actif = true ORDER BY numeroCompte ASC", TypeCompteComptable.TRESORERIE)
.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

@@ -1,43 +0,0 @@
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

@@ -1,16 +0,0 @@
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,20 +105,6 @@ public class EcritureComptableRepository implements PanacheRepositoryBase<Ecritu
public List<EcritureComptable> findByLettrage(String lettrage) {
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

@@ -1,21 +0,0 @@
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,15 +79,6 @@ public class JournalComptableRepository implements PanacheRepositoryBase<Journal
public List<JournalComptable> findAllActifs() {
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

@@ -1,52 +0,0 @@
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,13 +85,6 @@ public class MembreOrganisationRepository extends BaseRepository<MembreOrganisat
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.
*/

View File

@@ -1,29 +0,0 @@
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

@@ -1,25 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -1,27 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,34 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,64 +0,0 @@
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

@@ -1,155 +0,0 @@
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

@@ -1,83 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -1,98 +0,0 @@
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,7 +1,6 @@
package dev.lions.unionflow.server.resource;
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.MembreOrganisation;
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
@@ -68,59 +67,6 @@ public class CompteAdherentResource {
@Inject
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é :
* numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement.
@@ -192,17 +138,15 @@ public class CompteAdherentResource {
}
}
// Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a reçu un paiement.
// 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.
// Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a souscription active
// (membres sans premiereConnexion=true ou créés avant cette logique)
if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) {
Membre m = membreOpt.get();
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
.map(mo -> mo.getOrganisation().getId())
.orElse(null);
if (membreService.orgHasPaidSubscription(orgId)) {
LOG.infof("Auto-activation au login de %s (org %s a souscription payée)", m.getEmail(), orgId);
if (membreService.orgHasActiveSubscription(orgId)) {
LOG.infof("Auto-activation au login de %s (org %s a souscription active)", m.getEmail(), orgId);
membreService.activerMembre(m.getId());
try {
membreKeycloakSyncService.activerMembreDansKeycloak(m.getId());

View File

@@ -1,89 +0,0 @@
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

@@ -1,52 +0,0 @@
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

@@ -1,111 +0,0 @@
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,7 +11,6 @@ import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.MembreOrganisation;
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.service.MemberLifecycleService;
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
@@ -79,9 +78,6 @@ public class MembreResource {
@Inject
MembreOrganisationRepository membreOrgRepository;
@Inject
MembreRepository membreRepository;
@Inject
MembreRoleRepository membreRoleRepository;
@@ -451,40 +447,6 @@ 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).
* Utilisé pour la création de campagnes ciblées.
@@ -626,7 +588,7 @@ public class MembreResource {
@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 = """
{
"membres": [],
"membres": [...],
"totalElements": 247,
"totalPages": 13,
"currentPage": 0,

View File

@@ -1,139 +0,0 @@
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

@@ -1,67 +0,0 @@
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

@@ -1,40 +0,0 @@
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

@@ -1,88 +0,0 @@
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

@@ -1,73 +0,0 @@
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

@@ -1,52 +0,0 @@
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

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

View File

@@ -1,73 +0,0 @@
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

@@ -1,72 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,126 +0,0 @@
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,39 +28,6 @@ public class OrganisationContextHolder {
private Organisation organisation;
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() {
return organisationId;
}

View File

@@ -1,116 +0,0 @@
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

@@ -1,77 +0,0 @@
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

@@ -1,73 +0,0 @@
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

@@ -1,26 +0,0 @@
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,9 +19,6 @@ public final class RoleConstant {
public static final String SECRETAIRE = "SECRETAIRE";
public static final String TRESORIER = "TRESORIER";
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 ─────────────────────────────────────────────────────────
public static final String MEMBRE = "MEMBRE";

View File

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

View File

@@ -1,93 +0,0 @@
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,25 +87,6 @@ public class AuditService {
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
*/

View File

@@ -1,435 +0,0 @@
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,9 +2,7 @@ package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.comptabilite.request.*;
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.mutuelle.epargne.TransactionEpargne;
import dev.lions.unionflow.server.repository.*;
import dev.lions.unionflow.server.service.KeycloakService;
import jakarta.enterprise.context.ApplicationScoped;
@@ -223,207 +221,6 @@ public class ComptabiliteService {
.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
// ========================================

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