koprogo_api/domain/entities/
board_member.rs

1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Position du membre du conseil de copropriété
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum BoardPosition {
8    President, // Président du conseil
9    Treasurer, // Trésorier du conseil
10    Member,    // Membre simple
11}
12
13impl std::fmt::Display for BoardPosition {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        match self {
16            BoardPosition::President => write!(f, "president"),
17            BoardPosition::Treasurer => write!(f, "treasurer"),
18            BoardPosition::Member => write!(f, "member"),
19        }
20    }
21}
22
23impl std::str::FromStr for BoardPosition {
24    type Err = String;
25
26    fn from_str(s: &str) -> Result<Self, Self::Err> {
27        match s.to_lowercase().as_str() {
28            "president" => Ok(BoardPosition::President),
29            "treasurer" => Ok(BoardPosition::Treasurer),
30            "member" => Ok(BoardPosition::Member),
31            _ => Err(format!("Invalid board position: {}", s)),
32        }
33    }
34}
35
36/// Membre du conseil de copropriété (Article 577-8/4 Code Civil belge)
37/// Obligation légale pour immeubles >20 lots
38/// Les membres doivent être des copropriétaires (Owner), pas nécessairement des utilisateurs de la plateforme
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct BoardMember {
41    pub id: Uuid,
42    pub owner_id: Uuid, // Référence à la table owners (copropriétaires)
43    pub building_id: Uuid,
44    pub position: BoardPosition,
45    pub mandate_start: DateTime<Utc>,
46    pub mandate_end: DateTime<Utc>,
47    pub elected_by_meeting_id: Uuid, // ID de l'AG qui a élu ce membre
48    pub created_at: DateTime<Utc>,
49    pub updated_at: DateTime<Utc>,
50}
51
52impl BoardMember {
53    /// Crée un nouveau membre du conseil avec validation
54    pub fn new(
55        owner_id: Uuid,
56        building_id: Uuid,
57        position: BoardPosition,
58        mandate_start: DateTime<Utc>,
59        mandate_end: DateTime<Utc>,
60        elected_by_meeting_id: Uuid,
61    ) -> Result<Self, String> {
62        // Validation: mandate_start doit être avant mandate_end
63        if mandate_start >= mandate_end {
64            return Err("Mandate start date must be before end date".to_string());
65        }
66
67        // Validation: durée du mandat doit être environ 1 an (entre 11 et 13 mois)
68        let duration_days = (mandate_end - mandate_start).num_days();
69        if !(330..=395).contains(&duration_days) {
70            return Err("Mandate duration must be approximately 1 year (11-13 months)".to_string());
71        }
72
73        let now = Utc::now();
74        Ok(Self {
75            id: Uuid::new_v4(),
76            owner_id,
77            building_id,
78            position,
79            mandate_start,
80            mandate_end,
81            elected_by_meeting_id,
82            created_at: now,
83            updated_at: now,
84        })
85    }
86
87    /// Vérifie si le mandat est actuellement actif
88    pub fn is_active(&self) -> bool {
89        let now = Utc::now();
90        now >= self.mandate_start && now <= self.mandate_end
91    }
92
93    /// Calcule le nombre de jours restants dans le mandat
94    /// Retourne 0 si le mandat est expiré
95    pub fn days_remaining(&self) -> i64 {
96        let now = Utc::now();
97        if now > self.mandate_end {
98            return 0;
99        }
100        (self.mandate_end - now).num_days()
101    }
102
103    /// Vérifie si le mandat expire bientôt (< 60 jours)
104    pub fn expires_soon(&self) -> bool {
105        self.days_remaining() > 0 && self.days_remaining() < 60
106    }
107
108    /// Renouvelle le mandat pour une année supplémentaire
109    pub fn extend_mandate(&mut self, new_elected_by_meeting_id: Uuid) -> Result<(), String> {
110        if !self.expires_soon() && self.is_active() {
111            return Err("Cannot extend mandate more than 60 days before expiration".to_string());
112        }
113
114        // Nouveau mandat commence à la fin de l'ancien
115        self.mandate_start = self.mandate_end;
116        self.mandate_end = self.mandate_start + Duration::days(365);
117        self.elected_by_meeting_id = new_elected_by_meeting_id;
118        self.updated_at = Utc::now();
119
120        Ok(())
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_create_board_member_success() {
130        // Arrange
131        let owner_id = Uuid::new_v4();
132        let building_id = Uuid::new_v4();
133        let meeting_id = Uuid::new_v4();
134        let start = Utc::now();
135        let end = start + Duration::days(365);
136
137        // Act
138        let result = BoardMember::new(
139            owner_id,
140            building_id,
141            BoardPosition::President,
142            start,
143            end,
144            meeting_id,
145        );
146
147        // Assert
148        assert!(result.is_ok());
149        let member = result.unwrap();
150        assert_eq!(member.owner_id, owner_id);
151        assert_eq!(member.building_id, building_id);
152        assert_eq!(member.position, BoardPosition::President);
153        assert_eq!(member.mandate_start, start);
154        assert_eq!(member.mandate_end, end);
155        assert_eq!(member.elected_by_meeting_id, meeting_id);
156    }
157
158    #[test]
159    fn test_mandate_duration_one_year() {
160        // Arrange
161        let start = Utc::now();
162        let end = start + Duration::days(365);
163
164        // Act
165        let result = BoardMember::new(
166            Uuid::new_v4(),
167            Uuid::new_v4(),
168            BoardPosition::Member,
169            start,
170            end,
171            Uuid::new_v4(),
172        );
173
174        // Assert
175        assert!(result.is_ok());
176    }
177
178    #[test]
179    fn test_mandate_duration_too_short_fails() {
180        // Arrange
181        let start = Utc::now();
182        let end = start + Duration::days(300); // Trop court (< 330 jours)
183
184        // Act
185        let result = BoardMember::new(
186            Uuid::new_v4(),
187            Uuid::new_v4(),
188            BoardPosition::Member,
189            start,
190            end,
191            Uuid::new_v4(),
192        );
193
194        // Assert
195        assert!(result.is_err());
196        assert_eq!(
197            result.unwrap_err(),
198            "Mandate duration must be approximately 1 year (11-13 months)"
199        );
200    }
201
202    #[test]
203    fn test_mandate_duration_too_long_fails() {
204        // Arrange
205        let start = Utc::now();
206        let end = start + Duration::days(400); // Trop long (> 395 jours)
207
208        // Act
209        let result = BoardMember::new(
210            Uuid::new_v4(),
211            Uuid::new_v4(),
212            BoardPosition::Member,
213            start,
214            end,
215            Uuid::new_v4(),
216        );
217
218        // Assert
219        assert!(result.is_err());
220        assert_eq!(
221            result.unwrap_err(),
222            "Mandate duration must be approximately 1 year (11-13 months)"
223        );
224    }
225
226    #[test]
227    fn test_mandate_start_after_end_fails() {
228        // Arrange
229        let start = Utc::now();
230        let end = start - Duration::days(10); // End avant start
231
232        // Act
233        let result = BoardMember::new(
234            Uuid::new_v4(),
235            Uuid::new_v4(),
236            BoardPosition::President,
237            start,
238            end,
239            Uuid::new_v4(),
240        );
241
242        // Assert
243        assert!(result.is_err());
244        assert_eq!(
245            result.unwrap_err(),
246            "Mandate start date must be before end date"
247        );
248    }
249
250    #[test]
251    fn test_is_active_mandate() {
252        // Arrange
253        let start = Utc::now() - Duration::days(10); // Commencé il y a 10 jours
254        let end = Utc::now() + Duration::days(355); // Expire dans 355 jours
255        let member = BoardMember::new(
256            Uuid::new_v4(),
257            Uuid::new_v4(),
258            BoardPosition::Member,
259            start,
260            end,
261            Uuid::new_v4(),
262        )
263        .unwrap();
264
265        // Act & Assert
266        assert!(member.is_active());
267    }
268
269    #[test]
270    fn test_is_not_active_future_mandate() {
271        // Arrange
272        let start = Utc::now() + Duration::days(10); // Commence dans 10 jours
273        let end = start + Duration::days(365);
274        let member = BoardMember::new(
275            Uuid::new_v4(),
276            Uuid::new_v4(),
277            BoardPosition::Member,
278            start,
279            end,
280            Uuid::new_v4(),
281        )
282        .unwrap();
283
284        // Act & Assert
285        assert!(!member.is_active());
286    }
287
288    #[test]
289    fn test_days_remaining_calculation() {
290        // Arrange
291        let start = Utc::now() - Duration::days(300); // Commencé il y a 300 jours
292        let end = start + Duration::days(365); // Expire dans 65 jours
293        let member = BoardMember::new(
294            Uuid::new_v4(),
295            Uuid::new_v4(),
296            BoardPosition::Treasurer,
297            start,
298            end,
299            Uuid::new_v4(),
300        )
301        .unwrap();
302
303        // Act
304        let remaining = member.days_remaining();
305
306        // Assert
307        assert!((64..=66).contains(&remaining)); // ±1 jour de tolérance
308    }
309
310    #[test]
311    fn test_days_remaining_expired_returns_zero() {
312        // Arrange
313        let start = Utc::now() - Duration::days(400); // Commencé il y a 400 jours
314        let end = start + Duration::days(365); // Expiré il y a 35 jours
315        let member = BoardMember::new(
316            Uuid::new_v4(),
317            Uuid::new_v4(),
318            BoardPosition::Member,
319            start,
320            end,
321            Uuid::new_v4(),
322        )
323        .unwrap();
324
325        // Act
326        let remaining = member.days_remaining();
327
328        // Assert
329        assert_eq!(remaining, 0);
330    }
331
332    #[test]
333    fn test_expires_soon_true() {
334        // Arrange
335        let start = Utc::now() - Duration::days(320); // Commencé il y a 320 jours
336        let end = start + Duration::days(365); // Expire dans 45 jours
337        let member = BoardMember::new(
338            Uuid::new_v4(),
339            Uuid::new_v4(),
340            BoardPosition::President,
341            start,
342            end,
343            Uuid::new_v4(),
344        )
345        .unwrap();
346
347        // Act & Assert
348        assert!(member.expires_soon());
349    }
350
351    #[test]
352    fn test_expires_soon_false_far_expiration() {
353        // Arrange
354        let start = Utc::now() - Duration::days(100); // Commencé il y a 100 jours
355        let end = start + Duration::days(365); // Expire dans 265 jours
356        let member = BoardMember::new(
357            Uuid::new_v4(),
358            Uuid::new_v4(),
359            BoardPosition::Member,
360            start,
361            end,
362            Uuid::new_v4(),
363        )
364        .unwrap();
365
366        // Act & Assert
367        assert!(!member.expires_soon());
368    }
369
370    #[test]
371    fn test_extend_mandate_success() {
372        // Arrange
373        let start = Utc::now() - Duration::days(320); // Expire dans 45 jours
374        let end = start + Duration::days(365);
375        let new_meeting_id = Uuid::new_v4();
376        let mut member = BoardMember::new(
377            Uuid::new_v4(),
378            Uuid::new_v4(),
379            BoardPosition::President,
380            start,
381            end,
382            Uuid::new_v4(),
383        )
384        .unwrap();
385
386        let original_end = member.mandate_end;
387
388        // Act
389        let result = member.extend_mandate(new_meeting_id);
390
391        // Assert
392        assert!(result.is_ok());
393        assert_eq!(member.mandate_start, original_end);
394        assert_eq!(member.mandate_end, original_end + Duration::days(365));
395        assert_eq!(member.elected_by_meeting_id, new_meeting_id);
396    }
397
398    #[test]
399    fn test_extend_mandate_fails_too_early() {
400        // Arrange
401        let start = Utc::now() - Duration::days(100); // Expire dans 265 jours
402        let end = start + Duration::days(365);
403        let mut member = BoardMember::new(
404            Uuid::new_v4(),
405            Uuid::new_v4(),
406            BoardPosition::Member,
407            start,
408            end,
409            Uuid::new_v4(),
410        )
411        .unwrap();
412
413        // Act
414        let result = member.extend_mandate(Uuid::new_v4());
415
416        // Assert
417        assert!(result.is_err());
418        assert_eq!(
419            result.unwrap_err(),
420            "Cannot extend mandate more than 60 days before expiration"
421        );
422    }
423
424    #[test]
425    fn test_board_position_display() {
426        assert_eq!(BoardPosition::President.to_string(), "president");
427        assert_eq!(BoardPosition::Treasurer.to_string(), "treasurer");
428        assert_eq!(BoardPosition::Member.to_string(), "member");
429    }
430
431    #[test]
432    fn test_board_position_from_str() {
433        assert_eq!(
434            "president".parse::<BoardPosition>().unwrap(),
435            BoardPosition::President
436        );
437        assert_eq!(
438            "President".parse::<BoardPosition>().unwrap(),
439            BoardPosition::President
440        );
441        assert_eq!(
442            "TREASURER".parse::<BoardPosition>().unwrap(),
443            BoardPosition::Treasurer
444        );
445        assert_eq!(
446            "member".parse::<BoardPosition>().unwrap(),
447            BoardPosition::Member
448        );
449
450        assert!("invalid".parse::<BoardPosition>().is_err());
451    }
452}