Skip to main content

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}