koprogo_api/domain/copropriete/syndic_mandate.rs
1// Domain Entity: SyndicMandate — mandat de gestion d'une ACP
2//
3// Une ACP est une entité juridique à part entière : elle a son numéro BCE,
4// son acte de base, ses lots, ses copropriétaires et sa comptabilité. Un
5// syndic n'en est que le MANDATAIRE, désigné par l'assemblée générale et
6// révocable par elle (Art. 3.89 CC).
7//
8// Le modèle exprimait ce lien par un simple champ `Acp.organization_id`.
9// Changer de syndic ÉCRASE donc le précédent : le système ne sait plus qui
10// gérait l'ACP avant, ni depuis quand le nouveau la gère. Or cette date
11// n'est pas cosmétique — un état daté porte sur une date de référence et
12// engage le syndic en fonction À CETTE DATE (Art. 3.94). Sans historique de
13// mandat, la question « qui était mandataire le 12 mars ? » n'a pas de
14// réponse.
15//
16// Cette entité rend le mandat explicite et daté. Elle n'enlève rien à
17// `Acp::organization_id`, qui reste la lecture rapide du mandataire courant.
18
19use chrono::{DateTime, Duration, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23/// La durée maximale d'un mandat de syndic, en jours.
24///
25/// ── Art. 3.89 § 1er, alinéa 2 ─────────────────────────────────────────────
26///
27/// > « Le syndic est désigné par le règlement de copropriété ou par une
28/// > décision de l'assemblée générale. **La durée de son mandat ne peut
29/// > excéder trois ans**, mais est renouvelable par décision expresse de
30/// > l'assemblée générale. »
31///
32/// Trois ans, et **renouvelable par décision EXPRESSE**. C'est cette dernière
33/// précision qui donne son sens au plafond : sans elle, un mandat se
34/// reconduirait tacitement et l'assemblée perdrait le seul rendez-vous où elle
35/// juge son mandataire. Le plafond n'est pas une formalité de durée, c'est un
36/// rendez-vous imposé.
37///
38/// ── Pourquoi 3 × 365 et non une arithmétique de calendrier ───────────────
39///
40/// Trois années civiles comptent une ou deux journées bissextiles selon la
41/// date de départ. Prendre 1095 jours donne un plafond LÉGÈREMENT plus court
42/// que trois années calendaires dans le cas bissextile — donc plus strict que
43/// la loi, jamais plus permissif. C'est le bon sens de l'arrondi pour une
44/// borne maximale : on ne dépasse pas par erreur de calcul.
45///
46/// Ne pas confondre avec `MAX_MANDATE_DURATION_DAYS = 365 * 5` de
47/// `mandate.rs` : celui-là borne les mandats de professionnels externes —
48/// avocat, notaire, architecte — et c'est une hygiène anti-abus, pas cet
49/// article. La confusion a déjà été faite (#837).
50pub const DUREE_MAXIMALE_JOURS: i64 = 3 * 365;
51
52/// Erreurs de validation d'un mandat de gestion.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum SyndicMandateError {
55 /// La date de fin précède la date de début.
56 EndBeforeStart,
57 /// Le mandat est déjà clos : on ne le révoque pas deux fois.
58 AlreadyEnded,
59 /// La révocation précède la prise d'effet.
60 RevocationBeforeStart,
61 /// La fin proposée dépasse les trois ans de l'Art. 3.89 § 1er.
62 DureeExcedeTroisAns,
63}
64
65impl std::fmt::Display for SyndicMandateError {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 Self::EndBeforeStart => {
69 write!(f, "Mandate end date cannot precede its start date")
70 }
71 Self::AlreadyEnded => write!(f, "Mandate is already ended"),
72 Self::RevocationBeforeStart => {
73 write!(f, "Mandate cannot be revoked before it takes effect")
74 }
75 Self::DureeExcedeTroisAns => write!(
76 f,
77 "La durée du mandat de syndic ne peut excéder trois ans \
78 (Art. 3.89 § 1er). Il est renouvelable par décision expresse \
79 de l'assemblée générale."
80 ),
81 }
82 }
83}
84
85impl std::error::Error for SyndicMandateError {}
86
87/// Mandat de gestion d'une ACP par un cabinet syndic, borné dans le temps.
88///
89/// `ended_at == None` signifie « mandat en cours ». Un mandat clos n'est
90/// jamais supprimé : c'est lui qui permet de répondre à « qui gérait cette
91/// ACP à telle date ».
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct SyndicMandate {
94 pub id: Uuid,
95 /// L'ACP gérée. C'est elle qui possède la comptabilité, pas le syndic.
96 pub acp_id: Uuid,
97 /// Le cabinet syndic mandataire.
98 pub organization_id: Uuid,
99 /// Prise d'effet du mandat.
100 pub started_at: DateTime<Utc>,
101 /// Fin du mandat. `None` = en cours.
102 pub ended_at: Option<DateTime<Utc>>,
103 /// Décision d'assemblée générale ayant désigné le syndic (Art. 3.89 CC).
104 ///
105 /// Optionnel : la première mise en gestion d'une ACP encodée par le
106 /// SuperAdmin SaaS précède l'AG qui la confirmera.
107 pub appointed_by_meeting_id: Option<Uuid>,
108 /// Décision d'assemblée générale ayant mis fin au mandat.
109 pub revoked_by_meeting_id: Option<Uuid>,
110 /// Motif de fin, pour la trace : non-renouvellement, révocation,
111 /// démission, cession de portefeuille.
112 pub end_reason: Option<String>,
113 pub created_at: DateTime<Utc>,
114 pub updated_at: DateTime<Utc>,
115}
116
117impl SyndicMandate {
118 /// Confie une ACP à un cabinet syndic.
119 pub fn new(
120 acp_id: Uuid,
121 organization_id: Uuid,
122 started_at: DateTime<Utc>,
123 appointed_by_meeting_id: Option<Uuid>,
124 ) -> Self {
125 let now = Utc::now();
126 Self {
127 id: Uuid::new_v4(),
128 acp_id,
129 organization_id,
130 started_at,
131 ended_at: None,
132 appointed_by_meeting_id,
133 revoked_by_meeting_id: None,
134 end_reason: None,
135 created_at: now,
136 updated_at: now,
137 }
138 }
139
140 /// Le mandat est-il en cours ?
141 pub fn is_active(&self) -> bool {
142 self.ended_at.is_none()
143 }
144
145 /// Le mandat couvrait-il cette date ?
146 ///
147 /// Bornes : début INCLUS, fin EXCLUE. Le jour où un mandat s'achève est
148 /// le premier jour du suivant — deux syndics ne peuvent pas être en
149 /// fonction au même instant sur la même ACP, et aucun instant n'est
150 /// laissé sans mandataire au moment d'une passation.
151 ///
152 /// C'est ce que consulte un état daté : il porte sur une date de
153 /// référence et engage le syndic en fonction à cette date.
154 pub fn covers(&self, moment: DateTime<Utc>) -> bool {
155 if moment < self.started_at {
156 return false;
157 }
158 match self.ended_at {
159 Some(fin) => moment < fin,
160 None => true,
161 }
162 }
163
164 /// L'échéance légale du mandat : trois ans après sa prise d'effet.
165 ///
166 /// Au-delà, le mandat est expiré de plein droit — l'assemblée doit l'avoir
167 /// renouvelé par décision expresse (Art. 3.89 § 1er).
168 pub fn echeance_legale(&self) -> DateTime<Utc> {
169 self.started_at + Duration::days(DUREE_MAXIMALE_JOURS)
170 }
171
172 /// Le mandat a-t-il dépassé ses trois ans à cette date ?
173 ///
174 /// Un mandat sans date de fin qui franchit son échéance N'EST PLUS
175 /// valable : le silence de l'assemblée ne le reconduit pas. C'est
176 /// précisément ce que le plafond empêche.
177 ///
178 /// Vrai aussi pour un mandat clos APRÈS son échéance : la clôture tardive
179 /// ne régularise pas la période excédentaire.
180 pub fn est_expire_de_plein_droit(&self, moment: DateTime<Utc>) -> bool {
181 moment >= self.echeance_legale()
182 }
183
184 /// Met fin au mandat.
185 ///
186 /// Refuse une fin postérieure à l'échéance légale : dater la clôture d'un
187 /// mandat au-delà de ses trois ans reviendrait à écrire dans le registre
188 /// qu'il a couvert une période qu'il ne pouvait pas couvrir.
189 pub fn revoke(
190 &mut self,
191 ended_at: DateTime<Utc>,
192 revoked_by_meeting_id: Option<Uuid>,
193 end_reason: Option<String>,
194 ) -> Result<(), SyndicMandateError> {
195 if self.ended_at.is_some() {
196 return Err(SyndicMandateError::AlreadyEnded);
197 }
198 if ended_at < self.started_at {
199 return Err(SyndicMandateError::RevocationBeforeStart);
200 }
201 if ended_at > self.echeance_legale() {
202 return Err(SyndicMandateError::DureeExcedeTroisAns);
203 }
204 self.ended_at = Some(ended_at);
205 self.revoked_by_meeting_id = revoked_by_meeting_id;
206 self.end_reason = end_reason;
207 self.updated_at = Utc::now();
208 Ok(())
209 }
210
211 /// Le mandataire à une date donnée, parmi un historique.
212 ///
213 /// Renvoie `None` si l'ACP n'était confiée à personne — cas réel : une
214 /// ACP encodée par le SuperAdmin mais pas encore confiée, ou une ACP
215 /// auto-gérée (ADR-0010).
216 pub fn holder_at(mandats: &[SyndicMandate], moment: DateTime<Utc>) -> Option<Uuid> {
217 mandats
218 .iter()
219 .find(|m| m.covers(moment))
220 .map(|m| m.organization_id)
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use chrono::Duration;
228
229 fn mandat(debut_il_y_a_jours: i64) -> SyndicMandate {
230 SyndicMandate::new(
231 Uuid::new_v4(),
232 Uuid::new_v4(),
233 Utc::now() - Duration::days(debut_il_y_a_jours),
234 None,
235 )
236 }
237
238 #[test]
239 fn happy_un_mandat_neuf_est_en_cours() {
240 let m = mandat(30);
241 assert!(m.is_active());
242 assert!(m.covers(Utc::now()));
243 assert!(m.covers(Utc::now() - Duration::days(10)));
244 }
245
246 #[test]
247 fn happy_un_mandat_ne_couvre_pas_lavant() {
248 let m = mandat(30);
249 assert!(
250 !m.covers(Utc::now() - Duration::days(60)),
251 "le syndic n'engage rien avant sa prise de fonction"
252 );
253 }
254
255 #[test]
256 fn happy_revocation_ferme_le_mandat() {
257 let mut m = mandat(365);
258 let fin = Utc::now() - Duration::days(30);
259 m.revoke(fin, Some(Uuid::new_v4()), Some("Non-renouvellement".into()))
260 .unwrap();
261
262 assert!(!m.is_active());
263 assert!(
264 m.covers(Utc::now() - Duration::days(60)),
265 "il engageait encore la copropriété avant sa révocation"
266 );
267 assert!(!m.covers(Utc::now()), "il n'engage plus rien après");
268 }
269
270 /// @edge — bornes : début inclus, fin exclue.
271 ///
272 /// C'est ce qui garantit qu'à l'instant d'une passation il y a
273 /// exactement UN mandataire : ni deux, ni zéro.
274 #[test]
275 fn edge_bornes_debut_inclus_fin_exclue() {
276 let debut = Utc::now() - Duration::days(100);
277 let fin = Utc::now() - Duration::days(50);
278 let mut m = SyndicMandate::new(Uuid::new_v4(), Uuid::new_v4(), debut, None);
279 m.revoke(fin, None, None).unwrap();
280
281 assert!(
282 m.covers(debut),
283 "le jour de la prise de fonction est couvert"
284 );
285 assert!(
286 !m.covers(fin),
287 "le jour de la fin appartient au mandat suivant"
288 );
289 assert!(!m.covers(debut - Duration::milliseconds(1)));
290 }
291
292 #[test]
293 fn negative_on_ne_revoque_pas_deux_fois() {
294 let mut m = mandat(100);
295 m.revoke(Utc::now(), None, None).unwrap();
296 assert_eq!(
297 m.revoke(Utc::now(), None, None),
298 Err(SyndicMandateError::AlreadyEnded)
299 );
300 }
301
302 #[test]
303 fn negative_revocation_anterieure_a_la_prise_deffet() {
304 let mut m = mandat(10);
305 assert_eq!(
306 m.revoke(Utc::now() - Duration::days(30), None, None),
307 Err(SyndicMandateError::RevocationBeforeStart)
308 );
309 }
310
311 /// Le cœur de l'affaire : « qui gérait cette ACP le 12 mars ? »
312 ///
313 /// Un état daté porte sur une date de référence et engage le syndic en
314 /// fonction à cette date. Avec un simple champ mutable sur l'ACP, cette
315 /// question n'avait pas de réponse : la passation effaçait le prédécesseur.
316 #[test]
317 fn happy_qui_gerait_lacp_a_telle_date() {
318 let acp = Uuid::new_v4();
319 let ancien = Uuid::new_v4();
320 let nouveau = Uuid::new_v4();
321 let passation = Utc::now() - Duration::days(90);
322
323 let mut m1 = SyndicMandate::new(acp, ancien, Utc::now() - Duration::days(730), None);
324 m1.revoke(
325 passation,
326 Some(Uuid::new_v4()),
327 Some("Révocation AG".into()),
328 )
329 .unwrap();
330 let m2 = SyndicMandate::new(acp, nouveau, passation, Some(Uuid::new_v4()));
331 let historique = vec![m1, m2];
332
333 assert_eq!(
334 SyndicMandate::holder_at(&historique, Utc::now() - Duration::days(365)),
335 Some(ancien),
336 "avant la passation, c'est l'ancien cabinet qui engageait l'ACP"
337 );
338 assert_eq!(
339 SyndicMandate::holder_at(&historique, Utc::now()),
340 Some(nouveau)
341 );
342 // À l'instant exact de la passation : exactement un mandataire.
343 assert_eq!(
344 SyndicMandate::holder_at(&historique, passation),
345 Some(nouveau),
346 "la borne de fin étant exclue, la passation ne laisse aucun trou"
347 );
348 }
349
350 /// @edge — une ACP encodée mais pas encore confiée n'a pas de mandataire.
351 #[test]
352 fn edge_acp_sans_mandataire() {
353 assert_eq!(SyndicMandate::holder_at(&[], Utc::now()), None);
354 }
355
356 // ── Art. 3.89 § 1er : la durée du mandat n'excède pas trois ans ────────
357 //
358 // Le registre légal déclarait cet article couvert et attesté par
359 // `happy_un_mandat_neuf_est_en_cours` — un test qui vérifie qu'un mandat
360 // de trente jours est en cours, et qui ne dit rien d'un plafond. Le
361 // plafond n'existait pas (#847, #837).
362 //
363 // Ces tests-ci attestent l'obligation elle-même, et leur nom le dit.
364
365 #[test]
366 fn happy_lecheance_legale_tombe_trois_ans_apres_la_prise_deffet() {
367 let m = mandat(0);
368 assert_eq!(
369 m.echeance_legale(),
370 m.started_at + Duration::days(DUREE_MAXIMALE_JOURS)
371 );
372 }
373
374 #[test]
375 fn security_un_mandat_de_plus_de_trois_ans_est_expire_de_plein_droit() {
376 // Le silence de l'assemblée ne reconduit pas : l'Art. 3.89 § 1er veut
377 // une décision EXPRESSE de renouvellement.
378 let m = mandat(DUREE_MAXIMALE_JOURS + 1);
379 assert!(
380 m.est_expire_de_plein_droit(Utc::now()),
381 "un mandat entamé il y a plus de trois ans doit être expiré, \
382 même si personne ne l'a clos"
383 );
384 }
385
386 #[test]
387 fn edge_un_mandat_de_trois_ans_moins_un_jour_court_encore() {
388 let m = mandat(DUREE_MAXIMALE_JOURS - 1);
389 assert!(!m.est_expire_de_plein_droit(Utc::now()));
390 }
391
392 #[test]
393 fn edge_le_jour_de_lecheance_le_mandat_est_expire() {
394 // Borne INCLUSE : « ne peut excéder trois ans » — le trois-millième
395 // quatre-vingt-quinzième jour est déjà de trop.
396 let m = mandat(DUREE_MAXIMALE_JOURS);
397 assert!(m.est_expire_de_plein_droit(Utc::now()));
398 }
399
400 #[test]
401 fn negative_une_cloture_datee_au_dela_des_trois_ans_est_refusee() {
402 // Dater la clôture au-delà de l'échéance reviendrait à écrire dans le
403 // registre que le mandat a couvert une période qu'il ne pouvait pas
404 // couvrir.
405 let mut m = mandat(10);
406 let trop_tard = m.echeance_legale() + Duration::days(1);
407 assert_eq!(
408 m.revoke(trop_tard, None, None),
409 Err(SyndicMandateError::DureeExcedeTroisAns)
410 );
411 assert!(m.is_active(), "le mandat refusé ne doit pas être clos");
412 }
413
414 #[test]
415 fn happy_une_cloture_dans_les_trois_ans_est_acceptee() {
416 let mut m = mandat(10);
417 let dans_les_temps = m.echeance_legale() - Duration::days(1);
418 assert!(m.revoke(dans_les_temps, None, None).is_ok());
419 assert!(!m.is_active());
420 }
421}