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}