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}