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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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