Retour aux projets
Web + MobileEn coursCas d'étude

Djelimeet

SaaS multi-tenant de gouvernance de caisse commune pour associations, tontines et groupes communautaires ivoiriens : grand livre en partie double, retraits validés à 80% par le collège des chefs, réunions avec présence QR et PV, billetterie — sans jamais détenir l'argent (comptes marchands tenus par l'opérateur mobile money).

Djelimeet numérise la règle de gouvernance d'un groupe (association, tontine, mutuelle, amicale), pas l'argent lui-même : les fonds restent sur un compte marchand tenu chez un opérateur agréé (Wave au lancement), Djelimeet porte la preuve que la règle du groupe a été suivie — qui a décidé, à combien de voix, avec quelle trace.

Quatre promesses tiennent le produit : personne ne peut vider la caisse seul (retrait validé par au moins 80% des chefs actifs, hors demandeur) ; le journal ne se discute plus (grand livre en partie double, écritures non modifiables, correction par contre-passation) ; chacun rend compte de ce qu'il a dépensé (reddition de comptes après décaissement, lignes de justification visibles du groupe) ; la réunion se tient toute seule (convocation, présence par QR tournant HMAC, PV publié immuable).

Modèle économique : abonnement par groupe et par palier de membres, jamais de commission sur les flux — choix d'alignement produit et de positionnement réglementaire (éditeur de logiciel, pas émetteur de monnaie électronique).

Backend : AdonisJS 6 (TypeScript ESM), SQL brut paramétré sur PostgreSQL (pas d'ORM comme couche d'accès — les invariants forts vivent en contraintes/triggers SQL), Redis pour OTP/verrous, BullMQ pour les jobs asynchrones. Contrat d'API complet et validé : 147 opérations OpenAPI 3.1 sur 13 domaines. Frontend : Nuxt 4 + Nuxt UI 4 + Ionic (app web et mobile hybride à partir d'une seule base Vue), Pinia, Tailwind 4, i18n — espace membre, espace bureau et back-office système dans le même monorepo, sous-domaines séparés.

État réel : backend/API déclaré complet et validé par son propre contrat ; frontend Nuxt/Ionic en construction active (plus de 80 écrans déjà présents : auth, dashboard groupe, financier, réunions, événements, back-office admin).

Stack technique

Nuxt  · Frontend web/mobile hybride (Nuxt 4 + Ionic)Vue  · Framework composant (Vue 3, sous Nuxt)Nuxt UI  · Composants UI (Nuxt UI 4)Tailwind CSS  · Styles utilitaires (Tailwind CSS 4)TypeScript  · Langage backend et frontendNode.js  · Runtime backend (AdonisJS 6, TypeScript ESM)PostgreSQL  · Base de données — SQL brut paramétré, RLS, triggers d'immuabilitéRedis  · OTP, verrous courts, cache, file BullMQ

Feuille de route

  1. 1

    Cadrage

    Fait0/1 tâches terminées

    Livrables attendus : document de cadrage structuré (problème, objectifs mesurables, parties prenantes et leurs attentes, périmètre inclus/exclus, risques majeurs) rédigé sans ambiguïté — chaque terme métier est défini pour être compris par un humain OU un agent IA. Artefacts : cahier_des_charges (ébauche), regles_gestion (premières règles métier).

    Cadrage produit réalisé (business-pitch.html) : problème, segments, modèle économique, risques — avant l'écriture de l'architecture.

    7 oct. 2026

  2. 2

    Cahier des charges

    Fait1/1 tâches terminées

    Livrables attendus : exigences fonctionnelles priorisées (MUST/SHOULD/COULD) et traçables (id unique), exigences non fonctionnelles chiffrées (perf, dispo, sécurité), user stories au format « En tant que… je veux… afin de… » avec critères d'acceptation Given/When/Then. Artefacts : cahier_des_charges, user_stories, regles_gestion (numérotées RG-xx).

    Cahier des règles métier (79 écrans annotés) et cahier des charges dérivés de l'architecture — validés avant le lot 3 de développement.

    7 oct. 2026

  3. 3

    Maquettes

    En cours

    Livrables attendus : pour CHAQUE écran — identifiant (SCR-xx), objectif primaire, besoins primaires/secondaires, widgets souhaités (type, position, contenu, états vide/chargement/erreur), interactions et animations (déclencheur, durée, easing), règles responsive. S appuie sur le design system (tokens). Artefacts : maquette, design_system.

    79 écrans du canevas « Registre » déjà annotés de règles métier ; plus de 80 pages Nuxt déjà présentes dans djelimeet-web, design system extrait du code réel (palette brand/paper/sand/clay, rayon 0).

    7 oct. 2026

  4. 4

    Conception technique

    Fait1/1 tâches terminées

    Livrables attendus : diagramme d'architecture (composants, flux, protocoles), ERD structuré en données (champs typés, PK/FK, description obligatoire par champ), spec d'API avec pour CHAQUE route : schémas query params/body/success/error + exemples JSON lisibles par machine. Artefacts : diagramme_architecture, erd (data_entities), spec_api (api_endpoints), regles_gestion.

    Architecture (20 ADR), schéma de données (29 migrations, ~30 tables), contrat API (147 opérations/105 schémas) livrés et déclarés complets et validés.

    7 oct. 2026

  5. 5

    Développement

    En cours1/3 tâches terminées

    Livrables attendus : implémentation conforme à l ERD et à la spec d API (aucun endpoint hors spec), conventions de code documentées, commits tracés par référence d exigence ou RG-xx. Artefacts : changelog (incrémental).

    Backend : module financier et domaines codés selon CLAUDE.md. Frontend Nuxt/Ionic en construction active (auth, dashboard, financier, admin déjà présents ; intégration API et parcours complets à finaliser).

    7 oct. 2026

  6. 6

    Tests

    En cours0/1 tâches terminées

    Livrables attendus : un plan de test traçable (chaque RG-xx et chaque exigence MUST a au moins un test), matrice de couverture, cas limites des machines à états. Artefacts : guide_installation (environnement de test), post_mortem des incidents rencontrés.

    Suite de tests fonctionnels Japa existante mais bloquée par un défaut d'isolation OTP (voir post-mortem) — correction préalable à tout commit normal sur le dépôt backend.

    7 oct. 2026

  7. 7

    Mise en ligne

    À faire0/3 tâches terminées

    Livrables attendus : procédure de déploiement reproductible (pas de commande manuelle non documentée), variables d environnement listées avec leur rôle, rollback testé. Artefacts : guide_installation, changelog (version publiée).

    Pilote commercial (3 groupes, 90 jours) et activation RLS en production non encore démarrés — dépendent de la validation juridique et de la fin du frontend.

  8. 8

    Maintenance

    À faire

    Livrables attendus : post-mortem documenté pour chaque incident (timeline, cause racine, actions), veille technique tracée, règles de gestion et spec d API mises à jour avant tout changement de comportement.

    Pas encore d'exploitation en production — à activer après le pilote.

Artefacts de conception

Architectural Decision Records

ADR — Authentification par OTP sans mot de passe

Authentification par numéro de téléphone + OTP, sans mot de passe

  • Contexte : la cible (bureaux d'associations, tontines de quartier à Abidjan) est équipée en smartphones mais peu habituée aux mots de passe forts ; le numéro de téléphone est déjà l'identifiant universel du mobile money local (Wave, Orange Money). Un mot de passe oublié est le premier motif d'abandon d'une app grand public en Afrique de l'Ouest francophone, et sa récupération par email est peu fiable (adresses email secondaires, mal retenues).
  • Options envisagées :

    1. Email + mot de passe classique — familier pour un public technique, mais quasi inexistant comme identifiant stable chez la cible réelle (une association de quartier n'a pas d'email collectif) ; ajoute un flux de récupération de mot de passe à sécuriser et à maintenir.
    2. Numéro de téléphone + OTP (WhatsApp puis SMS), sans mot de passe — alignement total avec l'identifiant déjà utilisé pour le mobile money ; supprime toute la surface d'attaque liée aux mots de passe (réutilisation, fuite de base, phishing de mot de passe). Coût : dépendance à la délivrabilité WhatsApp/SMS (latence, coût d'envoi, zones mal couvertes), et un numéro perdu/volé devient le point unique de récupération de compte.
    3. OTP + mot de passe optionnel en secours — compromis qui réintroduit la complexité qu'on voulait éviter (deux chemins de login à sécuriser et à tester) pour un bénéfice marginal, la cible n'en demande pas.
  • Décision : option 2. OTP à 6 chiffres (WhatsApp d'abord, repli SMS à 20 s), haché en base, TTL 5 min, anti-abus (3 envois/numéro/h, 10/IP/h, 5 tentatives puis blocage 15 min). Un code PIN à 4 chiffres, distinct de l'OTP, protège en plus chaque opération financière — pas l'ouverture de l'app — pour qu'un téléphone déverrouillé ne vide pas une caisse à lui seul.
  • Conséquences :

    • Bénéfices : zéro mot de passe à stocker, zéro flux de récupération à sécuriser, parcours d'inscription aligné sur les habitudes mobile money déjà acquises par la cible ; le PIN ajoute une seconde barrière spécifiquement sur l'argent, sans alourdir l'usage quotidien non financier.
    • Coûts et risques acceptés : dépendance à un prestataire WhatsApp Business/SMS et à sa délivrabilité (coût direct par envoi, latence en zone mal couverte) ; un changement de numéro devient une opération sensible nécessitant OTP sur l'ancien ET le nouveau numéro, blocage des retraits 48 h et notification à tous les chefs de tous les groupes de l'utilisateur ; la sécurité du compte dépend in fine de la sécurité de la carte SIM (SIM swap), risque partagé avec tout système OTP-first.
    • Conditions de révision : si le coût d'envoi SMS/WhatsApp devient significatif à l'échelle, évaluer un second facteur alternatif (passkey, TOTP applicatif) en complément — jamais en remplacement du numéro comme identifiant, qui reste la promesse produit vis-à-vis de la cible.

ADR — SQL brut paramétré plutôt que Lucid ORM comme couche d'accès

SQL brut paramétré (db.rawQuery) plutôt que Lucid ORM comme couche d'accès aux données

  • Contexte : Djelimeet est construit sur AdonisJS 6, qui fournit Lucid comme ORM par défaut. Le cœur du produit (grand livre en partie double, immuabilité des écritures, unicité d'un vote de retrait, contraintes de classes de membres) repose sur des invariants qui doivent être garantis au niveau base — triggers (deny_ledger_mutation, assert_transaction_balanced, deny_audit_mutation), contraintes d'unicité composées, index partiels. Un ORM à mapping objet modéliserait ces invariants de façon imparfaite ou les laisserait entièrement à la charge du code applicatif, plus fragile.
  • Options envisagées :

    1. Lucid ORM pour tout le CRUD, SQL brut seulement pour les requêtes complexes — productivité élevée sur les domaines simples (group_settings, profils), mais introduit deux styles d'accès aux données dans la même base de code : incohérence qui rend plus difficile de savoir, pour un nouveau domaine, quelle convention suivre. Risque réel qu'un développeur presse par les délais utilise Lucid même sur une table financière, contournant un trigger sans le savoir.
    2. SQL brut paramétré partout (db.rawQuery / trx.rawQuery), Lucid réservé aux migrations (BaseSchema) — cohérence totale du style d'accès, les contraintes fortes vivent explicitement en SQL visible dans le code de service, aucune abstraction ne masque un verrou de ligne ou une transaction explicite. Coût : plus de code répétitif (mapping manuel ligne → objet), courbe d'apprentissage pour un développeur venu d'un monde Lucid/ActiveRecord.
    3. Un ORM distinct type Drizzle ou Prisma, plus proche du SQL — compromis entre les deux, mais change radicalement la stack déjà choisie (AdonisJS + Lucid pour les migrations) et n'était pas disponible au moment du choix initial du module financier déjà livré.
  • Décision : option 2. Le module financier déjà codé (app/finance/) sert de modèle de convention : tout accès aux données passe par db.rawQuery/trx.rawQuery avec requêtes paramétrées, y compris pour les domaines qui n'ont pas de contrainte forte (ex. CRUD de group_settings) — un compromis documenté en commentaire de tête de fichier plutôt qu'un choix silencieux, mais la cohérence prime sur l'optimisation locale.
  • Conséquences :

    • Bénéfices : les invariants les plus critiques (équilibre d'une transaction, immuabilité du grand livre, unicité d'un vote) sont visibles et vérifiables directement dans les migrations SQL, indépendamment du code applicatif — un audit de sécurité peut les relire sans comprendre Lucid. Un seul style de requête à auditer pour la revue pre-commit automatisée.
    • Coûts et risques acceptés : plus de code de mapping manuel à écrire et maintenir par service ; onboarding plus long pour un développeur habitué à un ORM classique ; risque d'injection SQL si la discipline de paramétrage n'est pas respectée scrupuleusement (mitigé par la revue automatique pre-commit qui cherche spécifiquement le SQL brut non paramétré).
    • Conditions de révision : si un domaine purement CRUD sans contrainte d'intégrité (ex. paramètres d'affichage utilisateur) s'avère disproportionnellement coûteux à écrire en SQL brut, documenter l'exception en tête de fichier plutôt que de généraliser Lucid silencieusement.

ADR — Isolation multi-tenant par Row-Level Security PostgreSQL

Isolation multi-tenant par RLS PostgreSQL, en plus du middleware applicatif

  • Contexte : le tenant est le groupe. Une fuite d'un groupe vers un autre (un membre qui verrait la caisse ou le fil d'un groupe auquel il n'appartient pas) est qualifiée dans le cahier des règles métier comme « la classe de bug la plus coûteuse du projet » — un seul oubli de filtre WHERE group_id = ? dans une requête, sur une centaine de routes, suffit à l'exposer. Un schéma PostgreSQL séparé par tenant a été écarté dès le cahier d'architecture (ADR-01) : ingérable dès 500 groupes (migrations × N).
  • Options envisagées :

    1. Un schéma partagé, isolation uniquement par filtre applicatif (WHERE group_id = ? dans chaque requête) — le plus simple à écrire au départ, mais entièrement dépendant de la discipline humaine : un seul service ou une seule requête ad hoc qui oublie le filtre expose tout. Aucune deuxième barrière si la première échoue.
    2. Un schéma par tenant — isolation maximale nativement, mais migrations × N groupes et connexions dispersées ; écarté comme ingérable à l'échelle visée (ADR-01).
    3. Schéma partagé + trois barrières indépendantes : middleware de résolution du tenant (lit group_id dans l'URL, jamais dans le corps), scope obligatoire côté accès aux données, et Row-Level Security PostgreSQL sur les tables financières (et étendue à group_settings, posts, meetings, minutes, events, etc.) — la RLS agit comme filet de sécurité au niveau moteur de base, indépendant du code applicatif : même une requête qui oublierait le filtre applicatif ne verrait que les lignes du tenant courant, posé via SELECT set_current_group('<uuid>') en SET LOCAL.
  • Décision : option 3. group_id sur toute table portée par un groupe, RLS créée dès les migrations mais activée table par table une fois le middleware de tenant posé et testé (choix explicite de séquencement, pas un oubli) ; certaines tables enfants sans group_id propre (post_media, comments, attendances, tickets...) restent protégées par les deux premières barrières seulement, leur accès ne passant jamais que par leur parent déjà couvert par la RLS.
  • Conséquences :

    • Bénéfices : une fuite entre groupes nécessiterait l'échec simultané de trois mécanismes indépendants plutôt que d'un seul oubli de WHERE ; la RLS protège aussi contre un accès direct à la base (script de debug, outil d'administration) qui contournerait la couche applicative ; le modèle reste un schéma unique, donc les migrations restent linéaires quel que soit le nombre de groupes.
    • Coûts et risques acceptés : le rôle applicatif PostgreSQL ne doit jamais être SUPERUSER ni BYPASSRLS, ce qui impose une discipline d'exploitation (séparation des rôles base de données) à maintenir dans le temps ; chaque transaction doit explicitement poser le groupe courant en SET LOCAL, un oubli désactive silencieusement la troisième barrière pour cette transaction (bien que les deux premières restent actives) ; les tables enfants sans group_id dénormalisé restent un angle mort documenté — accepté tant qu'aucune requête ne les interroge directement hors de leur parent ; un accès back-office légitime multi-groupes (modérateur système) nécessite un rôle PostgreSQL séparé ou une politique additionnelle, point explicitement laissé ouvert avant l'activation de la RLS en production.
    • Conditions de révision : activer ENABLE ROW LEVEL SECURITY table par table seulement après avoir testé le middleware de tenant sur cette table ; trancher le rôle back-office multi-groupes (rôle BYPASSRLS séparé vs politique dédiée) avant la mise en production, pas après.

ADR — Retraits à double validation, seuil 80 % des chefs actifs

Retrait de caisse validé par au moins 80 % des chefs actifs, collège figé à l'ouverture

  • Contexte : la promesse centrale du produit est « personne ne peut vider la caisse seul ». Une banque règle ce problème depuis un siècle avec la double signature ; le mobile money, en rendant le transfert instantané et individuel, a rouvert le problème pour les groupes communautaires. Le mécanisme doit rester praticable pour un petit groupe (parfois un seul chef au démarrage) tout en étant réellement collégial pour un groupe structuré.
  • Options envisagées :

    1. Unanimité systématique de tous les chefs — maximise la sécurité mais bloque tout retrait dès qu'un seul chef est injoignable (voyage, téléphone perdu) ; inadapté à un collège de 6+ chefs où l'unanimité devient statistiquement rare dans la fenêtre utile.
    2. Seuil fixe (ex. 2 signatures) indépendant de la taille du collège — simple, mais dilue la protection à mesure que le collège grandit (2 voix sur 10 chefs n'est plus une décision collégiale) et ne reflète pas l'intention réelle des bureaux.
    3. Seuil proportionnel ceil(0.8 × N), N = chefs actifs hors demandeur, figé à l'ouverture de la demande — s'adapte à la taille réelle du collège, équivaut à l'unanimité sous 4 chefs (où 80 % n'a pas de sens fractionnaire autre que « tous »), et le fait de figer N à l'ouverture empêche qu'une nomination de chef en cours de vote ne déplace discrètement le seuil requis.
  • Décision : option 3 (ADR-06 du cahier d'architecture). Table de référence : N=1→1, N=2→2, N=3→3, N=4→4, N=5→4, N=6→5. L'écran annonce toujours le seuil en fraction (« 3 approbations sur 3 »), jamais en pourcentage, pour rester lisible sous 4 chefs. Un chef qui est aussi le demandeur ne vote pas et ne compte pas dans N (séparation des pouvoirs) ; un chef ne peut pas se retirer du collège pendant un vote en cours ; modifier la demande après un premier vote invalide tous les votes émis.
  • Conséquences :

    • Bénéfices : le mécanisme reste praticable pour une tontine naissante (1 chef) tout en devenant réellement collégial à mesure que le groupe se structure ; figer le collège à l'ouverture élimine une classe entière de manipulation (nommer un chef favorable juste avant la décision finale) ; le non-quorum sous 72 h restitue intégralement les fonds, donc aucune caisse ne reste bloquée indéfiniment par l'indécision.
    • Coûts et risques acceptés : un groupe avec un seul chef (cas de démarrage encouragé mais réel) n'a aucune protection collégiale réelle — le produit l'annonce explicitement à l'écran plutôt que de le masquer, mais le risque existe tant que le bureau ne nomme pas d'autres chefs ; le calcul du seuil doit être recalculé et vérifié à chaque nouvelle demande, ce qui ajoute une étape de lecture (compter les chefs actifs) avant toute ouverture de retrait ; une attaque sociale visant à faire nommer puis retirer rapidement des chefs entre deux demandes reste un vecteur théorique, non totalement neutralisé par le seul figeage à l'ouverture.
    • Conditions de révision : le seuil et son mécanisme de double validation ne doivent jamais être changés sans demande explicite (règle écrite dans CLAUDE.md) ; au niveau plateforme, un changement de ce paramètre suit lui-même le même schéma à deux signatures distinctes que pour un groupe (platform_setting_change_requests/...approvals), pour qu'aucune modification de cet invariant ne dépende d'une seule personne côté exploitant non plus.

ADR — Grand livre en partie double, écritures immuables

Grand livre en partie double avec immuabilité stricte des écritures

  • Contexte : Djelimeet affiche des soldes réels en francs CFA pour des groupes qui n'ont ni comptable ni DAF. Le risque fatal identifié (pitch produit, partie 08) est « un incident financier public : un retrait exécuté deux fois, un solde faux » — ce type d'incident détruirait la réputation du produit dans un marché où « tout se sait ». Un simple champ balance incrémenté par chaque mouvement ne permet ni de prouver un historique ni de détecter un bug de double-crédit après coup.
  • Options envisagées :

    1. Solde en champ mutable, historique de transactions en table de log séparée (best-effort) — plus simple et rapide à implémenter, mais le solde affiché devient la source de vérité : un bug applicatif qui l'incrémente mal corrompt silencieusement l'état, sans moyen de rejouer ou d'auditer un recalcul fiable depuis zéro.
    2. Grand livre en partie double (ledger_entries), solde = cache recalculable, écritures non modifiables/supprimables par trigger — chaque mouvement est une écriture équilibrée (débit = crédit), une erreur se corrige par contre-passation visible, jamais par réécriture. Coût : complexité de modélisation et de requête plus élevée (toute lecture de solde est une agrégation, pas un SELECT direct), discipline transactionnelle stricte requise dans chaque service qui écrit au journal.
    3. Partie double mais sans triggers d'immuabilité (confiance dans la discipline applicative seule) — même modèle de données que l'option 2 mais sans garde-fou en base ; un bug ou un accès direct à la base (migration manuelle, script de hotfix) pourrait modifier une écriture sans qu'aucune couche ne s'y oppose.
  • Décision : option 2. Le solde affiché est un cache recalculable à partir de ledger_entries, jamais la source de vérité. Les triggers PostgreSQL deny_ledger_mutation() et assert_transaction_balanced() interdisent l'UPDATE/DELETE sur les écritures et refusent toute transaction déséquilibrée, y compris pour un compte applicatif disposant de tous les droits — la garantie vit en base, pas seulement dans le code.
  • Conséquences :

    • Bénéfices : un audit externe (régulateur, investisseur, bureau d'association méfiant) peut vérifier l'intégralité de l'historique sans faire confiance au code applicatif ; une erreur ne disparaît jamais, elle se corrige visiblement ; la réconciliation quotidienne avec le relevé de l'opérateur devient un simple contrôle d'écart entre deux sources indépendantes plutôt qu'une tentative de recoller un solde opaque.
    • Coûts et risques acceptés : chaque lecture de solde (wallet, caisse) nécessite une agrégation sur ledger_entries, qu'il faut indexer et éventuellement mettre en cache (lecture peu volatile, invalidation explicite à l'écriture — jamais de TTL seul) ; tout nouveau domaine financier doit passer par le point d'écriture unique (LedgerService.post()), ce qui impose une discipline de revue systématique sur ce fichier, qui devient un goulot d'étranglement organisationnel volontaire.
    • Conditions de révision : aucune prévue — c'est un invariant « socle » du cahier d'architecture (ADR-03) ; toute évolution qui toucherait au sens des débits/crédits ou à l'immuabilité doit être explicitement demandée et documentée, jamais glissée dans un autre changement (règle écrite dans CLAUDE.md du dépôt).

Règles de gestion

Règles de gestion — Djelimeet (RG-01 à RG-35)

Identité et RBAC

RG-01 — Numéro de téléphone, identifiant unique

  • L'inscription et la connexion se font exclusivement par numéro de téléphone normalisé E.164 ⇒ aucun email, aucun mot de passe n'est jamais demandé. Sinon : toute tentative de créer un compte par email est rejetée dès la validation d'entrée.
  • Portée : users.msisdn (unique) · POST /auth/otp/request · écrans Bienvenue/Numéro.

RG-02 — OTP anti-abus

  • Une demande de code ⇒ envoi WhatsApp, repli SMS si non délivré en 20 s, code à 6 chiffres haché en base, TTL 5 min. Au-delà de 3 envois/numéro/h ou 10 envois/IP/h ⇒ 429 RateLimited. Après 5 tentatives de vérification erronées ⇒ invalidation du code et blocage de 15 min.
  • Portée : table OTP (Redis) · POST /auth/otp/request, POST /auth/otp/verify.

RG-03 — PIN distinct de l'OTP, jamais lisible par l'admin

  • Un PIN à 4 chiffres protège chaque opération financière (pas l'ouverture de l'app) ⇒ requis sur retrait, vote, paiement par wallet. L'API ne renvoie et ne journalise jamais le PIN en clair ; aucun endpoint back-office ne l'expose, sous aucune forme.
  • Portée : users.pin_hash · PUT /me/pin · toutes les routes financières sensibles.

RG-04 — Session à rotation

  • Un refresh token est opaque, lié à l'appareil, valide 30 j, et tourne à chaque usage ⇒ la réutilisation d'un refresh déjà tourné révoque toute la famille de tokens (401, reconnexion complète requise).
  • Portée : auth_sessions, devices · POST /auth/refresh.

RG-05 — Changement de numéro sous contrôle

  • Un changement de numéro exige un OTP validé sur l'ancien ET le nouveau numéro ⇒ retraits bloqués 48 h après confirmation, notification automatique à tous les chefs de tous les groupes de l'utilisateur. Sinon : POST /me/phone-change/confirm échoue si l'un des deux codes est manquant ou expiré.
  • Portée : users · POST /me/phone-change/request, POST /me/phone-change/confirm.

RG-06 — Cloisonnement strict entre groupes

  • L'identité (profil, wallet) est globale ; tout le reste (fil, caisse, membres, réunions) est cloisonné par group_id ⇒ rejoindre un groupe n'expose au bureau que nom et numéro du nouveau membre, jamais ses autres groupes ni son solde personnel. Sinon : 403 sur toute tentative de lecture croisée.
  • Portée : toutes les tables group_id · middleware de résolution de tenant.

RG-07 — RBAC cumulatif, rôle « membre » permanent

  • Les rôles d'une adhésion se cumulent (ex. secrétaire + chef) ⇒ la permission effective est l'union des rôles ; le rôle « membre » ne peut jamais être retiré. Sinon : toute tentative de désactiver le rôle « membre » est rejetée en 422.
  • Portée : memberships, membership_roles · POST /groups/{groupId}/members/{membershipId}/roles.

RG-08 — Séparation des pouvoirs sur le vote de retrait

  • Un chef qui est aussi l'auteur (ou trésorier demandeur) d'une demande de retrait ne vote pas sur sa propre demande et ne compte pas dans le quorum requis ⇒ 403 en cas de tentative. Un chef ne peut pas se retirer lui-même du collège pendant un vote en cours ⇒ 409.
  • Portée : withdrawal_requests, withdrawal_approvals · POST /groups/{groupId}/withdrawal-requests/{withdrawalId}/approvals.

Groupes

RG-09 — Création de groupe et premier chef

  • Le créateur d'un groupe en devient automatiquement le premier chef, avec pouvoir de nommer trésorier, secrétaire et autres chefs ⇒ tant qu'il reste seul chef, un retrait ne nécessite que sa seule voix (N=1 ⇒ seuil=1), avec avertissement explicite à l'écran.
  • Portée : groups, memberships · POST /groups.

RG-10 — Visibilité et annuaire

  • Un groupe private n'apparaît dans aucune recherche ni annuaire ; on y entre uniquement sur invitation nominative. Le repassage en privé purge le cache public et désindexe la fiche. Sinon : GET /groups/directory ne retourne jamais un groupe privé.
  • Portée : groups.visibility · GET /groups/directory, GET /groups/{groupId}/public.

RG-11 — Invitation et demande d'adhésion

  • Une invitation entre toujours avec le rôle « membre », expire après 14 jours, et peut être renvoyée ⇒ l'invité voit avant d'accepter exactement ce que le groupe verra de lui (nom, numéro). Une demande via l'annuaire public passe obligatoirement par une validation du bureau — jamais d'entrée immédiate. Sinon : 410 sur une invitation expirée.
  • Portée : invitations, join_requests · POST /groups/{groupId}/invitations, POST /groups/{groupId}/join-requests/{joinRequestId}/decide.

RG-12 — Avis des bureaux, anonymisés

  • Un avis par groupe et par personne (contrainte d'unicité), rédigé par un chef, modifiable, et seulement après 30 jours d'adhésion. La lecture inter-groupes d'un profil ne renvoie jamais group_id ni l'auteur — seulement note, commentaire, date.
  • Portée : member_reviews · GET /groups/{groupId}/join-requests/{joinRequestId}/applicant, POST /groups/{groupId}/members/{membershipId}/review.

Finance et grand livre

RG-13 — Montants entiers, jamais de décimale

  • Tous les montants sont des entiers (bigint) en francs CFA ⇒ aucun champ de type float ou numeric flottant n'est toléré dans le schéma financier. Sinon : rejet de validation à l'écriture.
  • Portée : tous les champs *Xof · plan de comptes (accounts).

RG-14 — Écritures en partie double, immuables

  • Toute variation de solde est une écriture équilibrée dans ledger_entries ⇒ aucun solde n'est un champ qu'on incrémente directement ; une erreur se corrige par une contre-passation référençant l'écriture d'origine, jamais par une modification. Sinon : le trigger deny_ledger_mutation() bloque l'UPDATE/DELETE au niveau PostgreSQL, y compris pour l'administration système.
  • Portée : ledger_entries, transactions · GET /groups/{groupId}/journal.

RG-15 — Wallet et caisse créés automatiquement

  • Le wallet d'un membre se crée à l'inscription ; la caisse et le compte de fonds gelés d'un groupe se créent à la création du groupe. Sinon : aucune route financière de groupe n'est accessible avant la création effective de ces comptes.
  • Portée : accounts (kind member_wallet, group_treasury, group_frozen_funds).

RG-16 — Frais de rechargement à la charge du membre

  • Un rechargement applique les frais opérateur à la charge du membre, affichés avant confirmation (« vous rechargez 10 000 F, total à payer 10 100 F »). Sinon : l'intention de paiement n'est créée qu'après acceptation explicite du montant total affiché.
  • Portée : payment_intents · POST /me/wallet/topups.

RG-17 — Webhook jamais autorité sur le montant

  • Le montant annoncé par l'opérateur est toujours comparé à celui de l'intention enregistrée en base ⇒ en cas d'écart, la transaction part en litige, jamais d'ajustement silencieux. Rejouer un webhook ou un appel réseau ne crédite jamais deux fois (idempotence par clé unique topup:<intentId>).
  • Portée : payment_intents, provider_events · webhook waveCheckoutCompleted.

RG-18 — Cotisation : wallet gratuit, opérateur avec frais

  • Une cotisation payée depuis le wallet interne est gratuite (changement de propriétaire au journal, sans mouvement chez l'opérateur, PIN requis) ; payée directement via l'opérateur, elle porte les frais de rechargement. En cas d'échec de paiement : rien n'est prélevé, la cotisation reste due sans pénalité.
  • Portée : contributions, contribution_payments · POST /groups/{groupId}/contributions/{contributionId}/payments.

RG-19 — Abonnement impayé : dégradation, jamais de blocage

  • Un abonnement Djelimeet impayé dégrade le service (publication suspendue, plus de nouvelles cotisations ni d'événements) mais ne bloque jamais la lecture du journal ni le retrait des fonds d'un groupe. Règle non négociable, écrite dans le code, pas seulement dans les CGU.
  • Portée : subscriptions.status = unpaid · toutes les routes de lecture financière.

Retraits à double validation (seuil 80 %)

RG-20 — Calcul du seuil, collège figé à l'ouverture

  • À l'ouverture d'une demande, N = chefs actifs hors demandeur, requiredApprovals = ceil(0.8 × N) est figé immédiatement (table : N=1→1, N=2→2, N=3→3, N=4→4, N=5→4, N=6→5). Sous 4 chefs, 80 % veut dire l'unanimité — toujours annoncée en fraction (« 3 approbations sur 3 »), jamais en pourcentage.
  • Portée : withdrawal_requests.required_approvals · POST /groups/{groupId}/withdrawal-requests.

RG-21 — Un chef, une voix

  • Contrainte d'unicité (withdrawal_id, approver_membership_id) ⇒ rejouer l'appel d'approbation ne double jamais le vote. Modifier la demande (montant, bénéficiaire, motif) après un premier vote invalide tous les votes déjà émis, qui doivent être reproposés.
  • Portée : withdrawal_approvals · PATCH /groups/{groupId}/withdrawal-requests/{withdrawalId}.

RG-22 — Le vote porte sur le total sortant, frais compris

  • Le montant mis au vote est toujours amountXof + feeEstimateXof (total qui sort de la caisse), jamais le seul montant net au bénéficiaire. L'écart entre frais estimés et frais réels est écrit séparément (operator_fees), jamais fondu dans le montant du retrait.
  • Portée : withdrawal_requests.fee_estimate_xof / fee_actual_xof.

RG-23 — Bénéficiaire contraint à un membre actif

  • Le bénéficiaire d'un retrait est obligatoirement un membre actif du groupe, référencé par clé étrangère ⇒ jamais un numéro de téléphone libre saisi à la main. Sinon : 422 à la création de la demande.
  • Portée : withdrawal_requests.beneficiary_membership_id.

RG-24 — Liquidité insuffisante : jamais d'échec silencieux

  • Avant exécution, le float du compte marchand doit couvrir montant + frais et respecter le plafond journalier de l'opérateur ⇒ sinon la demande passe en awaiting_liquidity : les fonds restent gelés, personne n'est débité, l'exploitation est alertée.
  • Portée : withdrawal_requests.status = awaiting_liquidity.

RG-25 — Expiration à 72 h sans quorum

  • Une demande sans quorum atteint sous 72 h expire automatiquement ⇒ fonds intégralement restitués en caisse, notification collective. Pas d'exclusion automatique du collège pour un chef silencieux ; la demande est reproposable.
  • Portée : withdrawal_requests.status = expired, expires_at · job planifié.

RG-26 — Reddition de comptes séparée du grand livre

  • Un décaissement n'est terminé que « soldé » : lignes de justification (libellé, montant, date, justificatif facultatif) saisies par le bénéficiaire, visibles par tout le groupe. Ces lignes ne touchent jamais le grand livre ni l'équation de bilan — un témoignage, pas une écriture. Un reliquat se reverse en caisse par un rechargement ordinaire référencé au décaissement d'origine.
  • Portée : disbursement_lines · POST /groups/{groupId}/withdrawal-requests/{withdrawalId}/disbursement-lines, .../return-remainder.

Réunions et PV

RG-27 — QR de présence tournant, double scan sans effet

  • Le QR de présence est un jeton HMAC régénéré toutes les 60 s ; le scanner valide hors ligne avec une dérive tolérée de ±2 fenêtres. La contrainte d'unicité (meeting_id, membership_id) rend le double pointage (QR puis manuel) sans effet, par construction.
  • Portée : attendances · POST /groups/{groupId}/meetings/{meetingId}/attendances/scan.

RG-28 — PV publié immuable

  • Un PV publié n'est plus modifiable ; toute correction crée une nouvelle version visible de tous (même mécanisme d'immuabilité que le journal financier). La liste de présence d'un PV est reprise automatiquement du pointage électronique, jamais ressaisie.
  • Portée : minutes, minute_versions · POST /groups/{groupId}/meetings/{meetingId}/minutes/publish.

Billetterie et événements

RG-29 — Quota décrémenté par écriture atomique

  • Un quota de billets se décrémente par écriture conditionnelle (jamais lecture puis écriture) ; la jauge globale et celle de chaque formule sont contraignantes, la plus stricte gagne. Une place sélectionnée est tenue 10 minutes le temps du paiement, libérée automatiquement et sans frais si le délai expire.
  • Portée : ticket_tiers, orders.status = held · POST /events/{eventId}/orders.

RG-30 — Billet à usage unique, recettes vers la caisse du groupe

  • Le contrôle d'entrée fait une écriture conditionnelle (« déjà utilisé, entré à 20h14 » plutôt qu'un refus muet) ; fonctionne hors ligne. Les recettes de billetterie vont toujours à la caisse du groupe, jamais à un portefeuille personnel, y compris celui de l'organisateur.
  • Portée : tickets, ticket_scans · POST /events/{eventId}/scan.

Abonnement

RG-31 — Palier mesuré à l'échéance, jamais en cours de cycle

  • Le palier d'abonnement se mesure uniquement à la date d'échéance ⇒ recruter 20 membres en janvier ne facture rien avant l'échéance de février. « Membre actif » = adhésion active à la date d'échéance (un membre suspendu ou parti ne compte pas).
  • Portée : subscriptions.members_at_renewal · job generateRecurringInvoices().

RG-32 — Prélèvement d'abonnement toujours autorisé explicitement

  • Le prélèvement se fait sur la caisse du groupe, autorisé explicitement par un chef à la souscription (ou alternativement depuis le wallet personnel d'un chef) ⇒ jamais de prélèvement silencieux. Un échec documente sa cause : insufficient_funds (caisse vide) ou not_authorized (aucun chef actif n'a autorisé).
  • Portée : subscriptions.charge_source, invoices.failure_reason.

Social et modération

RG-33 — Publication publique explicite uniquement

  • Un post n'apparaît sur le site public que si is_public = true est explicitement posé ⇒ jamais par défaut. Toute action de modération masque le contenu à l'affichage (deleted_at) mais le conserve en base — jamais de suppression physique.
  • Portée : posts.is_public, moderation_actions · GET /me/feed (public).

RG-34 — Un signalement ouvert par signaleur et par contenu

  • Un même utilisateur n'a qu'un signalement ouvert par contenu (index partiel WHERE status = 'open') ⇒ un double envoi hors ligne n'ajoute rien au compteur de la file de modération. Le motif est un code fermé (defamation, off_topic, scam, harassment, spam, inappropriate, other), la précision libre va dans details.
  • Portée : reports.reason, reports_open_once_uniq · POST /reports.

Notifications et administration

RG-35 — Catégories financières non désactivables, double validation du seuil plateforme

  • Les catégories de notification contribution, withdrawal et subscription ne peuvent jamais être désactivées (contrainte notification_preferences_financial_locked en base) ⇒ 422 sur toute tentative. Un changement du seuil des 80 % au niveau plateforme suit le même schéma qu'un retrait de groupe : une demande, des approbations distinctes, appliquée seulement à deux signatures.
  • Portée : notification_preferences, platform_setting_change_requests + platform_setting_change_approvals · PUT /me/notification-preferences, POST /admin/platform-settings/change-requests/{changeRequestId}/approve.

Cahier des charges

Cahier des charges — Djelimeet

1. Contexte et problème

En Côte d'Ivoire, l'argent des associations, tontines et groupes communautaires circule déjà par mobile money, mais la décision collective qui l'entoure reste orale et invérifiable : un trésorier tient un cahier, personne ne peut lui donner tort ni raison, et ce climat de soupçon — plus que la fraude elle-même — use les groupes jusqu'à leur extinction. Les outils existants (compte mobile money personnel, groupe WhatsApp, cahier/tableur, compte bancaire associatif) ne couvrent chacun qu'un morceau du problème : aucun ne porte la gouvernance (qui décide, à combien de voix, avec quelle trace). Djelimeet numérise cette règle de gouvernance — pas l'argent, qui reste chez l'opérateur mobile money agréé.

2. Objectifs mesurables

  • Réduire à zéro les sorties de caisse non collégiales : 100 % des retraits de groupe passent par un vote à seuil (≥ 80 % des chefs actifs), sans exception technique possible (contrainte en base, pas seulement en écran).
  • Fiabilité du grand livre : 0 écriture modifiable ou supprimable après coup, vérifié par triggers PostgreSQL (deny_ledger_mutation, assert_transaction_balanced) sur 100 % des tables financières.
  • Idempotence : un rejeu de webhook ou d'appel réseau ne crédite jamais deux fois un wallet ou une caisse (0 double-crédit toléré en production).
  • Délai de traitement d'une demande de retrait sans quorum : expiration automatique sous 72 h, restitution à 100 % des fonds gelés.
  • Couverture du contrat d'API : 147 opérations OpenAPI 3.1 documentées sur 13 domaines, chaque route livrée correspond exactement à une opération du contrat (0 route hors spec).
  • Pilote commercial (horizon 90 jours post-lancement) : 3 groupes pilotes signés, mesure du taux de couverture opérateur chez les membres (seuil cible ≥ 80 % pour que le module financier soit viable) et du délai d'obtention du contrat marchand.

3. Parties prenantes

RôleAttentes
MembreVisibilité sur la caisse de ses groupes, paiement de cotisation simple, confiance que son argent ne peut pas disparaître sans trace.
TrésorierNe plus porter seul la charge de la preuve ; un journal qui parle pour lui en assemblée générale.
Chef (bureau)Pouvoir de décision collégiale réel sur les sorties de fonds ; outil de gouvernance, pas de comptabilité d'expert-comptable.
SecrétaireRéunions qui se convoquent et se documentent seules (présence QR, PV).
ModérateurTraiter les signalements de contenu de son groupe sans voir les autres groupes.
Back-office système (support, finance_admin, moderator, super_admin)Superviser la plateforme (facturation, réconciliation, litiges) sans jamais pouvoir lire un PIN ni modifier une écriture.
Opérateur mobile money (Wave)Rester le seul détenteur réel des fonds — Djelimeet ne doit jamais être requalifié en émetteur de monnaie électronique.
Investisseur / partenaireModèle économique soutenable (abonnement, pas de commission sur les flux) et défendable réglementairement.

4. Glossaire

TermeDéfinition
TenantLe groupe (association, tontine...) ; toute donnée cloisonnée par group_id.
Collège des chefsEnsemble des membres actifs portant le rôle « chef » au moment de l'ouverture d'une demande de retrait ; figé à cet instant (ADR-06).
Seuil des 80 %requiredApprovals = ceil(0.8 × N), N = chefs actifs hors demandeur.
Grand livre (ledger)Ensemble des écritures en partie double (ledger_entries) ; seule source de vérité des soldes, jamais modifiable.
FloatSolde réel détenu chez l'opérateur mobile money pour le compte marchand Djelimeet ; modélisé comme un compte du plan comptable, pas un solde caché.
Reddition de comptesJustification d'un décaissement par des lignes de dépense déclarées par le bénéficiaire — jamais des écritures comptables.
Palier (abonnement)Tranche de tarification par nombre de membres actifs à l'échéance (Découverte / Association / Fédération / Grand groupe).
RLSRow-Level Security PostgreSQL ; isolation d'un tenant au niveau base, en plus du middleware applicatif.
OTPCode à usage unique à 6 chiffres, canal WhatsApp d'abord puis SMS, utilisé pour l'authentification et la re-vérification des votes.

5. Périmètre

Inclus (v1) : identité par numéro de téléphone (OTP, PIN), groupes multi-tenant avec RBAC cumulatif, grand livre en partie double, wallet et rechargement (Wave), cotisations (montant fixe ou cagnotte libre, tarifs par classe de membre), retraits à 80 % avec reddition de comptes, réunions avec présence QR et PV, billetterie d'événements (formules, contrôle d'entrée), fil social modéré, sondages, notifications, abonnement par groupe/palier, back-office système (facturation, réconciliation, litiges, modération).

Exclus (hors v1) : catégories comptables et budgets prévisionnels, deuxième opérateur mobile money branché (architecture prête, intégration non faite), commission sur les flux comme source de revenu, versionnement de rupture de l'API (/v2), notifications financières désactivables sous quelque forme que ce soit, module tontine dédié (praticable aujourd'hui via cotisation + retrait, sans schéma spécifique).

6. Exigences fonctionnelles

IDDescriptionPrioritéRG liées
EXF-01Un utilisateur s'inscrit et se connecte uniquement par numéro de téléphone + OTP, jamais par mot de passe.MUSTRG-01, RG-02
EXF-02Un code PIN à 4 chiffres protège chaque opération financière, jamais l'ouverture de l'app, et n'est jamais lisible par l'administration.MUSTRG-03
EXF-03Un membre peut appartenir simultanément à plusieurs groupes, chacun cloisonné (fil, caisse, membres, réunions).MUSTRG-06, RG-14
EXF-04Les rôles de groupe (membre, secrétaire, trésorier, modérateur, chef) se cumulent au sein d'une adhésion ; la permission effective est l'union des rôles.MUSTRG-07
EXF-05Un trésorier peut ouvrir une demande de retrait avec motif et devis chiffré ; les fonds sont gelés immédiatement.MUSTRG-19, RG-22
EXF-06Chaque chef actif (hors demandeur et hors trésorier demandeur) vote une seule fois sur une demande de retrait, après ré-authentification OTP.MUSTRG-08, RG-20
EXF-07Une demande de retrait atteint l'exécution seulement si approvalsCount ≥ requiredApprovals ; sinon elle expire sous 72 h et restitue les fonds.MUSTRG-19, RG-24
EXF-08Un décaissement exécuté reste « à solder » jusqu'à ce que le bénéficiaire déclare des lignes de justification.MUSTRG-25
EXF-09Toute variation de solde (cotisation, retrait, abonnement, billetterie) produit une écriture en partie double, jamais un simple incrément de champ.MUSTRG-09, RG-10
EXF-10Un webhook de paiement rejoué (Wave) ne crédite jamais deux fois le même wallet ou la même caisse.MUSTRG-11, RG-17
EXF-11Un secrétaire peut planifier une réunion, générer un QR de présence tournant (60 s) et publier un PV immuable.MUSTRG-26, RG-30
EXF-12Un organisateur peut créer un événement à formules multiples (Standard/VIP/VVIP), vendre des billets avec réservation temporaire de 10 minutes.MUSTRG-27, RG-28
EXF-13Un groupe peut publier sur son fil interne, marquer explicitement un contenu comme public pour le site vitrine.SHOULDRG-31
EXF-14Un membre peut signaler un contenu ; un modérateur de groupe traite les signalements de son groupe, un modérateur système voit tous les groupes.MUSTRG-32
EXF-15Un groupe souscrit un abonnement par palier de membres, prélevé sur la caisse avec autorisation explicite d'un chef.MUSTRG-29
EXF-16Les alertes financières (cotisation, vote de retrait, retrait exécuté/échoué, abonnement) ne sont jamais désactivables par l'utilisateur.MUSTRG-13
EXF-17Le back-office système peut changer le seuil des 80 % uniquement via une double validation à deux signatures distinctes.MUSTRG-35
EXF-18Un bureau peut consulter la description de profil et les avis anonymisés d'un demandeur d'adhésion avant de l'accepter.SHOULDRG-33

7. Exigences non fonctionnelles

IDCatégorieCritère chiffré
EXN-01Sécurité — authOTP : TTL 5 min, 3 envois/numéro/h, 10 envois/IP/h, 5 tentatives puis blocage 15 min.
EXN-02Sécurité — sessionAccess token 15 min, refresh token opaque 30 j, rotation à chaque usage, réutilisation d'un refresh révoqué = révocation de toute la famille.
EXN-03Disponibilité — réconciliationRapprochement des webhooks Wave en retard : sweep périodique toutes les 5 min, écart toujours tracé (jamais d'ajustement silencieux).
EXN-04Performance — présenceQR de présence régénéré toutes les 60 s, scan validé hors ligne avec dérive d'horloge tolérée de ±2 fenêtres (±120 s).
EXN-05Fiabilité — retraitsFenêtre de quorum 72 h, relances automatiques programmées avant échéance (push puis WhatsApp puis SMS en approche de délai).
EXN-06Intégrité — montants100 % des montants stockés en entiers (bigint) francs CFA, aucun type flottant ou décimal dans le schéma financier.
EXN-07Contrat d'API147 opérations OpenAPI 3.1 ; toute opération financière ou hors-ligne porte un en-tête Idempotency-Key (UUID v7).
EXN-08ConfidentialitéUn modérateur de groupe n'accède jamais aux groupes dont il n'est pas membre ; RLS PostgreSQL sur les tables financières en plus du scope applicatif.
EXN-09Portabilité mobileLecture offline-first côté app (file de mutation avec clé d'idempotence) ; un secrétaire doit pouvoir pointer 80 présences sans réseau.

8. Contraintes

  • Réglementaire : aucune détention de fonds, aucune commission sur les flux — positionnement éditeur de logiciel, pas émetteur de monnaie électronique au sens UEMOA/BCEAO ; position à faire confirmer par un juriste ivoirien avant le premier franc réel.
  • Technique : SQL brut paramétré sur PostgreSQL comme couche d'accès (pas de Lucid ORM) pour les domaines financiers — cohérence de style imposée par CLAUDE.md du dépôt backend ; AdonisJS 6 / TypeScript ESM ; Redis pour OTP et verrous courts ; BullMQ pour les jobs asynchrones.
  • Opérateur : un seul opérateur mobile money en production au lancement (Wave) ; l'interface PaymentProvider doit rester multi-opérateur dès le premier jour sans que l'ajout d'un deuxième opérateur soit une refonte.
  • Budgétaire : modèle économique fondé exclusivement sur l'abonnement par groupe/palier ; le palier gratuit (≤ 15 membres) est un moteur d'acquisition volontairement non rentable.
  • Marché : cible initiale Abidjan, langue française, montants exclusivement en francs CFA (XOF).

9. Risques majeurs

RisqueImpactParade
Confier sa caisse à une jeune application (objection de tout bureau)Élevé — bloque l'adoptionLe produit se vend d'abord sans sa partie financière (vie de groupe, réunions) ; l'argent ne quitte jamais le compte tenu chez l'opérateur agréé.
Dépendance à un seul opérateur (panne, changement tarifaire, refus de contrat)Élevé — bloque tout le module financierArchitecture multi-opérateur dès le premier jour au niveau du modèle de données et de l'interface PaymentProvider ; ajouter le second est un connecteur, pas une refonte.
Requalification réglementaire (émetteur de monnaie électronique)Moyen — risque juridique existentielAucune détention de fonds, aucune commission sur les flux, revenus exclusivement en abonnement ; validation juridique avant tout flux réel.
Incident financier public (retrait exécuté deux fois, solde faux)Fatal — réputationnel et légalGrand livre en partie double, écritures non modifiables, idempotence systématique, réconciliation quotidienne ; au moindre écart, blocage plutôt que passage silencieux.
Cumul de rôles qui viderait le mécanisme des 80 % de son sensÉlevé — contourne l'invariant centralSéparation des pouvoirs codée en base (RG-08) : un chef-trésorier ne vote pas sur sa propre demande et ne compte pas dans le quorum.

10. Règles de gestion

Voir l'artefact dédié regles_gestion (RG-01 à RG-35), regroupées par domaine : identité/RBAC, groupes, finance/grand livre, retraits à double validation, abonnements, billetterie/événements, réunions/PV, social, notifications, admin. Chaque exigence fonctionnelle ci-dessus référence les RG qui la justifient.

Post-mortems

Post-mortem — Suite de tests fonctionnels des retraits bloquée par un défaut d'isolation OTP

Suite de tests tests/functional/retraits.spec.ts instable — 2026-09 — Sévérité : bloquante (CI)

  • Impact : le hook pre-commit (.husky/pre-commit, scripts/pre_commit_review.mjs) exécute pnpm test avant toute revue automatisée. Tant que ce défaut n'est pas corrigé, tous les commits sur le dépôt djelimeet-api sont bloqués à l'étape test — pas seulement ceux qui touchent au module de retrait. Aucune donnée de production affectée (le défaut est contenu à l'environnement de test).
  • Timeline :

    • Détection : lors de la mise en place du hook pre-commit, la suite de tests fonctionnels a révélé des échecs préexistants et instables sur tests/functional/retraits.spec.ts, avec le message « aucun OTP en attente ».
    • Diagnostic : le comportement n'est pas reproductible de façon déterministe — le symptôme apparaît ou non selon l'état laissé par une exécution précédente, signe d'un défaut d'isolation entre cas de test plutôt que d'un bug du mécanisme de retrait lui-même.
    • Mitigation : documentation explicite du défaut dans CLAUDE.md du dépôt, avec la décision assumée de laisser le hook bloquer en échec sûr (« fail safe ») plutôt que de l'ignorer silencieusement.
    • Résolution : non résolu à la date de rédaction de ce cas d'étude — corriger cette suite de tests est un préalable à tout commit normal sur le dépôt, pas une tâche différable.
  • Cause racine :

    • Technique : l'OTP est stocké dans Redis avec une clé vraisemblablement partagée ou insuffisamment isolée entre cas de test (ex. même numéro de téléphone réutilisé sans nettoyage entre tests, ou TTL qui chevauche l'exécution suivante). Le test suivant lit un état OTP laissé par le test précédent (ou l'absence d'un état qu'il attendait), d'où « aucun OTP en attente ».
    • Organisationnelle : le hook pre-commit a été introduit après l'écriture du module financier, révélant une dette de test déjà présente plutôt que créée par le hook — signe qu'aucune exécution systématique de la suite complète n'avait eu lieu en continu avant cette mise en place.
  • Ce qui a bien marché : le choix d'échec sûr (bloquer tous les commits plutôt que de laisser passer une suite de tests non fiable) a rendu le défaut immédiatement visible et documenté, au lieu de laisser une suite « flaky » être silencieusement ignorée ou relancée en boucle jusqu'à passer par hasard — un piège classique qui aurait masqué un vrai risque sur le mécanisme de retrait.
  • Ce qui a mal marché : l'absence d'isolation des tests n'a été détectée qu'au moment de l'automatisation du hook, pas au moment de l'écriture des tests eux-mêmes — un signal que la suite de tests du module financier n'avait pas encore de garde-fou d'isolation (reset Redis entre tests, numéros de téléphone générés uniques par cas) dès sa conception.
  • Actions :

    • Isoler chaque cas de test sur une clé Redis/numéro de téléphone généré dynamiquement (ex. UUID par test) plutôt que des valeurs fixes réutilisées → garde-fou : assertion explicite en fin de test que l'état OTP est nettoyé → Responsable : équipe backend → Échéance : avant la prochaine session de développement sur le module Retraits.
    • Ajouter un hook afterEach qui purge systématiquement les clés OTP Redis créées pendant le test → garde-fou : test de régression qui exécute la suite deux fois de suite et vérifie l'absence d'interférence → Responsable : équipe backend → Échéance : même lot que l'action précédente.
    • Documenter dans app/finance/README.md la convention d'isolation adoptée, pour que tout nouveau test financier la suive dès l'écriture plutôt que de la découvrir après coup → Responsable : auteur du correctif → Échéance : avec la correction elle-même.

Guides d'installation

Guide d'installation — Djelimeet (environnement local)

1. Prérequis (versions exactes)

  • Node.js 20 LTS (ESM natif, requis par AdonisJS 6)
  • pnpm 9+ (workspace pnpm-workspace.yaml utilisé dans les deux dépôts)
  • PostgreSQL 16
  • Redis 7
  • Docker + Docker Compose (pour l'infra locale fournie — docker-compose.yml à la racine du dépôt API)

2. Variables d'environnement

NomRequisRôleExemple
DATABASE_URLouiConnexion PostgreSQL de l'APIpostgres://djelimeet:djelimeet@localhost:5432/djelimeet
REDIS_URLouiOTP, verrous courts, cache, file BullMQredis://localhost:6379
APP_KEYouiClé de chiffrement AdonisJS (générée, jamais partagée)base64:... (générer avec node ace generate:key)
NODE_ENVouidevelopment expose devCode dans la réponse OTP pour les testsdevelopment
WAVE_API_KEYnon (dev)Clé API du compte marchand Wave — absente en local, utiliser un mockwave_sk_test_...
WAVE_WEBHOOK_SECRETnon (dev)Secret de signature des webhooks Wave sur corps brutwhsec_...
WHATSAPP_API_TOKENnon (dev)Envoi OTP WhatsApp — en dev, le devCode remplace l'envoi réelEAAG...
SMS_GATEWAY_TOKENnon (dev)Repli SMS si WhatsApp non délivré en 20 ssms_...
NUXT_PUBLIC_API_BASEoui (frontend)URL de base de l'API consommée par Nuxthttp://localhost:3333/v1

3. Installation pas-à-pas

# 1. Backend
cd djelimeet-api
pnpm install
docker compose up -d          # PostgreSQL + Redis locaux
cp .env.example .env           # puis renseigner les variables ci-dessus
node ace generate:key          # colle le résultat dans APP_KEY
node ace migration:run         # applique les 29 migrations dans l'ordre des horodatages
node ace serve --watch         # démarre l'API sur http://localhost:3333

Résultat attendu : node ace migration:run affiche 29 migrations appliquées sans erreur ; node ace serve affiche Server started on http://localhost:3333.

# 2. Frontend
cd ../djelimeet-web
pnpm install
pnpm dev                       # démarre Nuxt sur http://localhost:3000

Résultat attendu : page d'accueil Djelimeet accessible, requêtes vers NUXT_PUBLIC_API_BASE visibles dans les logs réseau du navigateur.

4. Vérification

  • curl -X POST http://localhost:3333/v1/auth/otp/request -H "Content-Type: application/json" -d '{"msisdn":"+2250700000000"}' → 202 avec un champ devCode en environnement development.
  • node ace test (Japa) depuis djelimeet-api → suite fonctionnelle exécutée (voir limitation connue ci-dessous).
  • Ouvrir http://localhost:3000/auth → le parcours OTP complet doit aboutir à une session.

5. Problèmes fréquents

SymptômeCauseSolution
node ace migration:run échoue sur une migration commuapp-coreMigrations jouées sans celles de commuapp-finance (triggers deny_audit_mutation, assert_transaction_balanced manquants)Toujours copier et jouer les deux jeux de migrations ensemble, dans l'ordre de leurs horodatages (database/README.md).
tests/functional/retraits.spec.ts échoue avec « aucun OTP en attente »Défaut connu et documenté (CLAUDE.md) : l'OTP lu depuis Redis dépend de l'état laissé par une exécution précédente — manque d'isolation entre tests, pas un bug de retraitVoir le post-mortem dédié ; en attendant un correctif, SKIP_AI_REVIEW=1 ne contourne que la revue IA, jamais la suite de tests elle-même.
Webhook Wave reçu en local sans signature valideWAVE_WEBHOOK_SECRET absent ou différent de celui utilisé pour signerUtiliser l'outil de simulation de webhook de Wave avec le même secret que .env, ou mocker le corps brut avec la signature calculée manuellement en dev.
Double crédit observé en test manuel après rejeu d'un webhookClé d'idempotence (topup:<intentId>) non respectée côté script de testVérifier que chaque appel de test réutilise la même intention plutôt que d'en recréer une à chaque rejeu.

Changelogs

Changelog — Djelimeet

0.5.0 — 2026-09-25

Added

  • Classes de membres et tarifs de cotisation par classe (member_classes, contribution_class_rates) — RG-18.
  • Réunions récurrentes (meeting_series) : une occurrence n'est générée qu'une fois par série (RG-28, migration 1757000026).
  • Formule de billet réservée à une classe de membre (eligibility_mode = class) — migration 1757000027.
  • 147 opérations documentées dans api/openapi.yaml (contrat initial à 89, croissance par lots additifs documentée dans database/README.md).

0.4.0 — 2026-09-20

Added

  • Avis des bureaux sur un demandeur d'adhésion, anonymisés (member_reviews) — RG-12.
  • Description de profil utilisateur (users.bio, bornée à 500 caractères).

Changed

  • Facturation back-office : prix négocié et palier figé par administration (subscriptions.plan_locked), cause d'impayé distinguée (insufficient_funds / not_authorized) — RG-32.

0.3.0 — 2026-09-12

Added

  • File de signalements structurée : motif fermé (reports.reason), un signalement ouvert par signaleur et par contenu (RG-34).
  • Sondages du fil et des réunions (polls, poll_options, poll_votes) — un bulletin par membre sur un choix unique.
  • Rechargement en espèces (cash_recharge_requests) unifié avec le rechargement Wave sous une seule route (recharge_providers.requires_proof).

0.2.0 — 2026-08-30

Added

  • Grand livre en partie double complet (accounts, transactions, ledger_entries), triggers d'immuabilité (deny_ledger_mutation, assert_transaction_balanced).
  • Retraits à double validation (seuil 80 %) : withdrawal_requests, withdrawal_approvals, disbursement_lines — RG-20 à RG-26.
  • Row-Level Security PostgreSQL créée (désactivée par défaut) sur les tables financières.

Breaking

  • Aucune route financière antérieure à ce lot n'existait — pas de migration de rupture.

0.1.0 — 2026-08-15

Added

  • Identité par OTP (WhatsApp/SMS), PIN distinct, sessions à rotation (users, devices, auth_sessions) — RG-01 à RG-04.
  • Groupes, adhésions et rôles cumulables (groups, memberships, membership_roles) — RG-06, RG-07.
  • Squelette AdonisJS 6 bootstrap, convention SQL brut paramétré établie par app/finance/ (voir ADR dédié).

Notes de version

  • Le backend/API est déclaré complet et validé par son propre contrat (api/README.md) à la date du changelog le plus récent ci-dessus.
  • Le frontend Nuxt/Ionic (djelimeet-web) suit son propre rythme de livraison, non repris dans ce changelog backend — plus de 80 écrans déjà présents au moment de la rédaction de ce cas d'étude (auth, dashboard groupe, financier, réunions, événements, back-office admin).

User stories

User stories — Djelimeet

US-01 — Rejoindre la plateforme sans mot de passe

  • En tant que membre potentiel d'une association, je veux m'inscrire avec mon seul numéro de téléphone, afin de ne jamais avoir à retenir un mot de passe supplémentaire.
  • Exigence liée : EXF-01 · Priorité : MUST

Critères d'acceptation

  • Given un numéro ivoirien valide non encore inscrit When je demande un code OTP Then je reçois un code à 6 chiffres par WhatsApp, repli SMS si non délivré en 20 s.
  • Given un code OTP valide saisi dans les 5 minutes When je le soumets Then une session s'ouvre (access 15 min + refresh 30 j).

Cas d'erreur couverts

  • Code expiré ou faux 5 fois → blocage 15 min, message explicite.

US-02 — Créer un groupe et devenir chef

  • En tant que fondateur d'une tontine, je veux créer un groupe et en devenir automatiquement le premier chef, afin de démarrer la gestion de la caisse immédiatement.
  • Exigence liée : EXF-03 · Priorité : MUST

Critères d'acceptation

  • Given un compte vérifié When je crée un groupe Then je deviens chef unique, la caisse et le compte de fonds gelés sont créés, et un avertissement m'indique qu'un retrait ne nécessitera que ma seule voix tant que je reste seul chef.
  • Given un groupe créé When je consulte mes groupes Then mon rôle y est affiché par groupe, jamais globalement.

US-03 — Demander un retrait de caisse

  • En tant que trésorier, je veux ouvrir une demande de retrait avec un motif et un devis chiffré, afin de financer une dépense du groupe de façon traçable.
  • Exigence liée : EXF-05 · Priorité : MUST

Critères d'acceptation

  • Given une caisse avec un solde suffisant When j'ouvre une demande de 150 000 F avec des lignes de devis Then les fonds (montant + frais estimés) sont gelés immédiatement et le seuil requis est calculé et figé.
  • Given une demande déjà votée une fois When je modifie le montant Then tous les votes déjà émis sont invalidés et doivent être repris.

US-04 — Approuver un retrait en tant que chef

  • En tant que chef de groupe, je veux approuver ou rejeter une demande de retrait après ré-authentification, afin de garantir qu'aucune sortie de caisse ne se fait sans accord collégial.
  • Exigence liée : EXF-06 · Priorité : MUST

Critères d'acceptation

  • Given une demande en attente où je suis chef actif et non demandeur When j'approuve avec mon PIN et un code OTP Then mon vote est enregistré une seule fois, même si l'appel est rejoué (idempotence).
  • Given je suis le trésorier demandeur et aussi chef When je tente de voter sur ma propre demande Then je reçois une erreur 403, je ne compte pas dans le quorum.

Cas d'erreur couverts

  • Demande déjà décidée ou expirée (72 h) → 410.

US-05 — Justifier un décaissement

  • En tant que bénéficiaire d'un retrait exécuté, je veux déclarer mes dépenses ligne par ligne, afin de rendre compte de l'usage des fonds devant le groupe.
  • Exigence liée : EXF-08 · Priorité : MUST

Critères d'acceptation

  • Given un retrait payé non soldé When j'ajoute des lignes de justification Then elles sont visibles de tout le groupe et le statut de justification se met à jour (partial/settled), sans jamais toucher le grand livre.
  • Given un reliquat après dépenses When je le reverse en caisse Then c'est journalisé comme un rechargement ordinaire référencé au retrait d'origine.

US-06 — Pointer sa présence à une réunion

  • En tant que membre, je veux scanner un QR affiché en salle pour signaler ma présence, afin de figurer sur la liste officielle sans ressaisie manuelle.
  • Exigence liée : EXF-11 · Priorité : MUST

Critères d'acceptation

  • Given une réunion en cours When je scanne le QR tourné depuis moins de 60 s Then ma présence est enregistrée, même hors ligne, et un second scan reste sans effet.
  • Given mon téléphone est déchargé When le secrétaire me pointe manuellement Then le pointage est tracé avec l'identité du secrétaire.

US-07 — Publier un PV immuable

  • En tant que secrétaire, je veux publier le procès-verbal d'une réunion, afin de fixer définitivement la trace des décisions prises.
  • Exigence liée : EXF-11 · Priorité : MUST

Critères d'acceptation

  • Given un PV en brouillon When je le publie Then son statut passe à publié et il devient immuable.
  • Given un PV déjà publié When je corrige une erreur Then une nouvelle version est créée, l'ancienne reste visible.

US-08 — Acheter un billet d'événement

  • En tant que membre, je veux réserver et payer un billet pour un événement du groupe, afin de garantir ma place sans risque de double-vente.
  • Exigence liée : EXF-12 · Priorité : MUST

Critères d'acceptation

  • Given une formule disponible When je réserve 2 billets Then ils sont tenus 10 minutes, décomptés de la jauge de façon atomique.
  • Given le paiement effectué dans le délai When je consulte mes billets Then chacun porte un QR à usage unique et un numéro de série.

Cas d'erreur couverts

  • Paiement non effectué après 10 min → réservation annulée automatiquement, sans frais.

US-09 — Refuser de désactiver les alertes financières

  • En tant que membre, je veux que mes notifications de cotisation/retrait/abonnement restent toujours actives, afin de ne jamais manquer une information qui engage l'argent du groupe.
  • Exigence liée : EXF-16 · Priorité : MUST

Critères d'acceptation

  • Given mes préférences de notification When je tente de désactiver la catégorie « withdrawal » Then la requête est rejetée en 422, la catégorie reste active.
  • Given une alerte « fil » ou « réunion » When je la désactive Then la préférence est bien enregistrée (ces catégories restent coupables).

US-10 — Changer le seuil des 80 % en double validation

  • **En tant qu'**administrateur système, je veux proposer et faire approuver par un second administrateur un changement du seuil de quorum, afin de garantir qu'aucun paramètre critique ne dépend d'une seule personne.
  • Exigence liée : EXF-17 · Priorité : MUST

Critères d'acceptation

  • Given une proposition de changement avec une première signature When un second administrateur distinct approuve Then le changement est appliqué.
  • Given le même administrateur qui a proposé When il tente d'approuver aussi Then la demande reste en attente (deux signatures distinctes requises).

Systèmes de design

Design system — Djelimeet

Palette, typographie et composants extraits tels qu'implémentés dans djelimeet-web/app/assets/css/main.css et app/app.config.ts (Nuxt UI 4) — pas une proposition, l'état réel du code. Chaque couleur de la palette existe déjà en 11 nuances dans le dépôt ; aucune valeur n'est inventée pour cet artefact.

1. Tokens

Couleurs

Palette brand (primaire) — ancrée sur #17614C (nuance 600, couleur de marque Djelimeet), hover sur #0F4638 (700) :

TokenValeurUsageContraste
--color-brand-50#EAF4F0fond très clair (bandeaux de succès discrets)—
--color-brand-100#D2E7DFsurvol léger sur fond clair—
--color-brand-200#A8D0C1bordures d'accent faible—
--color-brand-300#7AB9A1éléments décoratifs—
--color-brand-400#439880icônes secondaires sur fond clairAA sur paper-50
--color-brand-500#227862accent intermédiaireAA sur paper-50
--color-brand-600#17614C--ui-primary — boutons primaires, liens, --ui-border-accentedAA sur paper-50/100 (texte blanc dessus)
--color-brand-700#0F4638hover/active des éléments primairesAAA sur paper-50
--color-brand-800#0C382Dtexte sur fond brand-50AAA
--color-brand-900#092A22texte fort sur fond clairAAA
--color-brand-950#051712quasi-noir teinté, usage décoratif rareAAA

Palette paper (neutre — fonds, bordures, texte) :

TokenValeurUsage
--color-paper-50#FFFEFA--ui-bg-elevated — cartes, modales
--color-paper-100#F4F2EC--ui-bg — fond de page par défaut
--color-paper-200#EDEAE0--ui-bg-muted — zones secondaires
--color-paper-300#E4E0D4--ui-border-muted
--color-paper-400#D9D5C9--ui-border par défaut
--color-paper-500#B0AB9C--ui-text-dimmed
--color-paper-600#7A766B--ui-text-muted
--color-paper-700#4A4840--ui-text-toned
--color-paper-800#2E2C26texte secondaire fort
--color-paper-900#1B1A16--ui-text / --ui-text-highlighted — texte principal
--color-paper-950#0E0D0B--ui-border-inverted

Palette sand (avertissement — vote de retrait en attente) : texte #8F6314 (600) sur fond #F3EAD7 (100). Échelle complète 50→950 de #FBF6EC à #2A1C06, utilisée pour tout badge « en attente d'approbation » ou « cotisation en retard ».

Palette clay (erreur — écart de réconciliation, échec de paiement) : texte #8E2F22 (600) sur fond #F4E2DD (100). Échelle 50→950 de #FBF0ED à #290C09, réservée aux états d'échec réel (jamais pour un simple avertissement — voir sand).

Couleurs dédiées back-office : --color-admin-bar: #1B1A16 (fond du bandeau), --color-admin-bar-text: #FFFEFA, --color-admin-bar-accent: #8FCFB8 — seule touche sombre délibérée de toute l'interface, pour qu'un agent du back-office sache en permanence qu'il est hors du contexte d'un groupe.

Mapping sémantique Nuxt UI (app.config.ts) : primary → brand, secondary → paper, success → brand, warning → sand, error → clay, info → brand, neutral → paper. Le succès partage la teinte de marque plutôt qu'un vert générique — cohérence avec le propos financier du produit (confirmer une opération dans la couleur même de la confiance qu'on vend).

Typographie

TokenValeurUsage
--font-sans'IBM Plex Sans', ui-sans-serif, system-ui, sans-serifcorps de texte, interface, formulaires
--font-serif'Spectral', Georgia, seriftitres éditoriaux (site public, pitch, pages À propos)
--font-mono'IBM Plex Mono', ui-monospace, monospacemontants, références de transaction, codes OTP/PIN — tout ce qui doit s'aligner en colonne

Espacements, rayons et forme

  • Rayon global : 0px. --ui-radius: 0px est posé explicitement au niveau :root : aucune carte, bouton ou champ arrondi dans toute l'interface — identité visuelle délibérément « administrative, nette », cohérente avec le propos de traçabilité. Seule exception documentée : input reçoit rounded-[2px], jamais plus.
  • Cartes (UCard) : rounded-none, ring-1 ring-default — contour net plutôt qu'ombre, corps sans padding par défaut (p-0 sm:p-0) pour laisser le contenu (souvent un tableau financier) occuper tout l'espace.
  • Modales et slideovers (« feuilles glissantes ») : rounded-none, ring-1 ring-default — patron unique documenté dans le code (canvas.json, annotation sheets-note) : poignée, titre, champs, puis toujours Annuler à gauche / action à droite. Quand la feuille touche à l'argent (devis, dépense), elle affiche le nouveau total avant validation, jamais après.

Icônes

  • Bibliothèques Iconify embarquées : @iconify-json/lucide (usage général) et @iconify-json/simple-icons (logos d'opérateurs/réseaux sociaux).
  • Règle d'usage : icône toujours accompagnée d'un libellé visible sauf pour une action universellement reconnue (fermer, retour).

Tableaux (UTable) — journal financier, listes admin

  • En-têtes : classe utilitaire lbl sur fond bg-muted (paper-200).
  • Cellules : taille de texte fixe text-[13.5px] — densité volontairement élevée pour un journal financier lu en colonnes, pas de zébrage arrondi (cohérent avec le rayon 0).

2. Composants

ComposantVariantsÉtatsTokens consommésComportement
Button (UButton)couleur primary (brand) par défaut, variante solid par défaut ; secondary/error selon contextedefault, hover, active, focus, disabled, loading--ui-primary, rounded-none, font-mediumBouton d'action d'une feuille glissante toujours à droite ; action destructrice (annuler un retrait, rejeter) en couleur error (clay).
Card (UCard)standard, élevée (bg-elevated)—ring-default, rounded-noneConteneur par défaut des blocs de dashboard (solde, retraits en cours, tâches).
Input / Select / Textareadefault, with-label, error, disabledfocus, validationrounded-[2px] (seule exception au rayon 0), --ui-borderMessage d'erreur lié par aria-describedby ; champ montant toujours en --font-mono.
Modal / Slideoverfeuille glissante (patron unique)ouverture (overlay, focus trap), fermeturerounded-none, ring-1 ring-defaultPoignée + titre + champs + Annuler (gauche) / Action (droite) ; total recalculé affiché avant validation sur toute feuille financière.
Table (UTable)journal financier, liste admintri, pagination par curseurbg-muted (en-têtes), text-[13.5px]Chaque ligne du journal affiche le solde courant cumulatif, pas seulement le montant du mouvement.
Badgesand (en attente), clay (échec/litige), brand (réussi)—palettes sand/clay/brandBadge de statut de retrait : « gelé · 2 sur 3 » en sand, jamais en pourcentage.
Bandeau adminfixe, sombre—--color-admin-bar, --color-admin-bar-accentRappel visuel permanent qu'on est dans le back-office système, hors contexte d'un groupe.

3. Règles transverses

  • Mode sombre : colorMode: false dans nuxt.config.ts — l'interface est volontairement mono-thème clair (scheme-light posé sur body), cohérent avec un public qui lit des montants et où un mauvais contraste sur un chiffre n'est pas tolérable. Le bandeau admin reste la seule zone sombre, par choix fonctionnel (distinction de contexte), pas par thème.
  • Accessibilité : contrastes vérifiés AA minimum pour le texte sur paper-100/paper-50 (texte en paper-900) et pour brand-600 comme couleur d'action sur fond clair ; cible tactile minimale conservée malgré l'absence d'arrondi (le rayon 0 ne réduit pas la zone cliquable).
  • Animations : non documentées comme un système à part — l'identité visuelle « nette et administrative » (rayon 0, pas de dégradé) va de pair avec des transitions discrètes ; respecter prefers-reduced-motion sur toute transition ajoutée.
  • Conventions de nommage : classes utilitaires Tailwind 4 (@theme static pour les tokens), pas de BEM ; interdiction explicite documentée dans le CSS source de réintroduire un arrondi, un dégradé ou une couleur hors palette (« voir CLAUDE.md §1-§2 ») — toute couleur hors brand/paper/sand/clay/admin-* est un défaut, pas une variante.
  • i18n : @nuxtjs/i18n chargé — tout composant doit prévoir la variation de longueur de texte français/anglais sans casser la mise en page en colonnes du journal financier.

Diagrammes d'architecture

Diagramme d'architecture — Djelimeet

1. Diagramme (ASCII)

                      ┌────────────────────────┐        ┌───────────────────────┐
                      │  Nuxt 4 + Ionic (hybride)│        │  Nuxt 4 — back-office   │
                      │  app membre / bureau     │        │  système (admin)       │
                      │  Nuxt UI 4 · Pinia       │        │  Nuxt UI 4             │
                      └───────────┬──────────────┘        └───────────┬───────────┘
                                  │ HTTPS · JSON · JWT access 15min   │ HTTPS · JSON
                                  │ + refresh 30j (rotation)          │ x-system-role
                                  ▼                                  ▼
                      ┌────────────────────────────────────────────────────────┐
                      │            api.commuapp.ci / admin.commuapp.ci          │
                      │         API AdonisJS 6 — REST versionné /v1             │
                      │  routes → controllers (parse+valide) → services        │
                      │  (logique métier, SQL brut paramétré) → PostgreSQL     │
                      │  middlewares : resolve_group (groupId URL uniquement),  │
                      │  authentification OTP, Bouncer (x-permission/role)      │
                      └───┬───────────────┬───────────────┬────────────────┬───┘
                          │ SQL paramétré │ SET LOCAL      │ BullMQ enqueue │ cache/lock
                          ▼               │ set_current_   ▼                ▼
              ┌───────────────────────┐   │ group()  ┌─────────────┐  ┌───────────┐
              │   PostgreSQL 16        │◄──┘          │ Workers      │  │ Redis 7    │
              │  schéma partagé        │              │ BullMQ       │  │ OTP · cache│
              │  group_id + RLS sur    │              │ (process     │  │ verrous    │
              │  tables financières    │              │ séparé)      │  │ courts     │
              │  triggers : deny_ledger│              │ webhooks Wave│  └───────────┘
              │  _mutation, assert_    │              │ rappels      │
              │  transaction_balanced  │              │ réconciliat. │
              └───────────────────────┘              └──────┬───────┘
                                                              │ HTTPS webhook signé
                                                              ▼
                                                     ┌─────────────────────┐
                                                     │ Wave (compte marchand│
                                                     │ opérateur agréé)     │
                                                     │ float unique au      │
                                                     │ lancement — ADR-04   │
                                                     └─────────────────────┘

   Canaux sortants (via workers) :  WhatsApp/SMS (OTP, relances)  ·  Push FCM (Android/iOS)

2. Inventaire des composants

ComposantResponsabilitéTechnologieScalabilité
App Nuxt/Ionic (membre + bureau)Parcours membre (auth OTP, fil, caisse, réunions) et espace bureau (dashboard groupe, journal, PV) ; mobile et web depuis la même base Vue.Nuxt 4, Nuxt UI 4, Ionic, Pinia, Tailwind 4Statique/SSR déployable en CDN ; état côté client via Pinia, offline-first pour les écrans de présence.
Back-office systèmeFacturation, réconciliation, litiges, modération, gestion des plans, équipe interne.Même code Nuxt, sous-domaine admin.commuapp.ci dédié.Trafic interne faible volume, pas de scalabilité critique.
API AdonisJS 6Point d'entrée unique REST versionné ; résolution du tenant, authentification, autorisation (Bouncer), logique métier par domaine, écriture au grand livre.AdonisJS 6, TypeScript ESM, VineJSStateless horizontalement scalable derrière un load balancer ; état dans PostgreSQL/Redis.
PostgreSQL 16Source de vérité unique : identité, groupes, grand livre en partie double, réunions, billetterie, abonnement. RLS sur tables financières.PostgreSQL 16, SQL brut paramétréVertical au démarrage ; réplicas de lecture envisageables pour le journal/reporting.
Redis 7OTP (codes hachés, TTL), verrous courts par référence externe (webhook, paiement), cache de lecture peu volatile (annuaire public, platform_settings) avec invalidation explicite.Redis 7Cluster/réplication standard si le volume l'exige ; jamais source de vérité financière.
Workers BullMQTraitement asynchrone : webhooks Wave, relances de retrait/cotisation/réunion, génération de factures récurrentes, réconciliation nocturne, dérivés de PDF (PV).BullMQ, process Node.js séparé de l'APIScalable indépendamment de l'API ; redémarrable, jobs idempotents et rejouables.
Compte marchand WaveDétention réelle des fonds ; checkout, payout, notifications webhook.API Wave (REST + webhooks signés)Hors périmètre Djelimeet ; un seul opérateur en production, interface PaymentProvider prête pour un second.
Canaux sortantsOTP et relances (WhatsApp d'abord, SMS en repli), notifications push.WhatsApp Business API / SMS gateway, FCMDépendant de prestataires tiers ; déclenché exclusivement par les workers, jamais en synchrone dans la requête HTTP.

3. Flux critiques pas-à-pas

Flux A — Rechargement de wallet (Wave)

  1. App → POST /me/wallet/topups (providerCode=wave) → API crée une intention en base avant tout appel sortant (payment_intents, statut pending).
  2. API → Wave POST /v1/checkout/sessions → réponse wave_launch_url, renvoyée à l'app.
  3. Utilisateur paie côté Wave (hors app) → Wave → webhook checkout.session.completed signé sur corps brut → capture_raw_body_middleware → événement brut stocké → 200 renvoyé en < 5 s.
  4. Worker BullMQ consomme l'événement sous verrou Redis keyed sur topup:<intentId> → TopupService.settle() → écriture équilibrée au grand livre (crédit wallet membre, débit float Wave).
  5. Filet de sécurité : si le webhook n'arrive jamais, sweepPendingIntents() (toutes les 5 min) relit l'état chez Wave et appelle le même settle() — idempotence par clé garantit un seul crédit, quel que soit le chemin.

Flux B — Retrait validé à 80 %

  1. Trésorier → POST /groups/{groupId}/withdrawal-requests → service gèle les fonds (caisse → compte group_frozen_funds), photographie N = chefs actifs hors demandeur, fige requiredApprovals = ceil(0.8×N).
  2. Chaque chef → OTP de re-authentification + PIN → POST .../approvals (idempotent, UNIQUE(withdrawal_id, approver_membership_id)).
  3. Dès approvalsCount ≥ requiredApprovals → statut approved → vérification du float (plafond journalier opérateur) → executing → payout Wave → paid ; si liquidité insuffisante → awaiting_liquidity (fonds restent gelés, alerte exploitation).
  4. Sans quorum sous 72 h → job planifié → expired, restitution intégrale en caisse, notification collective.
  5. Bénéficiaire → lignes de justification (disbursement_lines, hors grand livre) jusqu'à solde ; reliquat → return-remainder (rechargement ordinaire référencé).

Flux C — Présence à une réunion, hors ligne

  1. Secrétaire ouvre la réunion le jour J → app génère un QR HMAC tournant (60 s) affiché en salle.
  2. Chaque membre scanne (ou le secrétaire scanne son badge) → validation locale hors ligne (tolérance ±2 fenêtres) → mise en file de mutation côté app si pas de réseau, avec clé d'idempotence.
  3. Synchronisation différée → API → contrainte (meeting_id, membership_id) rend un double scan sans effet.
  4. Secrétaire publie le PV (liste de présence reprise automatiquement) → minutes verrouillé, minute_versions pour toute correction ultérieure.

4. Stockage

  • PostgreSQL 16 : schéma unique, ~30 tables sur 12 domaines (identité, groupes, financier, abonnement, réunions, billetterie, social, notifications, opérateurs). Sauvegarde continue (PITR) ; RLS activée table par table sur les tables porteuses de group_id financières et sensibles, scope applicatif partout ailleurs.
  • Redis 7 : OTP (TTL 5 min), verrous courts (TTL de l'ordre de la seconde, libération par jeton), cache de lecture invalidé explicitement — jamais de donnée financière durable.
  • Stockage objet (S3-compatible) : médias du fil, justificatifs de dépense, PV exportés en PDF — upload direct par URL pré-signée, l'API ne sert jamais d'intermédiaire pour les octets.
  • File BullMQ (sur Redis) : jobs webhooks, relances, génération de factures, réconciliation — persistants jusqu'à traitement, avec reprise sur échec.

5. Décisions structurelles → ADR liés

  • SQL brut paramétré plutôt que Lucid ORM comme couche d'accès → ADR « SQL brut paramétré ».
  • Authentification OTP sans mot de passe → ADR « Authentification par OTP sans mot de passe ».
  • Grand livre en partie double et immuabilité → ADR « Grand livre en partie double, écritures immuables ».
  • Seuil des 80 % et collège figé → ADR « Retraits à double validation, seuil 80 % ».
  • RLS PostgreSQL pour l'isolation multi-tenant → ADR « Isolation multi-tenant par RLS PostgreSQL ».
  • Un seul opérateur en production (Wave), interface multi-opérateur prête → cahier d'architecture ADR-04/ADR-11 (float modélisé comme compte, pas solde caché).
  • SSE (@adonisjs/transmit) plutôt que WebSocket pour les mises à jour temps réel (compteur de présence, avancement d'un vote) → cahier d'architecture ADR-08.

6. Limites connues

  • Un seul opérateur mobile money branché en production : la bascule multi-opérateur réelle (routage, float consolidé) reste à implémenter, seule l'interface PaymentProvider existe.
  • RLS créée mais pas activée sur toutes les tables : l'activation table par table dépend de la validation préalable du middleware de tenant sur chacune — un chantier d'exploitation encore ouvert, pas un oubli.
  • Pas de rôle back-office BYPASSRLS dédié encore tranché : un modérateur système multi-groupes dépend d'une décision à prendre avant la mise en production.
  • Pas de catégories comptables ni de budgets prévisionnels (hors périmètre v1, cahier d'architecture §8).
  • Pas de versionnement de rupture d'API (/v2) ni de stratégie de dépréciation formalisée — non nécessaire tant qu'aucune rupture de contrat n'est requise.
  • Rate limiting documenté en texte sur certaines routes (OTP) mais pas encore déclaré via un mécanisme standard (en-têtes X-RateLimit-*).

Schéma de données

users

Identité globale d'une personne, indépendante de ses groupes. Numéro de téléphone normalisé E.164 comme seul identifiant (RG-01), PIN haché distinct de l'OTP (RG-03).

id PKuuid

Identifiant global de l'utilisateur.

non null

msisdn varchar(20)

Numéro de téléphone normalisé E.164, unique — seul identifiant de connexion (RG-01).

non null

display_name varchar(120)

Nom affiché aux autres membres des groupes.

non null

pin_hash varchar(255)

Hash du PIN à 4 chiffres protégeant les opérations financières ; jamais exposé en clair, même au back-office (RG-03).

bio varchar(500)

Description de profil montrée au bureau d'un groupe lors d'une demande d'adhésion (borné à 500 caractères).

locale varchar(5)

= fr

Langue d'affichage (fr ou en).

non null

anonymized_at timestamptz

Date d'anonymisation si le compte a été supprimé après retrait du solde — l'identité est retirée, les écritures sont conservées (RG-34).

created_at timestamptz

Date d'inscription.

non null

groups

Le tenant logique de la plateforme (association, tontine, mutuelle, amicale). Toute donnée métier est cloisonnée par son group_id (RG-06).

id PKuuid

Identifiant du groupe — c'est le tenant de toutes les tables cloisonnées.

non null

name varchar(150)

Nom du groupe.

non null

slug varchar(150)

Identifiant lisible utilisé dans les URLs publiques.

non null

kind varchar(30)

Type de groupe (association, tontine, famille, amicale...) — valeur informative, ne change aucune règle de gestion.

non null

city varchar(120)

Ville affichée sur la fiche publique.

contact_msisdn varchar(20)

Numéro de contact du bureau, visible sur la fiche publique.

non null

visibility varchar(10)

= private

public ou private — un groupe privé n'apparaît dans aucune recherche ni annuaire (RG-10).

non null

publications_suspended boolean

= false

Posé automatiquement en cas d'abonnement impayé — dégrade le fil sans jamais bloquer le journal ni les retraits (RG-19).

non null

created_at timestamptz

Date de création du groupe — la caisse et le compte de fonds gelés sont créés au même instant (RG-15).

non null

archived_at timestamptz

Date de dissolution — le groupe passe en archive lecture seule après un vote à 80 %, le journal reste consultable (RG-19 / règles métier §2).

memberships

Adhésion d'un utilisateur à un groupe — porte le statut et, via membership_roles, les rôles cumulables (RG-07). Un même user peut avoir plusieurs memberships (un par groupe).

id PKuuid

Identifiant de l'adhésion.

non null

user_id FKuuid

→ users

Utilisateur titulaire de l'adhésion.

non null

group_id FKuuid

→ groups

Groupe concerné — clé de cloisonnement (RG-06).

non null

status varchar(10)

= pending

pending | active | suspended | left — un membre suspendu ou parti ne compte pas dans le calcul du palier d'abonnement (RG-31) ni dans le collège de vote.

non null

member_class_id uuid

Classe de membre (tarif de cotisation) — table member_classes, hors ERD détaillé ; FK composite (id, group_id) pour empêcher de pointer vers un autre groupe.

joined_at timestamptz

Date d'entrée effective dans le groupe — sert de base au délai de 30 jours avant de pouvoir écrire un avis (RG-12).

membership_roles

Rôle cumulable attaché à une adhésion (membre, secrétaire, trésorier, modérateur, chef). La permission effective d'un membre est l'union de ses lignes ici (RG-07).

id PKuuid

Identifiant de la ligne de rôle.

non null

membership_id FKuuid

→ memberships

Adhésion à laquelle ce rôle est attaché.

non null

role varchar(20)

member | secretary | treasurer | moderator | chief — cumulable ; « member » ne peut jamais être retiré (RG-07).

non null

granted_at timestamptz

Date d'attribution du rôle — permet de dater un changement de taille de collège de chefs.

non null

invitations

Invitation nominative à rejoindre un groupe, rôle « membre » par défaut, expire après 14 jours (RG-11).

id PKuuid

Identifiant de l'invitation.

non null

group_id FKuuid

→ groups

Groupe qui invite.

non null

token varchar(64)

Jeton nominatif unique utilisé dans le lien d'invitation.

non null

invited_msisdn varchar(20)

Numéro de la personne invitée.

non null

status varchar(10)

= pending

pending | accepted | expired — expire automatiquement après 14 jours (RG-11).

non null

expires_at timestamptz

Échéance de l'invitation, créée à + 14 jours (RG-11).

non null

accounts

Plan de comptes du grand livre : wallet d'un membre, caisse d'un groupe, fonds gelés, float opérateur. Le solde de chaque compte est un cache recalculé depuis ledger_entries, jamais la source de vérité (RG-14).

id PKuuid

Identifiant du compte du plan comptable.

non null

kind varchar(30)

member_wallet | group_treasury | group_frozen_funds | operator_float | cash — nature comptable du compte (RG-15).

non null

owner_user_id FKuuid

→ users

Rempli pour un wallet membre ; nul pour un compte de groupe ou de plateforme.

owner_group_id FKuuid

→ groups

Rempli pour une caisse ou un compte de fonds gelés de groupe ; nul pour un wallet membre ou un compte plateforme.

cached_balance_xof bigint

= 0

Cache recalculable du solde (entier CFA) — jamais la source de vérité, toujours dérivable de ledger_entries (RG-13, RG-14).

non null

transactions

Un évènement financier regroupant un ensemble d'écritures équilibrées (ledger_entries). Porte la clé d'idempotence et le type d'opération (rechargement, cotisation, retrait, abonnement, billetterie).

id PKuuid

Identifiant de la transaction.

non null

kind varchar(30)

topup | contribution | withdrawal | subscription_charge | ticket_sale | adjustment | reversal — type d'opération, informatif, n'exempte jamais de l'équilibre (RG-14).

non null

idempotency_key varchar(100)

Clé unique garantissant qu'un rejeu de webhook ou d'appel réseau ne produit jamais deux fois la même transaction (RG-11, RG-17).

non null

reverses_transaction_id FKuuid

→ transactions

Référence la transaction d'origine en cas de contre-passation — une erreur se corrige par une nouvelle transaction inverse, jamais par modification (RG-14).

created_at timestamptz

Horodatage de la transaction.

non null

ledger_entries

Écriture élémentaire en partie double (débit ou crédit d'un compte), jamais modifiable ni supprimable (trigger deny_ledger_mutation, RG-14). Somme des écritures d'une transaction toujours égale à zéro (assert_transaction_balanced).

id PKuuid

Identifiant de l'écriture.

non null

transaction_id FKuuid

→ transactions

Transaction à laquelle appartient cette écriture — la somme des écritures d'une même transaction doit être nulle (assert_transaction_balanced).

non null

account_id FKuuid

→ accounts

Compte débité ou crédité par cette écriture.

non null

direction varchar(6)

debit | credit.

non null

amount_xof bigint

Montant entier en francs CFA, toujours positif — le sens vient de `direction` (RG-13).

non null

created_at timestamptz

Horodatage de l'écriture — immuable dès l'insertion, bloqué en UPDATE/DELETE par le trigger deny_ledger_mutation (RG-14).

non null

contributions

Appel de cotisation d'un groupe (montant fixe par membre ou cagnotte libre), éventuellement rattaché à une réunion (RG-18, RG-31 pour la mesure du palier d'abonnement par analogie).

id PKuuid

Identifiant de l'appel de cotisation.

non null

group_id FKuuid

→ groups

Groupe qui appelle la cotisation.

non null

meeting_id FKuuid

→ meetings

Réunion à laquelle la cotisation est rattachée, si l'échéance = début de réunion plutôt qu'une date fixée.

title varchar(150)

Titre de l'appel de cotisation.

non null

mode varchar(15)

fixed_amount | open_pot — montant fixe par membre (échéance obligatoire) ou cagnotte libre.

non null

amount_per_member_xof bigint

Montant dû par défaut par membre ; requis si mode = fixed_amount, remplacé par le tarif de classe pour les membres concernés.

due_at timestamptz

Échéance — obligatoire si fixed_amount sans réunion rattachée.

closed_at timestamptz

Date de clôture de l'appel de cotisation.

contribution_payments

Paiement d'une cotisation par un membre, une seule fois par membre et par cotisation (contrainte contribution_payments_member_uniq). Méthode wallet (gratuite) ou opérateur (avec frais) — RG-18.

id PKuuid

Identifiant du paiement.

non null

contribution_id FKuuid

→ contributions

Appel de cotisation réglé.

non null

membership_id FKuuid

→ memberships

Membre payeur — un seul paiement par membre et par cotisation (contribution_payments_member_uniq, RG-18).

non null

amount_xof bigint

Montant payé, entier CFA.

non null

method varchar(10)

wallet (gratuit, PIN requis) | operator (frais de rechargement) — RG-18.

non null

paid_at timestamptz

Date effective de paiement.

non null

withdrawal_requests

Demande de retrait de caisse. Gèle les fonds à l'ouverture, fige le collège de chefs et le seuil requis (RG-20). Machine à états : draft → pending_approval → approved → awaiting_liquidity → executing → paid | expired | rejected | cancelled | failed.

id PKuuid

Identifiant de la demande de retrait.

non null

group_id FKuuid

→ groups

Groupe dont la caisse est sollicitée.

non null

requested_by_membership_id FKuuid

→ memberships

Trésorier demandeur — ne vote pas et ne compte pas dans le quorum s'il est aussi chef (RG-08).

non null

beneficiary_membership_id FKuuid

→ memberships

Bénéficiaire du retrait, obligatoirement un membre actif — jamais un numéro libre (RG-23).

non null

purpose text

Motif du retrait, visible des votants avant le vote.

non null

amount_xof bigint

Net attendu par le bénéficiaire, hors frais.

non null

fee_estimate_xof bigint

Frais opérateur estimés — le vote porte sur amount_xof + fee_estimate_xof, total sortant de caisse (RG-22).

non null

fee_actual_xof bigint

Frais réels constatés à l'exécution — l'écart avec l'estimation est écrit séparément (operator_fees), jamais fondu dans le montant voté (RG-22).

required_approvals integer

ceil(0.8 × chefs actifs hors demandeur), figé à l'ouverture (RG-20).

non null

status varchar(20)

= draft

draft | pending_approval | approved | awaiting_liquidity | executing | paid | expired | rejected | cancelled | failed — machine à états réelle du contrat API (WithdrawalStatus).

non null

justification varchar(15)

= not_required

not_required | pending | partial | settled — statut de reddition de comptes, distinct du statut d'approbation (RG-26).

non null

expires_at timestamptz

72 h après l'ouverture — expiration automatique sans quorum (RG-25).

non null

withdrawal_approvals

Vote d'un chef sur une demande de retrait. Un chef, une voix (contrainte d'unicité withdrawal_id/approver_membership_id) — RG-21. Grave une empreinte du montant, bénéficiaire et motif au moment du vote.

id PKuuid

Identifiant du vote.

non null

withdrawal_id FKuuid

→ withdrawal_requests

Demande de retrait votée.

non null

approver_membership_id FKuuid

→ memberships

Chef votant — contrainte d'unicité (withdrawal_id, approver_membership_id), un chef une voix (RG-21).

non null

decision varchar(10)

approve | reject.

non null

approved_at timestamptz

Horodatage du vote, après ré-authentification OTP distincte du PIN.

non null

disbursement_lines

Ligne de justification d'un décaissement exécuté, saisie par le bénéficiaire. Jamais une écriture comptable — un témoignage, hors grand livre (RG-26).

id PKuuid

Identifiant de la ligne de justification.

non null

withdrawal_id FKuuid

→ withdrawal_requests

Décaissement justifié.

non null

label varchar(150)

Libellé de la dépense déclarée.

non null

amount_xof bigint

Montant de la dépense — ne touche jamais le grand livre, c'est un témoignage (RG-26).

non null

spent_at date

Date de la dépense.

non null

receipt_asset_id uuid

Justificatif facultatif (media_assets, hors ERD détaillé) — beaucoup d'achats au marché n'en ont pas.

created_by FKuuid

→ users

Auteur de la ligne — normalement le bénéficiaire du retrait.

non null

meetings

Réunion d'un groupe (ordre du jour, date). Peut provenir d'une série récurrente (meeting_series, hors ERD détaillé). Porte le pointage de présence et le PV.

id PKuuid

Identifiant de la réunion.

non null

group_id FKuuid

→ groups

Groupe organisateur.

non null

title varchar(150)

Titre de la réunion.

non null

starts_at timestamptz

Date/heure de début — le pointage de présence n'ouvre que le jour même.

non null

series_id uuid

Série de réunions récurrentes d'origine (meeting_series, hors ERD détaillé) — une occurrence n'est générée qu'une fois (meetings_series_occurrence_uniq).

attendances

Pointage de présence d'une adhésion à une réunion, par QR tournant ou manuel. Double pointage sans effet par construction (contrainte attendances_uniq meeting_id/membership_id) — RG-27.

id PKuuid

Identifiant du pointage.

non null

meeting_id FKuuid

→ meetings

Réunion concernée.

non null

membership_id FKuuid

→ memberships

Membre pointé — contrainte attendances_uniq(meeting_id, membership_id), double pointage sans effet (RG-27).

non null

method varchar(10)

qr | manual — un pointage manuel est tracé comme tel avec l'identité de qui l'a saisi (règles métier §3).

non null

recorded_at timestamptz

Horodatage du pointage.

non null

minutes

Procès-verbal d'une réunion. Une fois publié, immuable : toute correction crée une nouvelle ligne dans minute_versions (RG-28), jamais une réécriture.

id PKuuid

Identifiant du PV.

non null

meeting_id FKuuid

→ meetings

Réunion documentée.

non null

status varchar(10)

= draft

draft | published — publié = verrouillé, toute correction passe par minute_versions (RG-28).

non null

current_version integer

= 1

Numéro de la version courante — incrémenté à chaque correction post-publication (minute_versions).

non null

published_at timestamptz

Date de première publication.

events

Événement de billetterie organisé par un groupe (formules Standard/VIP/VVIP via ticket_tiers, hors ERD détaillé). Les recettes vont toujours à la caisse du groupe (RG-30).

id PKuuid

Identifiant de l'événement.

non null

group_id FKuuid

→ groups

Groupe organisateur — les recettes vont toujours à sa caisse (RG-30).

non null

title varchar(150)

Titre de l'événement.

non null

starts_at timestamptz

Date/heure de l'événement.

non null

access_control varchar(15)

= all

all | by_role | named_list — contrôle d'accès configurable à l'entrée.

non null

eligible_member_class_id uuid

Classe de membre éligible si une formule de billet est réservée (eligibility_mode = class) — member_classes, hors ERD détaillé.

tickets

Billet nominatif issu d'une commande payée (orders, hors ERD détaillé). QR à usage unique, contrôle d'entrée par écriture conditionnelle hors ligne (RG-30).

id PKuuid

Identifiant du billet.

non null

event_id FKuuid

→ events

Événement concerné.

non null

ticket_tier_label varchar(60)

Libellé de la formule (Standard/VIP/VVIP) au moment de l'achat.

non null

serial integer

Numéro dans la série (ex. 1 sur 2) quand plusieurs billets sont achetés ensemble.

non null

qr_token varchar(100)

Jeton QR à usage unique, vérifiable hors ligne.

non null

used_at timestamptz

Horodatage d'utilisation — null tant que non scanné ; index tickets_unused_idx (WHERE used_at IS NULL) lu par le contrôle d'entrée.

used_at_gate varchar(60)

Porte d'entrée ayant scanné le billet.

subscriptions

Abonnement d'un groupe à un palier (plans). Statut dégradé en cas d'impayé mais ne bloque jamais la lecture du journal ni les retraits (RG-19). Palier mesuré à l'échéance uniquement (RG-31).

group_id PKuuid

→ groups

Groupe abonné — clé primaire, un groupe a au plus un abonnement courant.

non null

plan_code varchar(30)

Palier souscrit (plans, hors ERD détaillé : decouverte/association/federation/grand_groupe).

non null

billing_cycle varchar(10)

monthly | yearly.

non null

members_at_renewal integer

Nombre de membres actifs mesuré à la dernière échéance — le palier ne change jamais en cours de cycle (RG-31).

status varchar(10)

= free

active | free | unpaid | cancelled — unpaid dégrade le service mais ne bloque jamais le journal ni les retraits (RG-19).

non null

charge_source varchar(20)

group_treasury | chief_wallet — toujours autorisé explicitement par un chef, jamais de prélèvement silencieux (RG-32).

plan_locked boolean

= false

Palier fixé par l'administration — la mesure automatique à l'échéance ne s'applique plus.

non null

current_period_end timestamptz

Fin de la période en cours — échéance de mesure du palier et de facturation.

non null

invoices

Facture d'une période d'abonnement d'un groupe. Un échec documente sa cause (insufficient_funds / not_authorized) — RG-32 ; un abandon de créance porte obligatoirement son auteur et son motif.

id PKuuid

Identifiant de la facture.

non null

group_id FKuuid

→ groups

Groupe facturé.

non null

period_start date

Début de la période facturée.

non null

amount_xof bigint

Montant de la facture, entier CFA.

non null

status varchar(10)

= pending

pending | paid | failed | waived.

non null

failure_reason varchar(20)

insufficient_funds (caisse vide) | not_authorized (aucun chef actif n'a autorisé) — RG-32.

waived_by FKuuid

→ users

Agent back-office ayant abandonné la créance — obligatoire avec waived_reason si status=waived (invoices_waiver_is_signed).

waived_reason text

Motif de l'abandon de créance.

notifications

Ligne de notification adressée à un utilisateur. Les catégories financières (contribution, withdrawal, subscription) ne sont jamais désactivables (RG-35) ; reste épinglée en tête tant qu'une action requise n'est pas résolue.

id PKuuid

Identifiant de la notification.

non null

user_id FKuuid

→ users

Destinataire.

non null

category varchar(20)

contribution | withdrawal | subscription (jamais désactivables, RG-35) | feed | meeting_reminder (désactivables par groupe/catégorie).

non null

requires_action boolean

= false

Si vrai, reste épinglée en tête de liste tant que resolved_at est nul, même après lecture.

non null

read_at timestamptz

Date de lecture — distincte de resolved_at.

resolved_at timestamptz

Date à laquelle l'action requise a été traitée.

Documentation API

GET/v1/groups/directory200

Annuaire public des groupes — un groupe privé n'apparaît jamais ici (RG-10).

Exemple de réponse

{"items": [{"id": "9b1e...", "name": "Amicale des Ressortissants de Daloa", "city": "Abidjan"}], "nextCursor": null}
GET/v1/groups/{groupId}/public200, 404

Fiche publique d'un groupe — jamais de liste de membres, numéro personnel ou montant de caisse (règles métier §5).

Exemple de réponse

{"id": "9b1e...", "name": "Amicale des Ressortissants de Daloa", "city": "Abidjan", "galleryUrls": [], "upcomingEvents": []}
GET/v1/plans200

Lister les paliers d'abonnement disponibles (Découverte/Association/Fédération/Grand groupe).

Exemple de réponse

[{"code": "association", "label": "Association", "memberFloor": 16, "memberCeiling": 50, "monthlyPriceXof": 5000}]

Jalons

  1. Backend/API complet et validé

    Atteint

    147 opérations OpenAPI 3.1, module financier (grand livre, retraits 80%) codé et documenté, 29 migrations appliquées.

    25 sept. 2026

  2. Frontend Nuxt/Ionic — parcours membre et bureau fonctionnels

    À faire

    Auth OTP, dashboard groupe, financier (journal, retraits), réunions, événements opérationnels de bout en bout sur l'app web/mobile hybride.

    30 nov. 2026

  3. 3 groupes pilotes signés à Abidjan

    À faire

    Un par segment prioritaire (association de ressortissants, amicale d'entreprise, groupe religieux) — produit sans argent d'abord (semaines 2-8).

    21 nov. 2026

  4. Premier retrait à 80% exécuté en argent réel

    À faire

    Journal, cotisations, puis retrait validé collectivement et payé sur un seul groupe volontaire, montants modestes (semaines 8-12 du plan de mise sur le marché).

    20 déc. 2026