Skip to main content

koprogo_api/domain/copropriete/
fenetre_ag_ordinaire.rs

1//! La fenêtre statutaire de l'assemblée générale ordinaire.
2//!
3//! Art. 3.85 § 3, 3° — le règlement d'ordre intérieur contient au moins :
4//!
5//! > « la **période annuelle de quinze jours** pendant laquelle se tient
6//! > l'assemblée générale ordinaire de l'association des copropriétaires. »
7//!
8//! Cette fenêtre n'est pas décorative : elle sert de point d'ancrage à
9//! l'Art. 3.87 § 3, qui oblige le syndic à inscrire à l'ordre du jour les
10//! propositions écrites reçues
11//!
12//! > « au moins **trois semaines avant le premier jour de la période**, fixée
13//! > dans le règlement d'ordre intérieur, au cours de laquelle l'assemblée
14//! > générale ordinaire doit avoir lieu. »
15//!
16//! Sans fenêtre, ce délai de trois semaines n'a aucun point de départ : un
17//! copropriétaire ne peut pas savoir quand déposer sa proposition, et un
18//! syndic ne peut pas justifier de l'avoir écartée. Les deux règles tiennent
19//! ou tombent ensemble.
20//!
21//! La période est **récurrente** : le ROI fixe un mois et un jour, pas une
22//! date. Elle se projette sur chaque exercice.
23//!
24//! Voir issue #747.
25
26use chrono::{Datelike, Duration, NaiveDate};
27use serde::{Deserialize, Serialize};
28
29/// Ce qui empêche une fenêtre d'être valide.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum FenetreInvalide {
32    /// Mois hors de 1..=12.
33    MoisHorsBornes(u32),
34    /// Jour hors de 1..=31.
35    JourHorsBornes(u32),
36    /// Le couple mois/jour ne désigne aucune date réelle — un 31 novembre,
37    /// par exemple. Le ROI ne peut pas fixer une période qui n'existe pas.
38    DateInexistante { mois: u32, jour: u32 },
39}
40
41impl std::fmt::Display for FenetreInvalide {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::MoisHorsBornes(m) => write!(f, "Mois invalide : {m}"),
45            Self::JourHorsBornes(j) => write!(f, "Jour invalide : {j}"),
46            Self::DateInexistante { mois, jour } => write!(
47                f,
48                "Le {jour}/{mois} n'existe pas : le règlement d'ordre intérieur ne peut pas \
49                 fixer une période qui ne tombe jamais"
50            ),
51        }
52    }
53}
54
55/// La période annuelle de quinze jours fixée par le règlement d'ordre
56/// intérieur (Art. 3.85 § 3, 3°).
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
58pub struct FenetreAgOrdinaire {
59    mois: u32,
60    jour: u32,
61}
62
63impl FenetreAgOrdinaire {
64    /// Quinze jours, bornes comprises.
65    pub const DUREE_JOURS: i64 = 15;
66
67    /// Trois semaines avant le premier jour, pour les propositions
68    /// (Art. 3.87 § 3).
69    pub const PREAVIS_PROPOSITIONS_JOURS: i64 = 21;
70
71    /// Le premier jour de la période, tel que le ROI le fixe.
72    ///
73    /// Une année bissextile suffit à valider un 29 février : la règle ne
74    /// s'applique alors qu'un an sur quatre, ce qui est un choix de ROI
75    /// discutable mais licite. On refuse en revanche le 30 février.
76    pub fn new(mois: u32, jour: u32) -> Result<Self, FenetreInvalide> {
77        if !(1..=12).contains(&mois) {
78            return Err(FenetreInvalide::MoisHorsBornes(mois));
79        }
80        if !(1..=31).contains(&jour) {
81            return Err(FenetreInvalide::JourHorsBornes(jour));
82        }
83        // 2024 est bissextile : elle accepte le 29 février et refuse le 30.
84        if NaiveDate::from_ymd_opt(2024, mois, jour).is_none() {
85            return Err(FenetreInvalide::DateInexistante { mois, jour });
86        }
87        Ok(Self { mois, jour })
88    }
89
90    /// Le premier jour de la période pour un exercice donné.
91    ///
92    /// `None` si la date ne tombe pas cette année-là — le seul cas est un
93    /// 29 février sur une année commune.
94    pub fn debut(&self, annee: i32) -> Option<NaiveDate> {
95        NaiveDate::from_ymd_opt(annee, self.mois, self.jour)
96    }
97
98    /// Le dernier jour de la période, inclus.
99    pub fn fin(&self, annee: i32) -> Option<NaiveDate> {
100        self.debut(annee)
101            .map(|d| d + Duration::days(Self::DUREE_JOURS - 1))
102    }
103
104    /// La date tombe-t-elle dans la fenêtre de son propre exercice ?
105    ///
106    /// La comparaison se fait sur l'année de la date, pas sur l'année civile
107    /// courante : une AG du 3 janvier relève de la fenêtre de janvier de la
108    /// même année.
109    pub fn contient(&self, date: NaiveDate) -> bool {
110        let Some(debut) = self.debut(date.year()) else {
111            return false;
112        };
113        let Some(fin) = self.fin(date.year()) else {
114            return false;
115        };
116        date >= debut && date <= fin
117    }
118
119    /// La date limite de réception des propositions à inscrire à l'ordre du
120    /// jour (Art. 3.87 § 3).
121    ///
122    /// « Au moins trois semaines avant » : une proposition reçue **ce jour-là**
123    /// est encore recevable, puisque le délai est alors exactement de trois
124    /// semaines.
125    pub fn derniere_date_pour_propositions(&self, annee: i32) -> Option<NaiveDate> {
126        self.debut(annee)
127            .map(|d| d - Duration::days(Self::PREAVIS_PROPOSITIONS_JOURS))
128    }
129
130    /// Une proposition reçue à cette date doit-elle être inscrite ?
131    pub fn proposition_recevable(&self, recue_le: NaiveDate, annee: i32) -> bool {
132        self.derniere_date_pour_propositions(annee)
133            .is_some_and(|limite| recue_le <= limite)
134    }
135
136    pub fn mois(&self) -> u32 {
137        self.mois
138    }
139
140    pub fn jour(&self) -> u32 {
141        self.jour
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn le(annee: i32, mois: u32, jour: u32) -> NaiveDate {
150        NaiveDate::from_ymd_opt(annee, mois, jour).expect("date de test valide")
151    }
152
153    /// Une fenêtre du 1er au 15 juin, cas le plus courant.
154    fn juin() -> FenetreAgOrdinaire {
155        FenetreAgOrdinaire::new(6, 1).expect("fenêtre valide")
156    }
157
158    #[test]
159    fn happy_la_periode_dure_quinze_jours_bornes_comprises() {
160        assert_eq!(juin().debut(2026), Some(le(2026, 6, 1)));
161        assert_eq!(juin().fin(2026), Some(le(2026, 6, 15)));
162    }
163
164    #[test]
165    fn happy_une_ag_dans_la_fenetre_est_conforme() {
166        assert!(juin().contient(le(2026, 6, 8)));
167    }
168
169    #[test]
170    fn edge_les_deux_bornes_sont_incluses() {
171        assert!(juin().contient(le(2026, 6, 1)), "le premier jour compte");
172        assert!(juin().contient(le(2026, 6, 15)), "le quinzième aussi");
173    }
174
175    #[test]
176    fn negative_une_ag_hors_fenetre_est_signalee() {
177        assert!(
178            !juin().contient(le(2026, 6, 16)),
179            "le seizième jour est dehors"
180        );
181        assert!(!juin().contient(le(2026, 5, 31)), "la veille aussi");
182    }
183
184    #[test]
185    fn happy_la_fenetre_est_annuelle_et_se_reprojette() {
186        // Le ROI fixe un mois et un jour, pas une date : la période revient
187        // chaque exercice.
188        assert!(juin().contient(le(2026, 6, 8)));
189        assert!(juin().contient(le(2031, 6, 8)));
190    }
191
192    #[test]
193    fn edge_une_fenetre_a_cheval_sur_deux_mois_reste_continue() {
194        let fin_decembre = FenetreAgOrdinaire::new(12, 25).expect("fenêtre valide");
195        assert_eq!(fin_decembre.fin(2026), Some(le(2027, 1, 8)));
196        // La date du 3 janvier appartient à la fenêtre ouverte en décembre
197        // 2026, pas à une fenêtre de 2027 : `contient` raisonne sur l'année de
198        // la date, donc il ne la voit pas. Limite connue et assumée — une
199        // fenêtre à cheval sur le nouvel an est un cas de ROI rare.
200        assert!(!fin_decembre.contient(le(2027, 1, 3)));
201    }
202
203    // ── Art. 3.87 § 3 : le préavis des propositions ────────────────────
204
205    #[test]
206    fn happy_les_propositions_se_deposent_trois_semaines_avant() {
207        assert_eq!(
208            juin().derniere_date_pour_propositions(2026),
209            Some(le(2026, 5, 11)),
210            "1er juin moins vingt-et-un jours"
211        );
212    }
213
214    #[test]
215    fn edge_une_proposition_recue_le_jour_limite_est_recevable() {
216        // « Au moins trois semaines avant » : le délai est exactement de trois
217        // semaines ce jour-là, donc il est respecté.
218        assert!(juin().proposition_recevable(le(2026, 5, 11), 2026));
219    }
220
221    #[test]
222    fn negative_une_proposition_recue_le_lendemain_ne_lest_plus() {
223        assert!(!juin().proposition_recevable(le(2026, 5, 12), 2026));
224    }
225
226    // ── Validation ─────────────────────────────────────────────────────
227
228    #[test]
229    fn negative_un_mois_hors_bornes_est_refuse() {
230        assert_eq!(
231            FenetreAgOrdinaire::new(13, 1),
232            Err(FenetreInvalide::MoisHorsBornes(13))
233        );
234    }
235
236    #[test]
237    fn negative_une_date_qui_nexiste_pas_est_refusee() {
238        // Le ROI ne peut pas fixer une période qui ne tombe jamais.
239        assert_eq!(
240            FenetreAgOrdinaire::new(11, 31),
241            Err(FenetreInvalide::DateInexistante { mois: 11, jour: 31 })
242        );
243    }
244
245    #[test]
246    fn edge_le_29_fevrier_est_accepte_mais_ne_tombe_pas_toutes_les_annees() {
247        let bissextile = FenetreAgOrdinaire::new(2, 29).expect("le 29 février existe");
248        assert!(bissextile.debut(2028).is_some(), "2028 est bissextile");
249        assert!(
250            bissextile.debut(2026).is_none(),
251            "2026 ne l'est pas : la fenêtre ne tombe pas cette année-là"
252        );
253        assert!(
254            !bissextile.contient(le(2026, 3, 1)),
255            "et rien n'y appartient"
256        );
257    }
258}