Skip to main content

koprogo_api/domain/copropriete/
delai_de_convocation.rs

1//! Peut-on encore convoquer régulièrement pour cette date ?
2//!
3//! ── Le défaut que ce module ferme ──────────────────────────────────────────
4//!
5//! Recette du 2026-09-06 (RN-9). Un syndic crée une assemblée pour le
6//! 20 septembre. Il clique « Créer une convocation » et reçoit :
7//!
8//!     Meeting date too soon. Ordinary meeting requires 15 days notice.
9//!     Minimum send date would be 2026-09-05 10:00
10//!
11//! **La règle est juste** — Art. 3.87 § 3 : « Sauf dans les cas d'urgence, la
12//! convocation est communiquée quinze jours au moins avant la date de
13//! l'assemblée. » Le défaut est sa **temporalité** : le contrôle arrive au
14//! moment où il ne reste plus qu'à subir.
15//!
16//! L'application a laissé créer cette assemblée sans rien dire, puis refuse de
17//! la convoquer. Le syndic n'en sort qu'en la supprimant.
18//!
19//! ── Pourquoi avertir plutôt que refuser ────────────────────────────────────
20//!
21//! Interdire la création serait faux, et pour trois raisons vérifiables :
22//!
23//!   1. **L'urgence est prévue par le texte lui-même** — « sauf dans les cas
24//!      d'urgence ». Une assemblée convoquée dans l'urgence est régulière.
25//!   2. **Une assemblée peut être encodée après coup**, pour tenir le registre
26//!      d'une réunion déjà tenue.
27//!   3. **Une seconde convocation** (Art. 3.87 § 5) suit une première dont le
28//!      quorum a manqué : sa date est contrainte par l'échec précédent.
29//!
30//! Le rôle de ce module n'est donc pas de dire non, mais de dire **quand**,
31//! assez tôt pour qu'on puisse encore changer la date.
32//!
33//! Voir #780, verrou 1.
34
35use super::convocation::ConvocationType;
36use chrono::{DateTime, Duration, Utc};
37
38/// Ce que l'on peut dire d'une date d'assemblée au moment où on la saisit.
39#[derive(Debug, Clone, PartialEq)]
40pub enum DelaiDeConvocation {
41    /// La convocation peut encore partir dans les temps.
42    ///
43    /// Porte la date limite d'envoi, pour qu'un écran puisse l'afficher plutôt
44    /// que de laisser l'utilisateur la calculer.
45    Tenable { date_limite_envoi: DateTime<Utc> },
46
47    /// Le délai de quinze jours ne peut plus être tenu.
48    ///
49    /// Ce n'est pas un refus : l'assemblée reste créable. C'est un avertissement
50    /// à donner **à la saisie**, avec ce qu'il faudrait pour le tenir.
51    TropCourt {
52        date_limite_envoi: DateTime<Utc>,
53        jours_manquants: i64,
54    },
55
56    /// La date est déjà passée : on encode une assemblée tenue.
57    ///
58    /// Aucun avertissement de délai n'a de sens ici, et en produire un
59    /// apprendrait à ignorer les avertissements.
60    DejaTenue,
61}
62
63/// Le délai est-il tenable pour cette assemblée, à cet instant ?
64///
65/// `maintenant` est passé en paramètre plutôt que lu de l'horloge : une règle
66/// qui lit l'heure ne se teste qu'en attendant, et une règle qu'on ne peut pas
67/// tester au bord ne se teste pas du tout.
68pub fn evaluer(
69    date_assemblee: DateTime<Utc>,
70    type_de_convocation: &ConvocationType,
71    maintenant: DateTime<Utc>,
72) -> DelaiDeConvocation {
73    if date_assemblee <= maintenant {
74        return DelaiDeConvocation::DejaTenue;
75    }
76
77    let jours = type_de_convocation.minimum_notice_days();
78    let date_limite_envoi = date_assemblee - Duration::days(jours);
79
80    if maintenant <= date_limite_envoi {
81        DelaiDeConvocation::Tenable { date_limite_envoi }
82    } else {
83        // Le nombre de jours dont il faudrait reculer la date d'assemblée.
84        // On arrondit vers le haut : à douze heures près, il manque un jour.
85        let manque = maintenant - date_limite_envoi;
86        let jours_manquants = (manque.num_seconds() as f64 / 86_400.0).ceil() as i64;
87        DelaiDeConvocation::TropCourt {
88            date_limite_envoi,
89            jours_manquants: jours_manquants.max(1),
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn t(jours: i64) -> DateTime<Utc> {
99        DateTime::from_timestamp(1_757_000_000, 0).unwrap() + Duration::days(jours)
100    }
101
102    #[test]
103    fn happy_une_assemblee_dans_un_mois_est_tenable() {
104        let r = evaluer(t(30), &ConvocationType::Ordinary, t(0));
105        match r {
106            DelaiDeConvocation::Tenable { date_limite_envoi } => {
107                assert_eq!(date_limite_envoi, t(15));
108            }
109            autre => panic!("attendu Tenable, obtenu {autre:?}"),
110        }
111    }
112
113    /// La borne exacte : envoyer le quinzième jour AVANT est régulier.
114    ///
115    /// « Quinze jours au moins » se compte en jours pleins ; le jour de l'envoi
116    /// compte. Se tromper d'un jour ici rendrait irrégulière une convocation
117    /// qui ne l'est pas, ou l'inverse.
118    #[test]
119    fn edge_la_borne_des_quinze_jours_est_tenable() {
120        let r = evaluer(t(15), &ConvocationType::Ordinary, t(0));
121        assert!(matches!(r, DelaiDeConvocation::Tenable { .. }));
122    }
123
124    #[test]
125    fn negative_un_jour_de_moins_ne_tient_plus() {
126        let r = evaluer(t(15), &ConvocationType::Ordinary, t(1));
127        match r {
128            DelaiDeConvocation::TropCourt {
129                jours_manquants, ..
130            } => assert_eq!(jours_manquants, 1),
131            autre => panic!("attendu TropCourt, obtenu {autre:?}"),
132        }
133    }
134
135    /// Le cas exact de la recette : une assemblée à cinq jours.
136    #[test]
137    fn negative_le_cas_de_la_recette_manque_de_dix_jours() {
138        let r = evaluer(t(5), &ConvocationType::Ordinary, t(0));
139        match r {
140            DelaiDeConvocation::TropCourt {
141                jours_manquants,
142                date_limite_envoi,
143            } => {
144                assert_eq!(jours_manquants, 10);
145                assert_eq!(date_limite_envoi, t(-10));
146            }
147            autre => panic!("attendu TropCourt, obtenu {autre:?}"),
148        }
149    }
150
151    /// Une assemblée déjà tenue ne reçoit aucun avertissement.
152    ///
153    /// Avertir ici apprendrait à ignorer les avertissements — c'est ainsi
154    /// qu'un garde-fou finit désactivé.
155    #[test]
156    fn edge_une_assemblee_passee_ne_declenche_aucun_avertissement() {
157        assert_eq!(
158            evaluer(t(-1), &ConvocationType::Ordinary, t(0)),
159            DelaiDeConvocation::DejaTenue
160        );
161    }
162
163    /// L'instant exact de l'assemblée compte comme tenue, pas comme à venir.
164    #[test]
165    fn edge_linstant_meme_de_lassemblee_compte_comme_tenue() {
166        assert_eq!(
167            evaluer(t(0), &ConvocationType::Ordinary, t(0)),
168            DelaiDeConvocation::DejaTenue
169        );
170    }
171
172    /// Les trois types partagent le même délai (Art. 3.87 § 3 et § 5).
173    ///
174    /// Ce test existe parce que la loi de 2019 a UNIFIÉ ces délais : la
175    /// seconde convocation obéissait autrefois à une règle distincte, et un
176    /// lecteur pressé pourrait « rétablir » l'ancienne.
177    #[test]
178    fn happy_les_trois_types_partagent_le_delai_de_quinze_jours() {
179        for type_de in [
180            ConvocationType::Ordinary,
181            ConvocationType::Extraordinary,
182            ConvocationType::SecondConvocation,
183        ] {
184            assert!(
185                matches!(
186                    evaluer(t(15), &type_de, t(0)),
187                    DelaiDeConvocation::Tenable { .. }
188                ),
189                "{type_de:?} devrait tenir à quinze jours"
190            );
191            assert!(
192                matches!(
193                    evaluer(t(14), &type_de, t(0)),
194                    DelaiDeConvocation::TropCourt { .. }
195                ),
196                "{type_de:?} ne devrait pas tenir à quatorze jours"
197            );
198        }
199    }
200}