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:
@@ -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
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
-- ============================================================================
|
||||
-- V32 — Mutuelle : Parts Sociales + Paramètres Financiers + Intérêts
|
||||
--
|
||||
-- Ajoute les tables nécessaires pour les fonctionnalités manquantes identifiées
|
||||
-- dans l'analyse du fichier FUSION 2013-2021.xlsx de la Mutuelle GBANE :
|
||||
-- 1. comptes_parts_sociales — capital social des membres
|
||||
-- 2. transactions_parts_sociales — historique des mouvements de parts
|
||||
-- 3. parametres_financiers_mutuelle — taux, périodicités, valeur nominale
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 1. Paramètres financiers de la mutuelle ────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS parametres_financiers_mutuelle (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
organisation_id UUID NOT NULL UNIQUE,
|
||||
valeur_nominale_par_defaut NUMERIC(19,4) NOT NULL DEFAULT 5000,
|
||||
taux_interet_annuel_epargne NUMERIC(6,4) NOT NULL DEFAULT 0.0300,
|
||||
taux_dividende_parts_annuel NUMERIC(6,4) NOT NULL DEFAULT 0.0500,
|
||||
periodicite_calcul VARCHAR(20) NOT NULL DEFAULT 'MENSUEL',
|
||||
seuil_min_epargne_interets NUMERIC(19,4) DEFAULT 0,
|
||||
prochaine_calcul_interets DATE,
|
||||
dernier_calcul_interets DATE,
|
||||
dernier_nb_comptes_traites INTEGER DEFAULT 0,
|
||||
-- BaseEntity cols
|
||||
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
date_modification TIMESTAMP,
|
||||
cree_par VARCHAR(255),
|
||||
modifie_par VARCHAR(255),
|
||||
version BIGINT DEFAULT 0,
|
||||
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT fk_pfm_organisation FOREIGN KEY (organisation_id) REFERENCES organisations(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pfm_org ON parametres_financiers_mutuelle(organisation_id);
|
||||
|
||||
-- ── 2. Comptes de parts sociales ───────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS comptes_parts_sociales (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
membre_id UUID NOT NULL,
|
||||
organisation_id UUID NOT NULL,
|
||||
numero_compte VARCHAR(50) NOT NULL UNIQUE,
|
||||
nombre_parts INTEGER NOT NULL DEFAULT 0,
|
||||
valeur_nominale NUMERIC(19,4) NOT NULL,
|
||||
montant_total NUMERIC(19,4) NOT NULL DEFAULT 0,
|
||||
total_dividendes_recus NUMERIC(19,4) NOT NULL DEFAULT 0,
|
||||
statut VARCHAR(30) NOT NULL DEFAULT 'ACTIF',
|
||||
date_ouverture DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
date_derniere_operation DATE,
|
||||
notes VARCHAR(500),
|
||||
-- BaseEntity cols
|
||||
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
date_modification TIMESTAMP,
|
||||
cree_par VARCHAR(255),
|
||||
modifie_par VARCHAR(255),
|
||||
version BIGINT DEFAULT 0,
|
||||
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT fk_cps_membre FOREIGN KEY (membre_id) REFERENCES utilisateurs(id),
|
||||
CONSTRAINT fk_cps_organisation FOREIGN KEY (organisation_id) REFERENCES organisations(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cps_numero ON comptes_parts_sociales(numero_compte);
|
||||
CREATE INDEX IF NOT EXISTS idx_cps_membre ON comptes_parts_sociales(membre_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cps_org ON comptes_parts_sociales(organisation_id);
|
||||
|
||||
-- ── 3. Transactions sur parts sociales ────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS transactions_parts_sociales (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
compte_id UUID NOT NULL,
|
||||
type_transaction VARCHAR(50) NOT NULL,
|
||||
nombre_parts INTEGER NOT NULL,
|
||||
montant NUMERIC(19,4) NOT NULL,
|
||||
solde_parts_avant INTEGER NOT NULL DEFAULT 0,
|
||||
solde_parts_apres INTEGER NOT NULL DEFAULT 0,
|
||||
motif VARCHAR(500),
|
||||
reference_externe VARCHAR(100),
|
||||
date_transaction TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
-- BaseEntity cols
|
||||
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
date_modification TIMESTAMP,
|
||||
cree_par VARCHAR(255),
|
||||
modifie_par VARCHAR(255),
|
||||
version BIGINT DEFAULT 0,
|
||||
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT fk_tps_compte FOREIGN KEY (compte_id) REFERENCES comptes_parts_sociales(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tps_compte ON transactions_parts_sociales(compte_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tps_date ON transactions_parts_sociales(date_transaction);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- ============================================================================
|
||||
-- V33 — Correction colonnes legacy de audit_logs
|
||||
--
|
||||
-- La V1 crée audit_logs avec action VARCHAR(50) NOT NULL (ancien schéma).
|
||||
-- L'entité AuditLog utilise type_action à la place.
|
||||
-- Hibernate ne remplit pas action → violation NOT NULL sur chaque insert.
|
||||
-- Fix : rendre action nullable + nettoyer les autres colonnes orphelines.
|
||||
-- ============================================================================
|
||||
|
||||
-- Rendre la colonne legacy nullable (elle est supersédée par type_action)
|
||||
ALTER TABLE audit_logs ALTER COLUMN action DROP NOT NULL;
|
||||
|
||||
-- Aligner entite_id : la V1 déclare UUID mais l'entité stocke une String (UUID textuel)
|
||||
-- → changer en VARCHAR pour éviter des cast errors sur certains IDs non-UUID
|
||||
ALTER TABLE audit_logs ALTER COLUMN entite_id TYPE VARCHAR(255) USING entite_id::VARCHAR;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- ============================================================================
|
||||
-- V34 — Rendre membre_id nullable dans les tables où l'entité Hibernate
|
||||
-- utilise désormais une autre colonne (utilisateur_id, membre_organisation_id).
|
||||
--
|
||||
-- Contexte : V1 crée ces tables avec membre_id UUID NOT NULL. Les entités ont
|
||||
-- évolué pour utiliser utilisateur_id (MembreOrganisation, DemandeAdhesion,
|
||||
-- IntentionPaiement) ou membre_organisation_id (MembreRole). Hibernate update
|
||||
-- a ajouté les nouvelles colonnes mais n'a pas supprimé membre_id.
|
||||
-- Résultat : chaque insert lève une violation NOT NULL sur membre_id.
|
||||
-- Fix : rendre membre_id nullable (colonne legacy, plus utilisée par le code).
|
||||
-- ============================================================================
|
||||
|
||||
-- membres_organisations : entité utilise utilisateur_id
|
||||
ALTER TABLE membres_organisations ALTER COLUMN membre_id DROP NOT NULL;
|
||||
|
||||
-- membres_roles : entité utilise membre_organisation_id
|
||||
ALTER TABLE membres_roles ALTER COLUMN membre_id DROP NOT NULL;
|
||||
|
||||
-- demandes_adhesion : entité utilise utilisateur_id
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'demandes_adhesion' AND column_name = 'membre_id'
|
||||
) THEN
|
||||
ALTER TABLE demandes_adhesion ALTER COLUMN membre_id DROP NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- intentions_paiement : entité utilise utilisateur_id
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'intentions_paiement' AND column_name = 'membre_id'
|
||||
) THEN
|
||||
ALTER TABLE intentions_paiement ALTER COLUMN membre_id DROP NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- ============================================================================
|
||||
-- V35 — Recalibrage nombre_membres + trigger auto-maintien
|
||||
--
|
||||
-- DATA-01 : Le compteur organisations.nombre_membres est désynchronisé quand
|
||||
-- des membres sont importés directement en DB (hors service Java).
|
||||
-- Fix :
|
||||
-- 1. Recalibrage immédiat depuis membres_organisations réels (actifs)
|
||||
-- 2. Trigger PostgreSQL pour maintenir le compteur à jour automatiquement
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. Recalibrage ponctuel : recalculer depuis la table membres_organisations
|
||||
UPDATE organisations o
|
||||
SET nombre_membres = (
|
||||
SELECT COUNT(*)
|
||||
FROM membres_organisations mo
|
||||
WHERE mo.organisation_id = o.id
|
||||
AND mo.actif = true
|
||||
AND mo.statut IN ('ACTIF', 'ACTIF_PREMIUM')
|
||||
);
|
||||
|
||||
-- 2. Fonction trigger : incrémente/décrémente selon INSERT/UPDATE/DELETE
|
||||
CREATE OR REPLACE FUNCTION update_organisation_nombre_membres()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- Nouveau membre actif → incrémenter
|
||||
IF NEW.actif = true AND NEW.statut IN ('ACTIF', 'ACTIF_PREMIUM') THEN
|
||||
UPDATE organisations
|
||||
SET nombre_membres = GREATEST(0, nombre_membres + 1)
|
||||
WHERE id = NEW.organisation_id;
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Transition actif/inactif ou statut
|
||||
DECLARE
|
||||
was_counted BOOLEAN := OLD.actif = true AND OLD.statut IN ('ACTIF', 'ACTIF_PREMIUM');
|
||||
is_counted BOOLEAN := NEW.actif = true AND NEW.statut IN ('ACTIF', 'ACTIF_PREMIUM');
|
||||
BEGIN
|
||||
IF NOT was_counted AND is_counted THEN
|
||||
UPDATE organisations
|
||||
SET nombre_membres = GREATEST(0, nombre_membres + 1)
|
||||
WHERE id = NEW.organisation_id;
|
||||
ELSIF was_counted AND NOT is_counted THEN
|
||||
UPDATE organisations
|
||||
SET nombre_membres = GREATEST(0, nombre_membres - 1)
|
||||
WHERE id = OLD.organisation_id;
|
||||
END IF;
|
||||
END;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
-- Suppression physique (rare)
|
||||
IF OLD.actif = true AND OLD.statut IN ('ACTIF', 'ACTIF_PREMIUM') THEN
|
||||
UPDATE organisations
|
||||
SET nombre_membres = GREATEST(0, nombre_membres - 1)
|
||||
WHERE id = OLD.organisation_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 3. Attacher le trigger à membres_organisations
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'trg_update_nombre_membres'
|
||||
AND tgrelid = 'membres_organisations'::regclass
|
||||
) THEN
|
||||
CREATE TRIGGER trg_update_nombre_membres
|
||||
AFTER INSERT OR UPDATE OF actif, statut OR DELETE
|
||||
ON membres_organisations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_organisation_nombre_membres();
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,393 @@
|
||||
-- ============================================================================
|
||||
-- V36 — SYSCOHADA : Alignement schéma + Seeds plan comptable standard + Trigger
|
||||
--
|
||||
-- P0.4 ROADMAP_2026.md — Obligation OHADA SYSCOHADA révisé (applicable depuis 2018)
|
||||
-- Corrige l'écart entre V1 (schéma minimal) et les entités Java (colonnes Hibernate).
|
||||
-- Ajoute le plan comptable standard SYSCOHADA pour mutuelles/coopératives UEMOA.
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. COMPTES_COMPTABLES — Alignement colonnes V1 → entité Java
|
||||
-- ============================================================================
|
||||
|
||||
-- La V1 crée la table avec numero/libelle/type_compte/organisation_id seulement.
|
||||
-- L'entité Java attend : numero_compte, classe_comptable, solde_initial, solde_actuel,
|
||||
-- compte_collectif, compte_analytique, cree_par, modifie_par.
|
||||
|
||||
-- Renommer la colonne numero → numero_compte si elle n'a pas déjà été renommée par Hibernate
|
||||
-- Sinon : si les deux colonnes coexistent (Hibernate a créé numero_compte, V1 a laissé numero),
|
||||
-- on supprime l'ancienne colonne obsolète numero (NOT NULL sans défaut, bloque les INSERTs).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'comptes_comptables' AND column_name = 'numero'
|
||||
) THEN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'comptes_comptables' AND column_name = 'numero_compte'
|
||||
) THEN
|
||||
ALTER TABLE comptes_comptables RENAME COLUMN numero TO numero_compte;
|
||||
ELSE
|
||||
-- Les deux colonnes coexistent : recopier les valeurs vers numero_compte si besoin,
|
||||
-- puis supprimer la colonne obsolète numero.
|
||||
UPDATE comptes_comptables SET numero_compte = numero
|
||||
WHERE numero_compte IS NULL AND numero IS NOT NULL;
|
||||
ALTER TABLE comptes_comptables DROP COLUMN numero;
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Ajouter colonnes manquantes si pas encore créées par Hibernate update
|
||||
ALTER TABLE comptes_comptables
|
||||
ADD COLUMN IF NOT EXISTS classe_comptable INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS solde_initial DECIMAL(14,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS solde_actuel DECIMAL(14,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS compte_collectif BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS compte_analytique BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS description VARCHAR(500),
|
||||
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
|
||||
|
||||
-- Déduire classe_comptable depuis numero_compte si null (première chiffre du numéro)
|
||||
UPDATE comptes_comptables
|
||||
SET classe_comptable = CAST(LEFT(numero_compte, 1) AS INTEGER)
|
||||
WHERE classe_comptable IS NULL AND numero_compte IS NOT NULL AND LENGTH(numero_compte) > 0;
|
||||
|
||||
-- Rendre classe_comptable NOT NULL après backfill
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'comptes_comptables' AND column_name = 'classe_comptable'
|
||||
AND is_nullable = 'NO'
|
||||
) THEN
|
||||
ALTER TABLE comptes_comptables ALTER COLUMN classe_comptable SET NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Contrainte classe 1-9 (SYSCOHADA a 9 classes, pas 7)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_compte_classe_syscohada') THEN
|
||||
ALTER TABLE comptes_comptables
|
||||
ADD CONSTRAINT chk_compte_classe_syscohada
|
||||
CHECK (classe_comptable >= 1 AND classe_comptable <= 9);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. JOURNAUX_COMPTABLES — Alignement colonnes
|
||||
-- ============================================================================
|
||||
ALTER TABLE journaux_comptables
|
||||
ADD COLUMN IF NOT EXISTS date_debut DATE,
|
||||
ADD COLUMN IF NOT EXISTS date_fin DATE,
|
||||
ADD COLUMN IF NOT EXISTS statut VARCHAR(20) DEFAULT 'OUVERT',
|
||||
ADD COLUMN IF NOT EXISTS description VARCHAR(500),
|
||||
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. ECRITURES_COMPTABLES — Alignement colonnes
|
||||
-- ============================================================================
|
||||
ALTER TABLE ecritures_comptables
|
||||
ADD COLUMN IF NOT EXISTS organisation_id UUID REFERENCES organisations(id),
|
||||
ADD COLUMN IF NOT EXISTS paiement_id UUID REFERENCES paiements(id),
|
||||
ADD COLUMN IF NOT EXISTS reference VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS lettrage VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS pointe BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS montant_debit DECIMAL(14,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS montant_credit DECIMAL(14,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS commentaire VARCHAR(1000),
|
||||
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. LIGNES_ECRITURE — Alignement colonnes (debit/credit → montant_debit/credit)
|
||||
-- ============================================================================
|
||||
|
||||
-- Renommer compte_id → compte_comptable_id si besoin
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'lignes_ecriture' AND column_name = 'compte_id'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'lignes_ecriture' AND column_name = 'compte_comptable_id'
|
||||
) THEN
|
||||
ALTER TABLE lignes_ecriture RENAME COLUMN compte_id TO compte_comptable_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Renommer debit/credit → montant_debit/montant_credit si besoin
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'lignes_ecriture' AND column_name = 'debit'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'lignes_ecriture' AND column_name = 'montant_debit'
|
||||
) THEN
|
||||
ALTER TABLE lignes_ecriture RENAME COLUMN debit TO montant_debit;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'lignes_ecriture' AND column_name = 'credit'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'lignes_ecriture' AND column_name = 'montant_credit'
|
||||
) THEN
|
||||
ALTER TABLE lignes_ecriture RENAME COLUMN credit TO montant_credit;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE lignes_ecriture
|
||||
ADD COLUMN IF NOT EXISTS numero_ligne INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS reference VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. TABLE MODELE_PLAN_COMPTABLE — Template SYSCOHADA (comptes standards réutilisables)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS modele_plan_comptable (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
numero_compte VARCHAR(10) NOT NULL UNIQUE,
|
||||
libelle VARCHAR(200) NOT NULL,
|
||||
classe_comptable INTEGER NOT NULL CHECK (classe_comptable >= 1 AND classe_comptable <= 9),
|
||||
type_compte VARCHAR(30) NOT NULL,
|
||||
description VARCHAR(500),
|
||||
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT chk_modele_classe CHECK (classe_comptable >= 1 AND classe_comptable <= 9)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. SEEDS — Plan comptable SYSCOHADA standard pour mutuelles/coopératives UEMOA
|
||||
-- ============================================================================
|
||||
INSERT INTO modele_plan_comptable (numero_compte, libelle, classe_comptable, type_compte) VALUES
|
||||
-- CLASSE 1 — Ressources durables
|
||||
('101000', 'Fonds propres', 1, 'PASSIF'),
|
||||
('104000', 'Réserve légale', 1, 'PASSIF'),
|
||||
('106000', 'Réserves statutaires', 1, 'PASSIF'),
|
||||
('120000', 'Résultat de l''exercice', 1, 'PASSIF'),
|
||||
('160000', 'Emprunts à long terme', 1, 'PASSIF'),
|
||||
('165000', 'Dépôts et cautionnements reçus', 1, 'PASSIF'),
|
||||
|
||||
-- CLASSE 2 — Actif immobilisé
|
||||
('222000', 'Matériel de transport', 2, 'ACTIF'),
|
||||
('232000', 'Matériel informatique', 2, 'ACTIF'),
|
||||
('244000', 'Logiciels informatiques', 2, 'ACTIF'),
|
||||
('281000', 'Amortissements immobilisations', 2, 'ACTIF'),
|
||||
|
||||
-- CLASSE 4 — Tiers
|
||||
('411000', 'Membres débiteurs — cotisations dues', 4, 'ACTIF'),
|
||||
('412000', 'Membres débiteurs — parts sociales dues', 4, 'ACTIF'),
|
||||
('413000', 'Membres débiteurs — avances sur prestations', 4, 'ACTIF'),
|
||||
('421000', 'Personnel — rémunérations dues', 4, 'PASSIF'),
|
||||
('431000', 'Sécurité sociale — cotisations patronales', 4, 'PASSIF'),
|
||||
('441000', 'État — TVA collectée', 4, 'PASSIF'),
|
||||
('447000', 'État — autres impôts et taxes', 4, 'PASSIF'),
|
||||
('467000', 'Tiers divers débiteurs', 4, 'ACTIF'),
|
||||
('468000', 'Tiers divers créditeurs', 4, 'PASSIF'),
|
||||
|
||||
-- CLASSE 5 — Trésorerie
|
||||
('512100', 'Compte Wave Senegal', 5, 'TRESORERIE'),
|
||||
('512200', 'Compte Orange Money', 5, 'TRESORERIE'),
|
||||
('512300', 'Compte MTN MoMo', 5, 'TRESORERIE'),
|
||||
('512400', 'Compte Moov Money', 5, 'TRESORERIE'),
|
||||
('512500', 'Compte bancaire principal', 5, 'TRESORERIE'),
|
||||
('531000', 'Caisse principale', 5, 'TRESORERIE'),
|
||||
('581000', 'Virements internes de trésorerie', 5, 'TRESORERIE'),
|
||||
|
||||
-- CLASSE 6 — Charges
|
||||
('601000', 'Achats de marchandises', 6, 'CHARGES'),
|
||||
('611000', 'Transports', 6, 'CHARGES'),
|
||||
('612000', 'Frais de télécommunications', 6, 'CHARGES'),
|
||||
('613000', 'Frais d''assurance', 6, 'CHARGES'),
|
||||
('614000', 'Location matériel', 6, 'CHARGES'),
|
||||
('616000', 'Frais d''entretien et réparations', 6, 'CHARGES'),
|
||||
('621000', 'Personnel externe (prestataires)', 6, 'CHARGES'),
|
||||
('622000', 'Rémunérations du personnel', 6, 'CHARGES'),
|
||||
('631000', 'Frais financiers — intérêts d''emprunts', 6, 'CHARGES'),
|
||||
('641000', 'Charges sur prestations mutuelles', 6, 'CHARGES'),
|
||||
('651000', 'Pertes sur créances irrécouvrables', 6, 'CHARGES'),
|
||||
|
||||
-- CLASSE 7 — Produits
|
||||
('706100', 'Cotisations ordinaires membres', 7, 'PRODUITS'),
|
||||
('706200', 'Cotisations spéciales / majorées', 7, 'PRODUITS'),
|
||||
('706300', 'Parts sociales', 7, 'PRODUITS'),
|
||||
('706400', 'Droits d''adhésion', 7, 'PRODUITS'),
|
||||
('762000', 'Produits financiers — intérêts épargne', 7, 'PRODUITS'),
|
||||
('771000', 'Subventions d''exploitation reçues', 7, 'PRODUITS'),
|
||||
('775000', 'Prestations de services', 7, 'PRODUITS'),
|
||||
|
||||
-- CLASSE 8 — Charges et produits exceptionnels / hors activité
|
||||
('870000', 'Dons reçus', 8, 'PRODUITS'),
|
||||
('871000', 'Legs et donations', 8, 'PRODUITS'),
|
||||
('875000', 'Produits exceptionnels d''événements', 8, 'PRODUITS'),
|
||||
('878000', 'Autres produits hors activité ordinaire', 8, 'PRODUITS'),
|
||||
('880000', 'Charges exceptionnelles', 8, 'CHARGES'),
|
||||
|
||||
-- CLASSE 9 — Engagements / comptabilité analytique
|
||||
('990000', 'Engagements hors bilan donnés', 9, 'AUTRE'),
|
||||
('991000', 'Engagements hors bilan reçus', 9, 'AUTRE')
|
||||
|
||||
ON CONFLICT (numero_compte) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. TRIGGER — Initialisation automatique du plan comptable à la création d'org
|
||||
-- ============================================================================
|
||||
CREATE OR REPLACE FUNCTION init_plan_comptable_organisation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO comptes_comptables (
|
||||
id, numero_compte, libelle, classe_comptable, type_compte,
|
||||
description, organisation_id, solde_initial, solde_actuel,
|
||||
compte_collectif, compte_analytique, actif,
|
||||
date_creation, version
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
m.numero_compte,
|
||||
m.libelle,
|
||||
m.classe_comptable,
|
||||
m.type_compte,
|
||||
m.description,
|
||||
NEW.id,
|
||||
0, 0,
|
||||
false, false, true,
|
||||
NOW(), 0
|
||||
FROM modele_plan_comptable m
|
||||
WHERE m.actif = true;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgname = 'trg_init_plan_comptable_org'
|
||||
AND tgrelid = 'organisations'::regclass
|
||||
) THEN
|
||||
CREATE TRIGGER trg_init_plan_comptable_org
|
||||
AFTER INSERT ON organisations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION init_plan_comptable_organisation();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 8. BACKFILL — Initialiser le plan comptable pour les organisations existantes
|
||||
-- (qui ont été créées avant ce trigger)
|
||||
-- ============================================================================
|
||||
INSERT INTO comptes_comptables (
|
||||
id, numero_compte, libelle, classe_comptable, type_compte,
|
||||
description, organisation_id, solde_initial, solde_actuel,
|
||||
compte_collectif, compte_analytique, actif,
|
||||
date_creation, version
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
m.numero_compte,
|
||||
m.libelle,
|
||||
m.classe_comptable,
|
||||
m.type_compte,
|
||||
m.description,
|
||||
o.id,
|
||||
0, 0,
|
||||
false, false, true,
|
||||
NOW(), 0
|
||||
FROM organisations o
|
||||
CROSS JOIN modele_plan_comptable m
|
||||
WHERE m.actif = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM comptes_comptables cc
|
||||
WHERE cc.organisation_id = o.id
|
||||
AND cc.numero_compte = m.numero_compte
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 9. JOURNAUX STANDARD par organisation
|
||||
-- ============================================================================
|
||||
|
||||
-- Remplacer la contrainte UNIQUE globale sur `code` par une contrainte composite
|
||||
-- (organisation_id, code) — plusieurs orgs peuvent avoir un journal ACH/VTE/etc.
|
||||
DO $$
|
||||
DECLARE
|
||||
constraint_name text;
|
||||
BEGIN
|
||||
SELECT tc.constraint_name INTO constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_name = 'journaux_comptables'
|
||||
AND tc.constraint_type = 'UNIQUE'
|
||||
AND ccu.column_name = 'code'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.constraint_column_usage ccu2
|
||||
WHERE ccu2.constraint_name = tc.constraint_name
|
||||
AND ccu2.column_name = 'organisation_id'
|
||||
);
|
||||
IF constraint_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE journaux_comptables DROP CONSTRAINT ' || quote_ident(constraint_name);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uk_journaux_org_code'
|
||||
) THEN
|
||||
ALTER TABLE journaux_comptables
|
||||
ADD CONSTRAINT uk_journaux_org_code UNIQUE (organisation_id, code);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
INSERT INTO journaux_comptables (
|
||||
id, code, libelle, type_journal, organisation_id,
|
||||
statut, actif, date_creation, version
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
jtype.code,
|
||||
jtype.libelle,
|
||||
jtype.type_journal,
|
||||
o.id,
|
||||
'OUVERT', true, NOW(), 0
|
||||
FROM organisations o
|
||||
CROSS JOIN (VALUES
|
||||
('ACH', 'Journal des achats', 'ACHATS'),
|
||||
('VTE', 'Journal des ventes / cotisations', 'VENTES'),
|
||||
('BQ', 'Journal bancaire', 'BANQUE'),
|
||||
('CAI', 'Journal de caisse', 'CAISSE'),
|
||||
('OD', 'Journal des opérations diverses', 'OD')
|
||||
) AS jtype(code, libelle, type_journal)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM journaux_comptables jc
|
||||
WHERE jc.organisation_id = o.id
|
||||
AND jc.type_journal = jtype.type_journal
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 10. INDEX utiles
|
||||
-- ============================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_comptes_org_numero
|
||||
ON comptes_comptables (organisation_id, numero_compte);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comptes_org_classe
|
||||
ON comptes_comptables (organisation_id, classe_comptable);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ecritures_org_date
|
||||
ON ecritures_comptables (organisation_id, date_ecriture);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lignes_compte
|
||||
ON lignes_ecriture (compte_comptable_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- ============================================================================
|
||||
-- V37 — Keycloak 26 Organizations : ajout keycloak_org_id sur organisations
|
||||
--
|
||||
-- P0.2 ROADMAP_2026.md — Migration Keycloak 23 → 26 + Organizations natives
|
||||
-- Stocke l'ID Keycloak Organization correspondant à chaque organisation UnionFlow.
|
||||
-- Null = organisation pas encore migrée vers Keycloak 26 Organizations.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE organisations
|
||||
ADD COLUMN IF NOT EXISTS keycloak_org_id UUID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_organisations_keycloak_org_id
|
||||
ON organisations (keycloak_org_id)
|
||||
WHERE keycloak_org_id IS NOT NULL;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- ============================================================================
|
||||
-- V38 — Module KYC/AML : table kyc_dossier
|
||||
--
|
||||
-- P1.5 ROADMAP_2026.md — KYC/AML — conformité GIABA/BCEAO LCB-FT
|
||||
-- Rétention 10 ans (GIABA) gérée par colonne annee_reference + archivage planifié.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kyc_dossier (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Identité du membre
|
||||
membre_id UUID NOT NULL REFERENCES utilisateurs(id),
|
||||
|
||||
-- Pièce d'identité
|
||||
type_piece VARCHAR(30) NOT NULL,
|
||||
numero_piece VARCHAR(50) NOT NULL,
|
||||
date_expiration_piece DATE,
|
||||
|
||||
-- Fichiers stockés (MinIO/S3 — identifiants opaques)
|
||||
piece_identite_recto_file_id VARCHAR(500),
|
||||
piece_identite_verso_file_id VARCHAR(500),
|
||||
justif_domicile_file_id VARCHAR(500),
|
||||
|
||||
-- Évaluation risque LCB-FT
|
||||
statut VARCHAR(20) NOT NULL DEFAULT 'NON_VERIFIE',
|
||||
niveau_risque VARCHAR(20) NOT NULL DEFAULT 'FAIBLE',
|
||||
score_risque INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (score_risque >= 0 AND score_risque <= 100),
|
||||
|
||||
-- PEP (Personne Exposée Politiquement)
|
||||
est_pep BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
nationalite VARCHAR(5),
|
||||
|
||||
-- Validation
|
||||
date_verification TIMESTAMP,
|
||||
validateur_id UUID REFERENCES utilisateurs(id),
|
||||
notes_validateur VARCHAR(1000),
|
||||
|
||||
-- Rétention 10 ans GIABA — partitionnement logique par année
|
||||
annee_reference INTEGER NOT NULL DEFAULT EXTRACT(YEAR FROM NOW()),
|
||||
|
||||
-- BaseEntity
|
||||
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
date_modification TIMESTAMP,
|
||||
cree_par VARCHAR(255),
|
||||
modifie_par VARCHAR(255),
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
CONSTRAINT chk_kyc_annee_reference CHECK (annee_reference >= 2020 AND annee_reference <= 2100)
|
||||
);
|
||||
|
||||
-- Un seul dossier actif par membre (le plus récent est actif, les anciens archivés)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_kyc_membre_actif
|
||||
ON kyc_dossier (membre_id)
|
||||
WHERE actif = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kyc_membre_id ON kyc_dossier (membre_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kyc_statut ON kyc_dossier (statut);
|
||||
CREATE INDEX IF NOT EXISTS idx_kyc_niveau_risque ON kyc_dossier (niveau_risque);
|
||||
CREATE INDEX IF NOT EXISTS idx_kyc_est_pep ON kyc_dossier (est_pep) WHERE est_pep = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_kyc_annee ON kyc_dossier (annee_reference);
|
||||
CREATE INDEX IF NOT EXISTS idx_kyc_date_expiration ON kyc_dossier (date_expiration_piece)
|
||||
WHERE date_expiration_piece IS NOT NULL;
|
||||
@@ -0,0 +1,174 @@
|
||||
-- ============================================================================
|
||||
-- V39 — PostgreSQL Row-Level Security : isolation multi-tenant
|
||||
--
|
||||
-- P1.2 ROADMAP_2026.md — Multi-tenancy RLS sur tables tenant-scoped
|
||||
--
|
||||
-- Variables de session :
|
||||
-- app.current_org_id : UUID de l'organisation active (set par RlsConnectionInitializer)
|
||||
-- app.is_super_admin : 'true' si SUPER_ADMIN (bypass RLS pour dashboards globaux)
|
||||
--
|
||||
-- Notes sécurité :
|
||||
-- - Ne pas activer FORCE ROW LEVEL SECURITY ici — le user Flyway (owner) bypasse naturellement.
|
||||
-- - En prod : créer user `unionflow_app` sans BYPASSRLS pour le pool Quarkus.
|
||||
-- - Le user Flyway (`unionflow_admin` ou `postgres`) doit avoir BYPASSRLS ou être owner.
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- Helper : policy template pour tables avec organisation_id direct
|
||||
-- ============================================================================
|
||||
|
||||
-- TABLE cotisations
|
||||
ALTER TABLE cotisations ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_cotisations') THEN
|
||||
CREATE POLICY rls_tenant_cotisations ON cotisations
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE souscriptions_organisation
|
||||
ALTER TABLE souscriptions_organisation ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_souscriptions') THEN
|
||||
CREATE POLICY rls_tenant_souscriptions ON souscriptions_organisation
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE evenements
|
||||
ALTER TABLE evenements ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_evenements') THEN
|
||||
CREATE POLICY rls_tenant_evenements ON evenements
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE documents
|
||||
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_documents') THEN
|
||||
CREATE POLICY rls_tenant_documents ON documents
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE comptes_comptables
|
||||
ALTER TABLE comptes_comptables ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_comptes_comptables') THEN
|
||||
CREATE POLICY rls_tenant_comptes_comptables ON comptes_comptables
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE journaux_comptables
|
||||
ALTER TABLE journaux_comptables ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_journaux_comptables') THEN
|
||||
CREATE POLICY rls_tenant_journaux_comptables ON journaux_comptables
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE ecritures_comptables
|
||||
ALTER TABLE ecritures_comptables ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_ecritures_comptables') THEN
|
||||
CREATE POLICY rls_tenant_ecritures_comptables ON ecritures_comptables
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE kyc_dossier (scoped via membres_organisations JOIN)
|
||||
-- Note : kyc_dossier n'a pas d'organisation_id direct — scope via membre_id + membres_organisations
|
||||
ALTER TABLE kyc_dossier ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_kyc_dossier') THEN
|
||||
CREATE POLICY rls_tenant_kyc_dossier ON kyc_dossier
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM membres_organisations mo
|
||||
WHERE mo.utilisateur_id = kyc_dossier.membre_id
|
||||
AND mo.organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
AND mo.actif = true
|
||||
)
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE membres_organisations (scope par organisation)
|
||||
ALTER TABLE membres_organisations ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_membres_organisations') THEN
|
||||
CREATE POLICY rls_tenant_membres_organisations ON membres_organisations
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE budgets
|
||||
ALTER TABLE budgets ENABLE ROW LEVEL SECURITY;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_budgets') THEN
|
||||
CREATE POLICY rls_tenant_budgets ON budgets
|
||||
USING (
|
||||
organisation_id = current_setting('app.current_org_id', true)::uuid
|
||||
OR current_setting('app.is_super_admin', true) = 'true'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- TABLE tontines (si applicable)
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tontines')
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_tontines') THEN
|
||||
EXECUTE 'ALTER TABLE tontines ENABLE ROW LEVEL SECURITY';
|
||||
EXECUTE '
|
||||
CREATE POLICY rls_tenant_tontines ON tontines
|
||||
USING (
|
||||
organisation_id = current_setting(''app.current_org_id'', true)::uuid
|
||||
OR current_setting(''app.is_super_admin'', true) = ''true''
|
||||
)';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Rôle PostgreSQL applicatif (prod only — commenté pour ne pas casser dev)
|
||||
-- À exécuter manuellement en prod avec le bon mot de passe.
|
||||
-- ============================================================================
|
||||
-- CREATE ROLE unionflow_app LOGIN PASSWORD '<UNIONFLOW_APP_DB_PASSWORD>';
|
||||
-- GRANT CONNECT ON DATABASE unionflow TO unionflow_app;
|
||||
-- GRANT USAGE ON SCHEMA public TO unionflow_app;
|
||||
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO unionflow_app;
|
||||
-- GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO unionflow_app;
|
||||
-- -- unionflow_app N'A PAS BYPASSRLS — RLS s'applique toujours
|
||||
--
|
||||
-- CREATE ROLE unionflow_admin LOGIN PASSWORD '<UNIONFLOW_ADMIN_DB_PASSWORD>' BYPASSRLS;
|
||||
-- GRANT ALL ON ALL TABLES IN SCHEMA public TO unionflow_admin;
|
||||
-- GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO unionflow_admin;
|
||||
-- -- unionflow_admin utilisé par Flyway et SuperAdminCrossTenantService
|
||||
@@ -0,0 +1,9 @@
|
||||
-- V40: Ajout du provider de paiement par défaut sur FormuleAbonnement
|
||||
-- Permet de configurer le provider (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI) par formule
|
||||
-- NULL = utiliser le provider global configuré dans application.properties
|
||||
|
||||
ALTER TABLE formules_abonnement
|
||||
ADD COLUMN IF NOT EXISTS provider_defaut VARCHAR(20);
|
||||
|
||||
COMMENT ON COLUMN formules_abonnement.provider_defaut IS
|
||||
'Code du provider de paiement par défaut pour cette formule (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI). NULL = provider global.';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- V41: Token FCM (Firebase Cloud Messaging) pour les notifications push mobile
|
||||
-- Nullable : vide si le membre n'a pas installé l'app mobile ou refusé les notifications
|
||||
-- Table : utilisateurs (entité Membre.java → @Table(name = "utilisateurs"))
|
||||
|
||||
ALTER TABLE utilisateurs
|
||||
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(500);
|
||||
|
||||
COMMENT ON COLUMN utilisateurs.fcm_token IS
|
||||
'Token FCM pour les notifications push Firebase. NULL si non enregistré.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_fcm_token
|
||||
ON utilisateurs (fcm_token) WHERE fcm_token IS NOT NULL;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- V42: Créer les rôles PostgreSQL pour l'isolation RLS
|
||||
-- unionflow_app : rôle applicatif (sans BYPASSRLS) — utilisé en prod par le backend
|
||||
-- unionflow_admin: rôle administrateur (BYPASSRLS) — utilisé pour les migrations Flyway et les ops DBA
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Rôle applicatif (sans bypass RLS — soumis aux policies)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'unionflow_app') THEN
|
||||
CREATE ROLE unionflow_app LOGIN PASSWORD 'CHANGE_ME_APP_PASSWORD';
|
||||
END IF;
|
||||
|
||||
-- Rôle administrateur (bypass RLS — pour Flyway, exports, audits DBA)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'unionflow_admin') THEN
|
||||
CREATE ROLE unionflow_admin LOGIN PASSWORD 'CHANGE_ME_ADMIN_PASSWORD' BYPASSRLS;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Accorder les privilèges sur le schéma public
|
||||
GRANT USAGE ON SCHEMA public TO unionflow_app, unionflow_admin;
|
||||
|
||||
-- unionflow_app : DML uniquement (pas DDL)
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO unionflow_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO unionflow_app;
|
||||
|
||||
-- unionflow_admin : tous les droits (DDL inclus pour Flyway)
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO unionflow_admin;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO unionflow_admin;
|
||||
|
||||
-- Garantir les droits sur les objets créés ultérieurement (nouvelles tables Flyway)
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO unionflow_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO unionflow_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT ALL PRIVILEGES ON TABLES TO unionflow_admin;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT ALL PRIVILEGES ON SEQUENCES TO unionflow_admin;
|
||||
|
||||
COMMENT ON ROLE unionflow_app IS 'Rôle applicatif UnionFlow — soumis aux policies RLS tenant isolation';
|
||||
COMMENT ON ROLE unionflow_admin IS 'Rôle DBA UnionFlow — BYPASSRLS pour Flyway et exports';
|
||||
42
src/main/resources/templates/email/bienvenue.html
Normal file
42
src/main/resources/templates/email/bienvenue.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Bienvenue sur UnionFlow</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
|
||||
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
|
||||
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
|
||||
.header h1 { margin: 0; font-size: 22px; }
|
||||
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
|
||||
.btn { display: inline-block; margin-top: 20px; padding: 12px 28px; background: #1A568C; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold; }
|
||||
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎉 Bienvenue sur UnionFlow !</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
|
||||
<p>Votre compte a été créé avec succès sur <strong>UnionFlow</strong>, la plateforme de gestion des mutuelles, coopératives et syndicats de Côte d'Ivoire.</p>
|
||||
|
||||
<p>Vous faites maintenant partie de l'organisation : <strong>{nomOrganisation}</strong></p>
|
||||
|
||||
<p>Votre identifiant de connexion est votre adresse email : <strong>{email}</strong></p>
|
||||
|
||||
{#if lienConnexion}
|
||||
<p>
|
||||
<a href="{lienConnexion}" class="btn">Accéder à mon espace</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p>En cas de question, contactez votre administrateur ou notre support : <a href="mailto:support@lions.dev">support@lions.dev</a></p>
|
||||
|
||||
<p>Cordialement,<br>L'équipe UnionFlow</p>
|
||||
</div>
|
||||
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Confirmation de cotisation</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
|
||||
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
|
||||
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
|
||||
.header h1 { margin: 0; font-size: 22px; }
|
||||
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
|
||||
.receipt { background: #f8faff; border: 1px solid #dce8f8; border-radius: 6px; padding: 18px; margin: 18px 0; }
|
||||
.receipt table { width: 100%; border-collapse: collapse; }
|
||||
.receipt td { padding: 7px 0; }
|
||||
.receipt td:last-child { text-align: right; font-weight: bold; }
|
||||
.amount { font-size: 24px; font-weight: bold; color: #1A568C; }
|
||||
.badge-success { display: inline-block; background: #e6f4ea; color: #2e7d32; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: bold; }
|
||||
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✅ Cotisation confirmée</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
|
||||
<p>Nous avons bien reçu votre cotisation. <span class="badge-success">CONFIRMÉ</span></p>
|
||||
|
||||
<div class="receipt">
|
||||
<table>
|
||||
<tr><td>Organisation</td><td>{nomOrganisation}</td></tr>
|
||||
<tr><td>Période</td><td>{periode}</td></tr>
|
||||
<tr><td>Référence</td><td>{numeroReference}</td></tr>
|
||||
<tr><td>Mode de paiement</td><td>{methodePaiement}</td></tr>
|
||||
<tr><td>Date de paiement</td><td>{datePaiement}</td></tr>
|
||||
<tr><td>Montant</td><td><span class="amount">{montant} XOF</span></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>Conservez cet email comme justificatif de paiement.</p>
|
||||
<p>Cordialement,<br>L'équipe UnionFlow</p>
|
||||
</div>
|
||||
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
45
src/main/resources/templates/email/rappelCotisation.html
Normal file
45
src/main/resources/templates/email/rappelCotisation.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Rappel de cotisation</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
|
||||
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
|
||||
.header { background: #e65100; color: #fff; padding: 28px 32px; }
|
||||
.header h1 { margin: 0; font-size: 22px; }
|
||||
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
|
||||
.alert { background: #fff3e0; border-left: 4px solid #e65100; padding: 14px 18px; border-radius: 4px; margin: 18px 0; }
|
||||
.btn { display: inline-block; margin-top: 16px; padding: 12px 28px; background: #e65100; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold; }
|
||||
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>⚠️ Rappel de cotisation</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
|
||||
|
||||
<div class="alert">
|
||||
<strong>Votre cotisation pour la période {periode} est en attente de paiement.</strong>
|
||||
</div>
|
||||
|
||||
<p>Organisation : <strong>{nomOrganisation}</strong></p>
|
||||
<p>Montant dû : <strong>{montant} XOF</strong></p>
|
||||
<p>Date limite : <strong>{dateLimite}</strong></p>
|
||||
|
||||
{#if lienPaiement}
|
||||
<p>
|
||||
<a href="{lienPaiement}" class="btn">Payer ma cotisation</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p>Si vous avez déjà effectué ce paiement, veuillez ignorer ce message ou contacter votre trésorier.</p>
|
||||
<p>Cordialement,<br>L'équipe UnionFlow</p>
|
||||
</div>
|
||||
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Souscription confirmée</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
|
||||
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
|
||||
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
|
||||
.header h1 { margin: 0; font-size: 22px; }
|
||||
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
|
||||
.plan-card { background: #e8f0fe; border-radius: 8px; padding: 20px; margin: 18px 0; text-align: center; }
|
||||
.plan-name { font-size: 20px; font-weight: bold; color: #1A568C; }
|
||||
.plan-price { font-size: 28px; font-weight: bold; color: #1A568C; margin: 8px 0; }
|
||||
.features { margin: 16px 0; }
|
||||
.features li { padding: 4px 0; }
|
||||
.badge { display: inline-block; background: #e6f4ea; color: #2e7d32; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: bold; }
|
||||
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✅ Souscription activée</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>Bonjour <strong>{nomAdministrateur}</strong>,</p>
|
||||
<p>La souscription de votre organisation <strong>{nomOrganisation}</strong> a été activée avec succès. <span class="badge">ACTIF</span></p>
|
||||
|
||||
<div class="plan-card">
|
||||
<div class="plan-name">Plan {nomFormule}</div>
|
||||
<div class="plan-price">{montant} XOF / {periodicite}</div>
|
||||
</div>
|
||||
|
||||
<p><strong>Détails de la souscription :</strong></p>
|
||||
<ul class="features">
|
||||
<li>Date d'activation : {dateActivation}</li>
|
||||
<li>Date d'expiration : {dateExpiration}</li>
|
||||
<li>Membres maximum : {maxMembres}</li>
|
||||
<li>Stockage : {maxStockageMo} Mo</li>
|
||||
{#if apiAccess}<li>✓ Accès API REST</li>{/if}
|
||||
{#if supportPrioritaire}<li>✓ Support prioritaire</li>{/if}
|
||||
</ul>
|
||||
|
||||
<p>Cordialement,<br>L'équipe UnionFlow</p>
|
||||
</div>
|
||||
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user