Skip to main content

koprogo_api/domain/copropriete/
commissaire_aux_comptes.rs

1//! Le commissaire aux comptes.
2//!
3//! Art. 3.91, en une seule phrase :
4//!
5//! > « L'assemblée générale désigne **annuellement** un commissaire aux
6//! > comptes ou un **collège** de commissaires aux comptes, **copropriétaires
7//! > ou non**, qui **contrôlent les comptes** de l'association des
8//! > copropriétaires, dont les compétences et obligations sont déterminées par
9//! > le **règlement d'ordre intérieur**. »
10//!
11//! Le domaine n'en portait qu'une seule occurrence. C'est pourtant un organe
12//! de l'ACP au même titre que l'assemblée, le syndic et le conseil de
13//! copropriété — et sans lui, la comptabilité que ce logiciel tient n'a aucun
14//! organe de vérification.
15//!
16//! Trois choses que la phrase dit et qu'il ne faut pas raboter :
17//!
18//! 1. **annuellement** — le mandat n'est pas reconduit tacitement. Une
19//!    désignation qui date de deux exercices n'en est plus une ;
20//! 2. **ou un collège** — la fonction peut être partagée, et le modèle doit
21//!    donc accepter plusieurs titulaires pour un même exercice ;
22//! 3. **copropriétaires ou non** — un expert-comptable externe est parfaitement
23//!    admis. Exiger la qualité de copropriétaire serait ajouter une condition
24//!    que la loi ne pose pas.
25//!
26//! Le seul empêchement vient d'ailleurs : Art. 3.89 § 9, « au sein d'une même
27//! association de copropriétaires, un syndic ne peut être en même temps ni
28//! membre du conseil de copropriété ni commissaire aux comptes ». On ne se
29//! contrôle pas soi-même.
30//!
31//! Les compétences précises relèvent du ROI, donc du paramétrage de chaque
32//! ACP, et non du code.
33//!
34//! Voir issue #754.
35
36use uuid::Uuid;
37
38/// Ce qui empêche une désignation.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum DesignationRefusee {
41    /// Art. 3.89 § 9 : on ne contrôle pas ses propres comptes.
42    LeSyndicNePeutPasControlerSesComptes,
43    /// Un collège vide n'est pas un collège.
44    CollegeVide,
45    /// Deux fois la même personne dans le même collège.
46    DoublonDansLeCollege { titulaire: Uuid },
47}
48
49impl std::fmt::Display for DesignationRefusee {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::LeSyndicNePeutPasControlerSesComptes => write!(
53                f,
54                "Art. 3.89 § 9 : le syndic ne peut pas être commissaire aux comptes de \
55                 l'ACP qu'il gère."
56            ),
57            Self::CollegeVide => write!(
58                f,
59                "Art. 3.91 : l'assemblée doit désigner au moins un commissaire aux comptes."
60            ),
61            Self::DoublonDansLeCollege { titulaire } => write!(
62                f,
63                "Art. 3.91 : {titulaire} figure deux fois dans le collège."
64            ),
65        }
66    }
67}
68
69/// La désignation d'un commissaire, ou d'un collège, pour un exercice.
70#[derive(Debug, Clone, PartialEq)]
71pub struct Commissariat {
72    /// Un ou plusieurs titulaires — l'article prévoit expressément le collège.
73    pub titulaires: Vec<Uuid>,
74    /// L'exercice contrôlé. La désignation est **annuelle** : elle ne vaut que
75    /// pour celui-là.
76    pub exercice: i32,
77}
78
79impl Commissariat {
80    /// Désigne le ou les commissaires pour un exercice.
81    ///
82    /// `syndic_owner_id` est l'identifiant du syndic **s'il est aussi
83    /// copropriétaire** — le seul cas où il pourrait figurer dans la liste.
84    pub fn designer(
85        titulaires: Vec<Uuid>,
86        exercice: i32,
87        syndic_owner_id: Option<Uuid>,
88    ) -> Result<Self, DesignationRefusee> {
89        if titulaires.is_empty() {
90            return Err(DesignationRefusee::CollegeVide);
91        }
92        if let Some(syndic) = syndic_owner_id {
93            if titulaires.contains(&syndic) {
94                return Err(DesignationRefusee::LeSyndicNePeutPasControlerSesComptes);
95            }
96        }
97        for (i, titulaire) in titulaires.iter().enumerate() {
98            if titulaires[i + 1..].contains(titulaire) {
99                return Err(DesignationRefusee::DoublonDansLeCollege {
100                    titulaire: *titulaire,
101                });
102            }
103        }
104        Ok(Self {
105            titulaires,
106            exercice,
107        })
108    }
109
110    pub fn est_un_college(&self) -> bool {
111        self.titulaires.len() > 1
112    }
113
114    /// Cette désignation couvre-t-elle l'exercice demandé ?
115    ///
116    /// Elle ne couvre que le sien : « désigne annuellement ». Une désignation
117    /// qui date de l'exercice précédent ne vaut pas pour celui-ci, faute de
118    /// reconduction tacite.
119    pub fn couvre(&self, exercice: i32) -> bool {
120        self.exercice == exercice
121    }
122}
123
124/// L'ACP a-t-elle un commissaire pour cet exercice ?
125///
126/// Le manque n'est pas une faute du syndic mais un défaut de l'assemblée : les
127/// comptes de l'exercice n'ont alors personne pour les contrôler.
128pub fn commissariat_de_lexercice(
129    designations: &[Commissariat],
130    exercice: i32,
131) -> Option<&Commissariat> {
132    designations.iter().find(|d| d.couvre(exercice))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn happy_un_commissaire_unique_est_valable() {
141        let c = Commissariat::designer(vec![Uuid::new_v4()], 2026, None).unwrap();
142        assert!(!c.est_un_college());
143    }
144
145    /// L'article prévoit expressément le collège.
146    #[test]
147    fn happy_un_college_de_trois_est_valable() {
148        let c = Commissariat::designer(
149            vec![Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()],
150            2026,
151            None,
152        )
153        .unwrap();
154        assert!(c.est_un_college());
155        assert_eq!(c.titulaires.len(), 3);
156    }
157
158    /// « Copropriétaires ou non » : un expert externe est admis, et rien dans
159    /// la désignation n'exige la qualité de copropriétaire.
160    #[test]
161    fn happy_un_expert_externe_peut_etre_designe() {
162        let externe = Uuid::new_v4();
163        let c = Commissariat::designer(vec![externe], 2026, Some(Uuid::new_v4())).unwrap();
164        assert_eq!(c.titulaires, vec![externe]);
165    }
166
167    /// @security — Art. 3.89 § 9 : on ne contrôle pas ses propres comptes.
168    #[test]
169    fn security_le_syndic_ne_peut_pas_controler_ses_propres_comptes() {
170        let syndic = Uuid::new_v4();
171        assert_eq!(
172            Commissariat::designer(vec![Uuid::new_v4(), syndic], 2026, Some(syndic)),
173            Err(DesignationRefusee::LeSyndicNePeutPasControlerSesComptes)
174        );
175    }
176
177    #[test]
178    fn negative_un_college_vide_est_refuse() {
179        assert_eq!(
180            Commissariat::designer(vec![], 2026, None),
181            Err(DesignationRefusee::CollegeVide)
182        );
183    }
184
185    #[test]
186    fn negative_un_doublon_dans_le_college_est_refuse() {
187        let deux_fois = Uuid::new_v4();
188        assert_eq!(
189            Commissariat::designer(vec![deux_fois, Uuid::new_v4(), deux_fois], 2026, None),
190            Err(DesignationRefusee::DoublonDansLeCollege {
191                titulaire: deux_fois
192            })
193        );
194    }
195
196    // ── Le caractère annuel ────────────────────────────────────────
197
198    #[test]
199    fn happy_une_designation_couvre_son_exercice() {
200        let designations = vec![Commissariat::designer(vec![Uuid::new_v4()], 2026, None).unwrap()];
201        assert!(commissariat_de_lexercice(&designations, 2026).is_some());
202    }
203
204    /// @security — pas de reconduction tacite.
205    ///
206    /// « Désigne annuellement » : une désignation de l'exercice précédent ne
207    /// vaut pas pour celui-ci, et les comptes de 2027 n'ont alors personne
208    /// pour les contrôler.
209    #[test]
210    fn security_une_designation_ne_se_reconduit_pas_tacitement() {
211        let designations = vec![Commissariat::designer(vec![Uuid::new_v4()], 2026, None).unwrap()];
212        assert!(
213            commissariat_de_lexercice(&designations, 2027).is_none(),
214            "la désignation de 2026 ne couvre pas 2027"
215        );
216    }
217
218    #[test]
219    fn happy_plusieurs_exercices_se_suivent_sans_se_confondre() {
220        let designations = vec![
221            Commissariat::designer(vec![Uuid::new_v4()], 2025, None).unwrap(),
222            Commissariat::designer(vec![Uuid::new_v4()], 2026, None).unwrap(),
223        ];
224        assert_ne!(
225            commissariat_de_lexercice(&designations, 2025)
226                .unwrap()
227                .titulaires,
228            commissariat_de_lexercice(&designations, 2026)
229                .unwrap()
230                .titulaires
231        );
232    }
233}