Skip to main content

koprogo_api/domain/copropriete/
conseil_de_copropriete.rs

1//! Le conseil de copropriété.
2//!
3//! Art. 3.90 § 1er :
4//!
5//! > « Dans tout immeuble ou groupe d'immeubles **d'au moins vingt lots à
6//! > l'exclusion des caves, garages et parkings**, un conseil de copropriété
7//! > est constitué par la **première assemblée générale**. Ce conseil, dont
8//! > peuvent être membre **les titulaires d'un droit réel disposant d'un droit
9//! > de vote** à l'assemblée générale, est chargé de **veiller à la bonne
10//! > exécution par le syndic de ses missions** [...] »
11//!
12//! Et § 2 : en dessous de vingt lots, l'assemblée **peut** en constituer un,
13//! « composé de la même manière et chargé des mêmes missions ». La faculté
14//! remplace l'obligation, le reste ne change pas.
15//!
16//! § 3 : nomination « à la **majorité absolue**, pour **chaque membre
17//! séparément** », et le mandat « dure **jusqu'à la prochaine assemblée
18//! générale ordinaire** et est renouvelable ».
19//!
20//! **Le décompte est le même que celui de la comptabilité simplifiée**
21//! (Art. 3.89 § 5, 15°) : vingt lots, caves, garages et parkings exclus. Le
22//! législateur emploie deux fois la même définition, et le code la partage au
23//! lieu de la réécrire — s'il la réécrivait, les deux finiraient par diverger.
24//!
25//! La borne, en revanche, n'est pas la même : la comptabilité simplifiée vaut
26//! « de **moins de** vingt lots », le conseil est obligatoire « d'**au moins**
27//! vingt ». Elles se complètent exactement, mais les inverser ferait basculer
28//! une copropriété de vingt lots du mauvais côté des deux.
29//!
30//! Voir issue #753.
31
32use super::decompte_des_lots::lots_comptes;
33use super::unit::UnitType;
34use super::unit_owner::OwnershipType;
35use chrono::{DateTime, Utc};
36use uuid::Uuid;
37
38/// Le seuil de l'Art. 3.90 § 1er, en lots comptés.
39pub const SEUIL_OBLIGATION: usize = 20;
40
41/// Le conseil est-il obligatoire, facultatif, ou déjà là ?
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum RegimeDuConseil {
44    /// Au moins vingt lots : la première assemblée doit le constituer.
45    Obligatoire,
46    /// Moins de vingt lots : l'assemblée peut le constituer (§ 2).
47    Facultatif,
48}
49
50/// Ce qui empêche une nomination.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum NominationRefusee {
53    /// Seuls les titulaires d'un droit réel **disposant du droit de vote**
54    /// peuvent siéger.
55    SansDroitDeVote,
56    /// Art. 3.89 § 9 : dans une même ACP, le syndic ne peut être ni membre du
57    /// conseil de copropriété ni commissaire aux comptes.
58    LeSyndicNePeutPasSieger,
59}
60
61impl std::fmt::Display for NominationRefusee {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::SansDroitDeVote => write!(
65                f,
66                "Art. 3.90 § 1er : seuls les titulaires d'un droit réel disposant du droit \
67                 de vote peuvent siéger au conseil de copropriété."
68            ),
69            Self::LeSyndicNePeutPasSieger => write!(
70                f,
71                "Art. 3.89 § 9 : dans une même ACP, le syndic ne peut être ni membre du \
72                 conseil de copropriété ni commissaire aux comptes."
73            ),
74        }
75    }
76}
77
78/// Le régime applicable, dérivé du décompte légal des lots.
79///
80/// La borne est **inclusive** : vingt lots rendent le conseil obligatoire.
81pub fn regime(natures_des_lots: &[UnitType]) -> RegimeDuConseil {
82    if lots_comptes(natures_des_lots) >= SEUIL_OBLIGATION {
83        RegimeDuConseil::Obligatoire
84    } else {
85        RegimeDuConseil::Facultatif
86    }
87}
88
89/// Un membre du conseil, avec son mandat borné.
90#[derive(Debug, Clone, PartialEq)]
91pub struct MembreDuConseil {
92    pub owner_id: Uuid,
93    pub nomme_le: DateTime<Utc>,
94    /// Le mandat court jusqu'à la prochaine AG ordinaire (§ 3). Tant qu'elle
95    /// ne s'est pas tenue, la date est inconnue — d'où l'`Option`.
96    pub echu_le: Option<DateTime<Utc>>,
97}
98
99impl MembreDuConseil {
100    /// Nomme un membre, si la loi le permet.
101    ///
102    /// `nature_du_droit` et `dispose_du_droit_de_vote` viennent de la
103    /// détention : l'article vise « les titulaires d'un droit réel disposant
104    /// d'un droit de vote », donc les deux conditions.
105    pub fn nommer(
106        owner_id: Uuid,
107        nature_du_droit: OwnershipType,
108        dispose_du_droit_de_vote: bool,
109        est_le_syndic: bool,
110        nomme_le: DateTime<Utc>,
111    ) -> Result<Self, NominationRefusee> {
112        if est_le_syndic {
113            return Err(NominationRefusee::LeSyndicNePeutPasSieger);
114        }
115        if !dispose_du_droit_de_vote {
116            return Err(NominationRefusee::SansDroitDeVote);
117        }
118        // La nature du droit ne restreint pas au-delà : usufruitier,
119        // nu-propriétaire ou indivisaire peuvent siéger dès lors qu'ils ont le
120        // droit de vote. L'article dit « titulaires d'un droit réel », sans
121        // autre distinction.
122        let _ = nature_du_droit;
123        Ok(Self {
124            owner_id,
125            nomme_le,
126            echu_le: None,
127        })
128    }
129
130    /// Le mandat prend fin à la prochaine assemblée générale ordinaire.
131    pub fn echoir_a_lag_ordinaire(&mut self, tenue_le: DateTime<Utc>) {
132        self.echu_le = Some(tenue_le);
133    }
134
135    pub fn en_fonction_le(&self, moment: DateTime<Utc>) -> bool {
136        moment >= self.nomme_le && self.echu_le.is_none_or(|fin| moment < fin)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use chrono::Duration;
144
145    fn lots(appartements: usize, caves: usize, parkings: usize) -> Vec<UnitType> {
146        let mut v = vec![UnitType::Apartment; appartements];
147        v.extend(vec![UnitType::Cellar; caves]);
148        v.extend(vec![UnitType::Parking; parkings]);
149        v
150    }
151
152    fn il_y_a(jours: i64) -> DateTime<Utc> {
153        Utc::now() - Duration::days(jours)
154    }
155
156    #[test]
157    fn happy_a_partir_de_vingt_lots_le_conseil_est_obligatoire() {
158        assert_eq!(regime(&lots(20, 0, 0)), RegimeDuConseil::Obligatoire);
159    }
160
161    /// @edge — la borne est inclusive, à l'inverse de celle du régime
162    /// comptable.
163    ///
164    /// La comptabilité simplifiée vaut « de moins de vingt lots », le conseil
165    /// est obligatoire « d'au moins vingt ». Elles se complètent exactement,
166    /// et les inverser ferait basculer une copropriété de vingt lots du
167    /// mauvais côté des deux.
168    #[test]
169    fn edge_dix_neuf_lots_rendent_le_conseil_facultatif() {
170        assert_eq!(regime(&lots(19, 0, 0)), RegimeDuConseil::Facultatif);
171        assert_eq!(regime(&lots(20, 0, 0)), RegimeDuConseil::Obligatoire);
172    }
173
174    /// Le décompte est celui de l'Art. 3.89 § 5, 15°, partagé et non réécrit.
175    #[test]
176    fn happy_caves_et_parkings_sortent_du_decompte_ici_aussi() {
177        let natures = lots(15, 15, 20);
178        assert_eq!(natures.len(), 50);
179        assert_eq!(
180            regime(&natures),
181            RegimeDuConseil::Facultatif,
182            "quinze lots au sens de la loi, pas cinquante"
183        );
184    }
185
186    // ── La nomination ──────────────────────────────────────────────
187
188    #[test]
189    fn happy_un_coproprietaire_votant_peut_sieger() {
190        assert!(MembreDuConseil::nommer(
191            Uuid::new_v4(),
192            OwnershipType::FullOwner,
193            true,
194            false,
195            il_y_a(10)
196        )
197        .is_ok());
198    }
199
200    /// L'usufruitier siège s'il a le droit de vote : l'article dit
201    /// « titulaires d'un droit réel », sans autre distinction.
202    #[test]
203    fn happy_un_usufruitier_votant_peut_sieger() {
204        assert!(MembreDuConseil::nommer(
205            Uuid::new_v4(),
206            OwnershipType::Usufruct,
207            true,
208            false,
209            il_y_a(10)
210        )
211        .is_ok());
212    }
213
214    #[test]
215    fn negative_sans_droit_de_vote_on_ne_siege_pas() {
216        assert_eq!(
217            MembreDuConseil::nommer(
218                Uuid::new_v4(),
219                OwnershipType::BareOwner,
220                false,
221                false,
222                il_y_a(10)
223            ),
224            Err(NominationRefusee::SansDroitDeVote)
225        );
226    }
227
228    /// @security — Art. 3.89 § 9 : le syndic ne surveille pas le syndic.
229    #[test]
230    fn security_le_syndic_ne_peut_pas_sieger_au_conseil_quil_doit_subir() {
231        assert_eq!(
232            MembreDuConseil::nommer(
233                Uuid::new_v4(),
234                OwnershipType::FullOwner,
235                true,
236                true,
237                il_y_a(10)
238            ),
239            Err(NominationRefusee::LeSyndicNePeutPasSieger)
240        );
241    }
242
243    // ── Le mandat ──────────────────────────────────────────────────
244
245    #[test]
246    fn happy_un_mandat_neuf_est_en_cours() {
247        let membre = MembreDuConseil::nommer(
248            Uuid::new_v4(),
249            OwnershipType::FullOwner,
250            true,
251            false,
252            il_y_a(10),
253        )
254        .unwrap();
255        assert!(membre.en_fonction_le(Utc::now()));
256    }
257
258    /// Le mandat « dure jusqu'à la prochaine assemblée générale ordinaire »
259    /// (§ 3) : il s'éteint le jour où elle se tient.
260    #[test]
261    fn happy_le_mandat_sacheve_a_la_prochaine_ag_ordinaire() {
262        let mut membre = MembreDuConseil::nommer(
263            Uuid::new_v4(),
264            OwnershipType::FullOwner,
265            true,
266            false,
267            il_y_a(400),
268        )
269        .unwrap();
270        let ag = il_y_a(30);
271        membre.echoir_a_lag_ordinaire(ag);
272
273        assert!(membre.en_fonction_le(ag - Duration::days(1)));
274        assert!(
275            !membre.en_fonction_le(ag),
276            "le jour de l'AG, le mandat est échu — comme pour le mandat de \
277             syndic, début inclus et fin exclue"
278        );
279    }
280}