Skip to main content

koprogo_api/domain/copropriete/
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    ///
55    /// Validation per Art. 3.90 CC (conseil de copropriété) : durée du mandat
56    /// ≈ 1 an. Fenêtre 330–395 jours, alignée sur la contrainte DB
57    /// `mandate_duration_one_year`. NB : le plafond 3 ans est la règle *syndic*
58    /// (Art. 3.89 CC) — un mandat distinct, non applicable au conseil
59    /// (cf. `tests/features/legal_compliance.feature` §Art. 3.89 / Art. 3.90).
60    pub fn new(
61        owner_id: Uuid,
62        building_id: Uuid,
63        position: BoardPosition,
64        mandate_start: DateTime<Utc>,
65        mandate_end: DateTime<Utc>,
66        elected_by_meeting_id: Uuid,
67    ) -> Result<Self, String> {
68        // Validation: mandate_start doit être avant mandate_end
69        if mandate_start >= mandate_end {
70            return Err("Mandate start date must be before end date".to_string());
71        }
72
73        // Durée du mandat ≈ 1 an (Art. 3.90 CC, conseil de copropriété).
74        // Fenêtre 330–395 j alignée sur la contrainte DB `mandate_duration_one_year`.
75        let duration_days = (mandate_end - mandate_start).num_days();
76        if !(330..=395).contains(&duration_days) {
77            return Err("Mandate duration must be approximately 1 year (Art. 3.90 CC)".to_string());
78        }
79
80        let now = Utc::now();
81        Ok(Self {
82            id: Uuid::new_v4(),
83            owner_id,
84            building_id,
85            position,
86            mandate_start,
87            mandate_end,
88            elected_by_meeting_id,
89            created_at: now,
90            updated_at: now,
91        })
92    }
93
94    /// Vérifie si le mandat est actif à un instant donné.
95    ///
96    /// Borne de fin **exclusive** : cohérent avec `MembreDuConseil::en_fonction_le`
97    /// (`conseil_de_copropriete.rs`), qui documente pourquoi — « le jour de
98    /// l'AG, le mandat est échu ». Permet aussi de tester précisément la
99    /// bascule d'une démission (`resign`) sans dépendre de `Utc::now()`.
100    pub fn is_active_at(&self, moment: DateTime<Utc>) -> bool {
101        moment >= self.mandate_start && moment < self.mandate_end
102    }
103
104    /// Vérifie si le mandat est actuellement actif
105    pub fn is_active(&self) -> bool {
106        self.is_active_at(Utc::now())
107    }
108
109    /// Démissionne : le mandat s'arrête à `at`, immédiatement (Story 4.7,
110    /// @edge). N'avance jamais `mandate_end` — seulement l'avancer a du sens
111    /// pour une démission.
112    pub fn resign(&mut self, at: DateTime<Utc>) {
113        self.mandate_end = self.mandate_end.min(at);
114        self.updated_at = Utc::now();
115    }
116
117    /// Calcule le nombre de jours restants dans le mandat
118    /// Retourne 0 si le mandat est expiré
119    pub fn days_remaining(&self) -> i64 {
120        let now = Utc::now();
121        if now > self.mandate_end {
122            return 0;
123        }
124        (self.mandate_end - now).num_days()
125    }
126
127    /// Vérifie si le mandat expire bientôt (< 60 jours)
128    pub fn expires_soon(&self) -> bool {
129        self.days_remaining() > 0 && self.days_remaining() < 60
130    }
131
132    /// Renouvelle le mandat (Art. 3.90 CC, conseil de copropriété : ≈ 1 an).
133    /// mandate_duration_days: durée du nouveau mandat en jours (≈ 1 an, 330–395).
134    pub fn extend_mandate(
135        &mut self,
136        mandate_duration_days: i64,
137        new_elected_by_meeting_id: Uuid,
138    ) -> Result<(), String> {
139        if !self.expires_soon() && self.is_active() {
140            return Err("Cannot extend mandate more than 60 days before expiration".to_string());
141        }
142
143        // Durée ≈ 1 an (Art. 3.90 CC) — fenêtre alignée sur la contrainte DB.
144        if !(330..=395).contains(&mandate_duration_days) {
145            return Err("Mandate duration must be approximately 1 year (Art. 3.90 CC)".to_string());
146        }
147
148        // Nouveau mandat commence à la fin de l'ancien
149        self.mandate_start = self.mandate_end;
150        self.mandate_end = self.mandate_start + Duration::days(mandate_duration_days);
151        self.elected_by_meeting_id = new_elected_by_meeting_id;
152        self.updated_at = Utc::now();
153
154        Ok(())
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_create_board_member_success() {
164        // Arrange
165        let owner_id = Uuid::new_v4();
166        let building_id = Uuid::new_v4();
167        let meeting_id = Uuid::new_v4();
168        let start = Utc::now();
169        let end = start + Duration::days(365);
170
171        // Act
172        let result = BoardMember::new(
173            owner_id,
174            building_id,
175            BoardPosition::President,
176            start,
177            end,
178            meeting_id,
179        );
180
181        // Assert
182        assert!(result.is_ok());
183        let member = result.unwrap();
184        assert_eq!(member.owner_id, owner_id);
185        assert_eq!(member.building_id, building_id);
186        assert_eq!(member.position, BoardPosition::President);
187        assert_eq!(member.mandate_start, start);
188        assert_eq!(member.mandate_end, end);
189        assert_eq!(member.elected_by_meeting_id, meeting_id);
190    }
191
192    #[test]
193    fn test_mandate_duration_one_year() {
194        // Arrange
195        let start = Utc::now();
196        let end = start + Duration::days(365);
197
198        // Act
199        let result = BoardMember::new(
200            Uuid::new_v4(),
201            Uuid::new_v4(),
202            BoardPosition::Member,
203            start,
204            end,
205            Uuid::new_v4(),
206        );
207
208        // Assert
209        assert!(result.is_ok());
210    }
211
212    #[test]
213    fn test_mandate_duration_too_short_fails() {
214        // @negative — Arrange: 300 j < 330 (borne basse ≈ 1 an, Art. 3.90 CC)
215        let start = Utc::now();
216        let end = start + Duration::days(300);
217
218        // Act
219        let result = BoardMember::new(
220            Uuid::new_v4(),
221            Uuid::new_v4(),
222            BoardPosition::Member,
223            start,
224            end,
225            Uuid::new_v4(),
226        );
227
228        // Assert
229        assert!(result.is_err());
230        assert_eq!(
231            result.unwrap_err(),
232            "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
233        );
234    }
235
236    #[test]
237    fn test_mandate_duration_too_long_fails() {
238        // @negative — 730 j (2 ans) > 395 : règle conseil ≈ 1 an (Art. 3.90 CC).
239        // (Le plafond 3 ans est la règle *syndic* Art. 3.89, distincte.)
240        let start = Utc::now();
241        let end = start + Duration::days(730);
242
243        // Act
244        let result = BoardMember::new(
245            Uuid::new_v4(),
246            Uuid::new_v4(),
247            BoardPosition::Member,
248            start,
249            end,
250            Uuid::new_v4(),
251        );
252
253        // Assert
254        assert!(result.is_err());
255        assert_eq!(
256            result.unwrap_err(),
257            "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
258        );
259    }
260
261    #[test]
262    fn test_mandate_duration_boundaries() {
263        // @edge — fenêtre 330–395 j (Art. 3.90 CC), alignée contrainte DB.
264        let start = Utc::now();
265        let mk = |days: i64| {
266            BoardMember::new(
267                Uuid::new_v4(),
268                Uuid::new_v4(),
269                BoardPosition::Member,
270                start,
271                start + Duration::days(days),
272                Uuid::new_v4(),
273            )
274        };
275
276        // Bornes incluses : 330 et 395 acceptées.
277        assert!(mk(330).is_ok(), "330 days must be accepted (lower bound)");
278        assert!(mk(395).is_ok(), "395 days must be accepted (upper bound)");
279        // Juste hors bornes : 329 et 396 rejetées.
280        assert!(mk(329).is_err(), "329 days must be rejected (< 330)");
281        assert!(mk(396).is_err(), "396 days must be rejected (> 395)");
282    }
283
284    #[test]
285    fn test_mandate_duration_far_too_long_fails() {
286        // @negative — 1100 j (~3 ans, l'ancienne règle syndic mal appliquée)
287        // doit désormais être rejeté pour le conseil (Art. 3.90 CC, ≈ 1 an).
288        let start = Utc::now();
289        let end = start + Duration::days(1100);
290
291        // Act
292        let result = BoardMember::new(
293            Uuid::new_v4(),
294            Uuid::new_v4(),
295            BoardPosition::Member,
296            start,
297            end,
298            Uuid::new_v4(),
299        );
300
301        // Assert
302        assert!(result.is_err());
303        assert_eq!(
304            result.unwrap_err(),
305            "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
306        );
307    }
308
309    #[test]
310    fn test_mandate_start_after_end_fails() {
311        // Arrange
312        let start = Utc::now();
313        let end = start - Duration::days(10); // End avant start
314
315        // Act
316        let result = BoardMember::new(
317            Uuid::new_v4(),
318            Uuid::new_v4(),
319            BoardPosition::President,
320            start,
321            end,
322            Uuid::new_v4(),
323        );
324
325        // Assert
326        assert!(result.is_err());
327        assert_eq!(
328            result.unwrap_err(),
329            "Mandate start date must be before end date"
330        );
331    }
332
333    #[test]
334    fn test_is_active_mandate() {
335        // Arrange
336        let start = Utc::now() - Duration::days(10); // Commencé il y a 10 jours
337        let end = Utc::now() + Duration::days(355); // Expire dans 355 jours
338        let member = BoardMember::new(
339            Uuid::new_v4(),
340            Uuid::new_v4(),
341            BoardPosition::Member,
342            start,
343            end,
344            Uuid::new_v4(),
345        )
346        .unwrap();
347
348        // Act & Assert
349        assert!(member.is_active());
350    }
351
352    #[test]
353    fn test_is_not_active_future_mandate() {
354        // Arrange
355        let start = Utc::now() + Duration::days(10); // Commence dans 10 jours
356        let end = start + Duration::days(365);
357        let member = BoardMember::new(
358            Uuid::new_v4(),
359            Uuid::new_v4(),
360            BoardPosition::Member,
361            start,
362            end,
363            Uuid::new_v4(),
364        )
365        .unwrap();
366
367        // Act & Assert
368        assert!(!member.is_active());
369    }
370
371    #[test]
372    fn test_days_remaining_calculation() {
373        // Arrange
374        let start = Utc::now() - Duration::days(300); // Commencé il y a 300 jours
375        let end = start + Duration::days(365); // Expire dans 65 jours
376        let member = BoardMember::new(
377            Uuid::new_v4(),
378            Uuid::new_v4(),
379            BoardPosition::Treasurer,
380            start,
381            end,
382            Uuid::new_v4(),
383        )
384        .unwrap();
385
386        // Act
387        let remaining = member.days_remaining();
388
389        // Assert
390        assert!((64..=66).contains(&remaining)); // ±1 jour de tolérance
391    }
392
393    #[test]
394    fn test_days_remaining_expired_returns_zero() {
395        // Arrange
396        let start = Utc::now() - Duration::days(400); // Commencé il y a 400 jours
397        let end = start + Duration::days(365); // Expiré il y a 35 jours
398        let member = BoardMember::new(
399            Uuid::new_v4(),
400            Uuid::new_v4(),
401            BoardPosition::Member,
402            start,
403            end,
404            Uuid::new_v4(),
405        )
406        .unwrap();
407
408        // Act
409        let remaining = member.days_remaining();
410
411        // Assert
412        assert_eq!(remaining, 0);
413    }
414
415    #[test]
416    fn test_expires_soon_true() {
417        // Arrange
418        let start = Utc::now() - Duration::days(320); // Commencé il y a 320 jours
419        let end = start + Duration::days(365); // Expire dans 45 jours
420        let member = BoardMember::new(
421            Uuid::new_v4(),
422            Uuid::new_v4(),
423            BoardPosition::President,
424            start,
425            end,
426            Uuid::new_v4(),
427        )
428        .unwrap();
429
430        // Act & Assert
431        assert!(member.expires_soon());
432    }
433
434    #[test]
435    fn test_expires_soon_false_far_expiration() {
436        // Arrange
437        let start = Utc::now() - Duration::days(100); // Commencé il y a 100 jours
438        let end = start + Duration::days(365); // Expire dans 265 jours
439        let member = BoardMember::new(
440            Uuid::new_v4(),
441            Uuid::new_v4(),
442            BoardPosition::Member,
443            start,
444            end,
445            Uuid::new_v4(),
446        )
447        .unwrap();
448
449        // Act & Assert
450        assert!(!member.expires_soon());
451    }
452
453    #[test]
454    fn test_extend_mandate_success() {
455        // Arrange
456        let start = Utc::now() - Duration::days(320); // Expire dans 45 jours
457        let end = start + Duration::days(365);
458        let new_meeting_id = Uuid::new_v4();
459        let mut member = BoardMember::new(
460            Uuid::new_v4(),
461            Uuid::new_v4(),
462            BoardPosition::President,
463            start,
464            end,
465            Uuid::new_v4(),
466        )
467        .unwrap();
468
469        let original_end = member.mandate_end;
470
471        // Act: Extend for another 365 days
472        let result = member.extend_mandate(365, new_meeting_id);
473
474        // Assert
475        assert!(result.is_ok());
476        assert_eq!(member.mandate_start, original_end);
477        assert_eq!(member.mandate_end, original_end + Duration::days(365));
478        assert_eq!(member.elected_by_meeting_id, new_meeting_id);
479    }
480
481    #[test]
482    fn test_extend_mandate_too_long_fails() {
483        // @negative — renouveler pour 2 ans (730 j) > 395 : rejeté
484        // (conseil ≈ 1 an, Art. 3.90 CC). L'ancien comportement (ok) appliquait
485        // à tort la règle syndic 1–3 ans (Art. 3.89).
486        let start = Utc::now() - Duration::days(320); // Expire dans 45 jours
487        let end = start + Duration::days(365);
488        let new_meeting_id = Uuid::new_v4();
489        let mut member = BoardMember::new(
490            Uuid::new_v4(),
491            Uuid::new_v4(),
492            BoardPosition::President,
493            start,
494            end,
495            Uuid::new_v4(),
496        )
497        .unwrap();
498
499        // Act: Extend for 2 years (730 days)
500        let result = member.extend_mandate(730, new_meeting_id);
501
502        // Assert
503        assert!(result.is_err());
504        assert_eq!(
505            result.unwrap_err(),
506            "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
507        );
508    }
509
510    #[test]
511    fn test_extend_mandate_far_too_long_fails() {
512        // @negative — renouveler pour ~3 ans (1100 j) rejeté pour le conseil
513        // (Art. 3.90 CC, ≈ 1 an).
514        let start = Utc::now() - Duration::days(320); // Expire dans 45 jours
515        let end = start + Duration::days(365);
516        let new_meeting_id = Uuid::new_v4();
517        let mut member = BoardMember::new(
518            Uuid::new_v4(),
519            Uuid::new_v4(),
520            BoardPosition::President,
521            start,
522            end,
523            Uuid::new_v4(),
524        )
525        .unwrap();
526
527        // Act: Try to extend for ~3 years (1100 days)
528        let result = member.extend_mandate(1100, new_meeting_id);
529
530        // Assert
531        assert!(result.is_err());
532        assert_eq!(
533            result.unwrap_err(),
534            "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
535        );
536    }
537
538    #[test]
539    fn test_extend_mandate_fails_too_early() {
540        // Arrange
541        let start = Utc::now() - Duration::days(100); // Expire dans 265 jours
542        let end = start + Duration::days(365);
543        let mut member = BoardMember::new(
544            Uuid::new_v4(),
545            Uuid::new_v4(),
546            BoardPosition::Member,
547            start,
548            end,
549            Uuid::new_v4(),
550        )
551        .unwrap();
552
553        // Act
554        let result = member.extend_mandate(365, Uuid::new_v4());
555
556        // Assert
557        assert!(result.is_err());
558        assert_eq!(
559            result.unwrap_err(),
560            "Cannot extend mandate more than 60 days before expiration"
561        );
562    }
563
564    // ── Story 4.7 — démission et bascule du mandat ──────────────────────
565
566    /// @happy — un membre en cours de mandat démissionne : le mandat
567    /// s'arrête au moment de la démission.
568    #[test]
569    fn happy_resign_sets_mandate_end_to_now() {
570        let start = Utc::now() - Duration::days(100);
571        let end = start + Duration::days(365);
572        let mut member = BoardMember::new(
573            Uuid::new_v4(),
574            Uuid::new_v4(),
575            BoardPosition::Member,
576            start,
577            end,
578            Uuid::new_v4(),
579        )
580        .unwrap();
581
582        let resignation_instant = Utc::now();
583        member.resign(resignation_instant);
584
585        assert_eq!(member.mandate_end, resignation_instant);
586    }
587
588    /// @edge — la borne se teste au moment précis de la bascule, pas
589    /// seulement juste avant / juste après (Story 4.7 §@edge).
590    #[test]
591    fn edge_resign_loses_rights_exactly_at_mandate_end() {
592        let start = Utc::now() - Duration::days(100);
593        let end = start + Duration::days(365);
594        let mut member = BoardMember::new(
595            Uuid::new_v4(),
596            Uuid::new_v4(),
597            BoardPosition::Member,
598            start,
599            end,
600            Uuid::new_v4(),
601        )
602        .unwrap();
603
604        let resignation_instant = Utc::now();
605        member.resign(resignation_instant);
606
607        assert!(
608            member.is_active_at(resignation_instant - Duration::milliseconds(1)),
609            "juste avant la démission, le mandat était encore actif"
610        );
611        assert!(
612            !member.is_active_at(resignation_instant),
613            "au moment exact de la démission, le mandat est déjà échu"
614        );
615    }
616
617    /// @edge — démissionner ne peut jamais *prolonger* un mandat déjà échu :
618    /// `resign` prend le minimum entre la fin actuelle et l'instant donné.
619    #[test]
620    fn edge_resign_after_mandate_already_ended_does_not_extend_it() {
621        let start = Utc::now() - Duration::days(400);
622        let end = start + Duration::days(365); // déjà échu
623        let mut member = BoardMember::new(
624            Uuid::new_v4(),
625            Uuid::new_v4(),
626            BoardPosition::Member,
627            start,
628            end,
629            Uuid::new_v4(),
630        )
631        .unwrap();
632
633        member.resign(Utc::now());
634
635        assert_eq!(member.mandate_end, end, "la fin de mandat n'a pas bougé");
636    }
637
638    #[test]
639    fn test_board_position_display() {
640        assert_eq!(BoardPosition::President.to_string(), "president");
641        assert_eq!(BoardPosition::Treasurer.to_string(), "treasurer");
642        assert_eq!(BoardPosition::Member.to_string(), "member");
643    }
644
645    #[test]
646    fn test_board_position_from_str() {
647        assert_eq!(
648            "president".parse::<BoardPosition>().unwrap(),
649            BoardPosition::President
650        );
651        assert_eq!(
652            "President".parse::<BoardPosition>().unwrap(),
653            BoardPosition::President
654        );
655        assert_eq!(
656            "TREASURER".parse::<BoardPosition>().unwrap(),
657            BoardPosition::Treasurer
658        );
659        assert_eq!(
660            "member".parse::<BoardPosition>().unwrap(),
661            BoardPosition::Member
662        );
663
664        assert!("invalid".parse::<BoardPosition>().is_err());
665    }
666}