koprogo_api/domain/entities/
meeting.rs

1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Type d'assemblée générale
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum MeetingType {
8    Ordinary,      // Assemblée Générale Ordinaire (AGO)
9    Extraordinary, // Assemblée Générale Extraordinaire (AGE)
10}
11
12/// Statut de l'assemblée
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub enum MeetingStatus {
15    Scheduled,
16    Completed,
17    Cancelled,
18}
19
20/// Représente une assemblée générale de copropriétaires
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct Meeting {
23    pub id: Uuid,
24    pub organization_id: Uuid,
25    pub building_id: Uuid,
26    pub meeting_type: MeetingType,
27    pub title: String,
28    pub description: Option<String>,
29    pub scheduled_date: DateTime<Utc>,
30    pub location: String,
31    pub status: MeetingStatus,
32    pub agenda: Vec<String>,
33    pub attendees_count: Option<i32>,
34    // Quorum — Art. 3.87 §5 CC : AG valide si >50% des quotes-parts présentes/représentées
35    pub quorum_validated: bool,
36    pub quorum_percentage: Option<f64>, // % des quotes-parts présentes/représentées (0.0-100.0)
37    pub total_quotas: Option<f64>,      // Total millièmes du bâtiment (généralement 1000)
38    pub present_quotas: Option<f64>,    // Millièmes présents + représentés par procuration
39    // Second Convocation — Issue #311 (Art. 3.87 §5 CC: No quorum required for 2nd convocation)
40    pub is_second_convocation: bool, // true = 2e convocation (no quorum check needed)
41    // PV Distribution — Issue #313: Track when AG minutes are sent to owners
42    pub minutes_document_id: Option<Uuid>, // FK to Document
43    pub minutes_sent_at: Option<DateTime<Utc>>, // When PV was distributed
44    pub created_at: DateTime<Utc>,
45    pub updated_at: DateTime<Utc>,
46}
47
48impl Meeting {
49    pub fn new(
50        organization_id: Uuid,
51        building_id: Uuid,
52        meeting_type: MeetingType,
53        title: String,
54        description: Option<String>,
55        scheduled_date: DateTime<Utc>,
56        location: String,
57    ) -> Result<Self, String> {
58        if title.is_empty() {
59            return Err("Title cannot be empty".to_string());
60        }
61        if location.is_empty() {
62            return Err("Location cannot be empty".to_string());
63        }
64
65        let now = Utc::now();
66        Ok(Self {
67            id: Uuid::new_v4(),
68            organization_id,
69            building_id,
70            meeting_type,
71            title,
72            description,
73            scheduled_date,
74            location,
75            status: MeetingStatus::Scheduled,
76            agenda: Vec::new(),
77            attendees_count: None,
78            quorum_validated: false,
79            quorum_percentage: None,
80            total_quotas: None,
81            present_quotas: None,
82            is_second_convocation: false, // Default: first convocation
83            minutes_document_id: None,
84            minutes_sent_at: None,
85            created_at: now,
86            updated_at: now,
87        })
88    }
89
90    pub fn add_agenda_item(&mut self, item: String) -> Result<(), String> {
91        if item.is_empty() {
92            return Err("Agenda item cannot be empty".to_string());
93        }
94        self.agenda.push(item);
95        self.updated_at = Utc::now();
96        Ok(())
97    }
98
99    pub fn complete(&mut self, attendees_count: i32) -> Result<(), String> {
100        match self.status {
101            MeetingStatus::Scheduled => {
102                self.status = MeetingStatus::Completed;
103                self.attendees_count = Some(attendees_count);
104                self.updated_at = Utc::now();
105                Ok(())
106            }
107            MeetingStatus::Completed => Err("Meeting is already completed".to_string()),
108            MeetingStatus::Cancelled => Err("Cannot complete a cancelled meeting".to_string()),
109        }
110    }
111
112    pub fn cancel(&mut self) -> Result<(), String> {
113        match self.status {
114            MeetingStatus::Scheduled => {
115                self.status = MeetingStatus::Cancelled;
116                self.updated_at = Utc::now();
117                Ok(())
118            }
119            MeetingStatus::Completed => Err("Cannot cancel a completed meeting".to_string()),
120            MeetingStatus::Cancelled => Err("Meeting is already cancelled".to_string()),
121        }
122    }
123
124    pub fn reschedule(&mut self, new_date: DateTime<Utc>) -> Result<(), String> {
125        match self.status {
126            MeetingStatus::Scheduled | MeetingStatus::Cancelled => {
127                self.scheduled_date = new_date;
128                self.status = MeetingStatus::Scheduled;
129                self.updated_at = Utc::now();
130                Ok(())
131            }
132            MeetingStatus::Completed => Err("Cannot reschedule a completed meeting".to_string()),
133        }
134    }
135
136    pub fn is_upcoming(&self) -> bool {
137        self.status == MeetingStatus::Scheduled && self.scheduled_date > Utc::now()
138    }
139
140    /// Valide le quorum de l'AG (Art. 3.87 §5 CC).
141    /// Quorum atteint si les quotes-parts présentes/représentées dépassent 50% du total.
142    /// Retourne Ok(true) si quorum atteint, Ok(false) si insuffisant (2e convocation requise).
143    pub fn validate_quorum(
144        &mut self,
145        present_quotas: f64,
146        total_quotas: f64,
147    ) -> Result<bool, String> {
148        if total_quotas <= 0.0 {
149            return Err("Total quotas must be positive".to_string());
150        }
151        if present_quotas < 0.0 {
152            return Err("Present quotas cannot be negative".to_string());
153        }
154        if present_quotas > total_quotas {
155            return Err("Present quotas cannot exceed total quotas".to_string());
156        }
157
158        let percentage = (present_quotas / total_quotas) * 100.0;
159        // Quorum : >50% des quotes-parts (Art. 3.87 §5 — majorité stricte)
160        let quorum_reached = percentage > 50.0;
161
162        self.present_quotas = Some(present_quotas);
163        self.total_quotas = Some(total_quotas);
164        self.quorum_percentage = Some(percentage);
165        self.quorum_validated = quorum_reached;
166        self.updated_at = Utc::now();
167
168        Ok(quorum_reached)
169    }
170
171    /// Vérifie si le quorum est atteint avant d'autoriser un vote.
172    /// Retourne Err si le quorum n'a pas encore été validé ou n'est pas atteint.
173    ///
174    /// EXCEPTION (Art. 3.87 §5 CC): No quorum check required for second convocation (is_second_convocation = true).
175    /// Belgian law: 2e convocation = voting allowed without quorum requirement.
176    pub fn check_quorum_for_voting(&self) -> Result<(), String> {
177        // Art. 3.87 §5 CC: No quorum check needed for 2nd convocation
178        if self.is_second_convocation {
179            return Ok(());
180        }
181
182        if self.quorum_percentage.is_none() {
183            return Err("Quorum has not been validated yet (Art. 3.87 §5 CC)".to_string());
184        }
185        if !self.quorum_validated {
186            let pct = self.quorum_percentage.unwrap_or(0.0);
187            return Err(format!(
188                "Quorum not reached: {:.1}% present (>50% required, Art. 3.87 §5 CC). \
189                 A second convocation is required.",
190                pct
191            ));
192        }
193        Ok(())
194    }
195
196    /// Sets minutes as sent (Issue #313: PV distribution tracking).
197    /// Can only be called once meeting is Completed.
198    pub fn set_minutes_sent(&mut self, document_id: Uuid) -> Result<(), String> {
199        if self.status != MeetingStatus::Completed {
200            return Err("Minutes can only be sent after meeting is completed".to_string());
201        }
202        self.minutes_document_id = Some(document_id);
203        self.minutes_sent_at = Some(Utc::now());
204        self.updated_at = Utc::now();
205        Ok(())
206    }
207
208    /// Checks if minutes are overdue (Issue #313: 30 days after meeting completion).
209    /// Returns true if meeting is Completed, minutes not yet sent, and >30 days have passed.
210    pub fn is_minutes_overdue(&self) -> bool {
211        if self.status != MeetingStatus::Completed {
212            return false;
213        }
214        if self.minutes_sent_at.is_some() {
215            return false;
216        }
217        // Minutes are overdue if more than 30 days have passed since completion/update
218        let deadline = self.updated_at + Duration::days(30);
219        Utc::now() > deadline
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use chrono::Duration;
227
228    #[test]
229    fn test_create_meeting_success() {
230        let org_id = Uuid::new_v4();
231        let building_id = Uuid::new_v4();
232        let future_date = Utc::now() + Duration::days(30);
233
234        let meeting = Meeting::new(
235            org_id,
236            building_id,
237            MeetingType::Ordinary,
238            "AGO 2024".to_string(),
239            Some("Assemblée générale ordinaire annuelle".to_string()),
240            future_date,
241            "Salle des fêtes".to_string(),
242        );
243
244        assert!(meeting.is_ok());
245        let meeting = meeting.unwrap();
246        assert_eq!(meeting.organization_id, org_id);
247        assert_eq!(meeting.status, MeetingStatus::Scheduled);
248        assert!(meeting.is_upcoming());
249    }
250
251    #[test]
252    fn test_add_agenda_item() {
253        let org_id = Uuid::new_v4();
254        let building_id = Uuid::new_v4();
255        let future_date = Utc::now() + Duration::days(30);
256
257        let mut meeting = Meeting::new(
258            org_id,
259            building_id,
260            MeetingType::Ordinary,
261            "AGO 2024".to_string(),
262            None,
263            future_date,
264            "Salle des fêtes".to_string(),
265        )
266        .unwrap();
267
268        let result = meeting.add_agenda_item("Approbation des comptes".to_string());
269        assert!(result.is_ok());
270        assert_eq!(meeting.agenda.len(), 1);
271    }
272
273    #[test]
274    fn test_complete_meeting() {
275        let org_id = Uuid::new_v4();
276        let building_id = Uuid::new_v4();
277        let future_date = Utc::now() + Duration::days(30);
278
279        let mut meeting = Meeting::new(
280            org_id,
281            building_id,
282            MeetingType::Ordinary,
283            "AGO 2024".to_string(),
284            None,
285            future_date,
286            "Salle des fêtes".to_string(),
287        )
288        .unwrap();
289
290        let result = meeting.complete(45);
291        assert!(result.is_ok());
292        assert_eq!(meeting.status, MeetingStatus::Completed);
293        assert_eq!(meeting.attendees_count, Some(45));
294        assert!(!meeting.is_upcoming());
295    }
296
297    #[test]
298    fn test_complete_already_completed_fails() {
299        let org_id = Uuid::new_v4();
300        let building_id = Uuid::new_v4();
301        let future_date = Utc::now() + Duration::days(30);
302
303        let mut meeting = Meeting::new(
304            org_id,
305            building_id,
306            MeetingType::Ordinary,
307            "AGO 2024".to_string(),
308            None,
309            future_date,
310            "Salle des fêtes".to_string(),
311        )
312        .unwrap();
313
314        meeting.complete(45).unwrap();
315        let result = meeting.complete(50);
316        assert!(result.is_err());
317        assert_eq!(meeting.attendees_count, Some(45)); // Should not change
318    }
319
320    #[test]
321    fn test_cancel_meeting() {
322        let org_id = Uuid::new_v4();
323        let building_id = Uuid::new_v4();
324        let future_date = Utc::now() + Duration::days(30);
325
326        let mut meeting = Meeting::new(
327            org_id,
328            building_id,
329            MeetingType::Ordinary,
330            "AGO 2024".to_string(),
331            None,
332            future_date,
333            "Salle des fêtes".to_string(),
334        )
335        .unwrap();
336
337        let result = meeting.cancel();
338        assert!(result.is_ok());
339        assert_eq!(meeting.status, MeetingStatus::Cancelled);
340    }
341
342    #[test]
343    fn test_quorum_reached_above_50_percent() {
344        let org_id = Uuid::new_v4();
345        let building_id = Uuid::new_v4();
346        let future_date = Utc::now() + Duration::days(30);
347
348        let mut meeting = Meeting::new(
349            org_id,
350            building_id,
351            MeetingType::Ordinary,
352            "AGO 2024".to_string(),
353            None,
354            future_date,
355            "Salle des fêtes".to_string(),
356        )
357        .unwrap();
358
359        // 600 millièmes présents sur 1000 = 60% → quorum atteint
360        let result = meeting.validate_quorum(600.0, 1000.0);
361        assert!(result.is_ok());
362        assert!(result.unwrap());
363        assert!(meeting.quorum_validated);
364        assert!((meeting.quorum_percentage.unwrap() - 60.0).abs() < 0.01);
365    }
366
367    #[test]
368    fn test_quorum_not_reached_at_50_percent_exact() {
369        let org_id = Uuid::new_v4();
370        let building_id = Uuid::new_v4();
371        let future_date = Utc::now() + Duration::days(30);
372
373        let mut meeting = Meeting::new(
374            org_id,
375            building_id,
376            MeetingType::Ordinary,
377            "AGO 2024".to_string(),
378            None,
379            future_date,
380            "Salle des fêtes".to_string(),
381        )
382        .unwrap();
383
384        // 500 millièmes sur 1000 = exactement 50% → quorum NON atteint (Art. 3.87 §5 : >50% requis)
385        let result = meeting.validate_quorum(500.0, 1000.0);
386        assert!(result.is_ok());
387        assert!(!result.unwrap());
388        assert!(!meeting.quorum_validated);
389    }
390
391    #[test]
392    fn test_quorum_not_reached_below_50_percent() {
393        let org_id = Uuid::new_v4();
394        let building_id = Uuid::new_v4();
395        let future_date = Utc::now() + Duration::days(30);
396
397        let mut meeting = Meeting::new(
398            org_id,
399            building_id,
400            MeetingType::Ordinary,
401            "AGO 2024".to_string(),
402            None,
403            future_date,
404            "Salle des fêtes".to_string(),
405        )
406        .unwrap();
407
408        // 400 millièmes sur 1000 = 40% → quorum non atteint
409        let result = meeting.validate_quorum(400.0, 1000.0);
410        assert!(result.is_ok());
411        assert!(!result.unwrap());
412        assert!(!meeting.quorum_validated);
413    }
414
415    #[test]
416    fn test_check_quorum_blocks_vote_when_not_validated() {
417        let org_id = Uuid::new_v4();
418        let building_id = Uuid::new_v4();
419        let future_date = Utc::now() + Duration::days(30);
420
421        let meeting = Meeting::new(
422            org_id,
423            building_id,
424            MeetingType::Ordinary,
425            "AGO 2024".to_string(),
426            None,
427            future_date,
428            "Salle des fêtes".to_string(),
429        )
430        .unwrap();
431
432        let result = meeting.check_quorum_for_voting();
433        assert!(result.is_err());
434        assert!(result.unwrap_err().contains("not been validated yet"));
435    }
436
437    #[test]
438    fn test_check_quorum_skipped_for_second_convocation() {
439        // Art. 3.87 §5 CC: No quorum check for 2nd convocation
440        let org_id = Uuid::new_v4();
441        let building_id = Uuid::new_v4();
442        let future_date = Utc::now() + Duration::days(30);
443
444        let mut meeting = Meeting::new(
445            org_id,
446            building_id,
447            MeetingType::Extraordinary,
448            "2e Convocation AGE".to_string(),
449            Some("Deuxième convocation - sans quorum".to_string()),
450            future_date,
451            "Salle des fêtes".to_string(),
452        )
453        .unwrap();
454
455        // Mark as second convocation
456        meeting.is_second_convocation = true;
457
458        // Should allow voting even without quorum validation
459        let result = meeting.check_quorum_for_voting();
460        assert!(result.is_ok(), "2nd convocation should skip quorum check");
461    }
462
463    #[test]
464    fn test_check_quorum_blocks_vote_when_quorum_not_reached() {
465        let org_id = Uuid::new_v4();
466        let building_id = Uuid::new_v4();
467        let future_date = Utc::now() + Duration::days(30);
468
469        let mut meeting = Meeting::new(
470            org_id,
471            building_id,
472            MeetingType::Ordinary,
473            "AGO 2024".to_string(),
474            None,
475            future_date,
476            "Salle des fêtes".to_string(),
477        )
478        .unwrap();
479
480        meeting.validate_quorum(400.0, 1000.0).unwrap();
481        let result = meeting.check_quorum_for_voting();
482        assert!(result.is_err());
483        assert!(result.unwrap_err().contains("second convocation"));
484    }
485
486    #[test]
487    fn test_quorum_invalid_total_quotas() {
488        let org_id = Uuid::new_v4();
489        let building_id = Uuid::new_v4();
490        let future_date = Utc::now() + Duration::days(30);
491
492        let mut meeting = Meeting::new(
493            org_id,
494            building_id,
495            MeetingType::Ordinary,
496            "AGO 2024".to_string(),
497            None,
498            future_date,
499            "Salle des fêtes".to_string(),
500        )
501        .unwrap();
502
503        let result = meeting.validate_quorum(100.0, 0.0);
504        assert!(result.is_err());
505    }
506
507    #[test]
508    fn test_reschedule_meeting() {
509        let org_id = Uuid::new_v4();
510        let building_id = Uuid::new_v4();
511        let future_date = Utc::now() + Duration::days(30);
512
513        let mut meeting = Meeting::new(
514            org_id,
515            building_id,
516            MeetingType::Ordinary,
517            "AGO 2024".to_string(),
518            None,
519            future_date,
520            "Salle des fêtes".to_string(),
521        )
522        .unwrap();
523
524        let new_date = Utc::now() + Duration::days(60);
525        let result = meeting.reschedule(new_date);
526        assert!(result.is_ok());
527        assert_eq!(meeting.scheduled_date, new_date);
528    }
529
530    #[test]
531    fn test_set_minutes_sent_success() {
532        // Arrange
533        let org_id = Uuid::new_v4();
534        let building_id = Uuid::new_v4();
535        let future_date = Utc::now() + Duration::days(30);
536        let doc_id = Uuid::new_v4();
537
538        let mut meeting = Meeting::new(
539            org_id,
540            building_id,
541            MeetingType::Ordinary,
542            "AGO 2024".to_string(),
543            None,
544            future_date,
545            "Salle des fêtes".to_string(),
546        )
547        .unwrap();
548
549        // Act: Complete the meeting first
550        meeting.complete(45).unwrap();
551        let result = meeting.set_minutes_sent(doc_id);
552
553        // Assert
554        assert!(result.is_ok());
555        assert_eq!(meeting.minutes_document_id, Some(doc_id));
556        assert!(meeting.minutes_sent_at.is_some());
557    }
558
559    #[test]
560    fn test_set_minutes_sent_before_completion_fails() {
561        // Arrange
562        let org_id = Uuid::new_v4();
563        let building_id = Uuid::new_v4();
564        let future_date = Utc::now() + Duration::days(30);
565        let doc_id = Uuid::new_v4();
566
567        let mut meeting = Meeting::new(
568            org_id,
569            building_id,
570            MeetingType::Ordinary,
571            "AGO 2024".to_string(),
572            None,
573            future_date,
574            "Salle des fêtes".to_string(),
575        )
576        .unwrap();
577
578        // Act: Try to send minutes while meeting is still Scheduled
579        let result = meeting.set_minutes_sent(doc_id);
580
581        // Assert
582        assert!(result.is_err());
583        assert_eq!(
584            result.unwrap_err(),
585            "Minutes can only be sent after meeting is completed"
586        );
587    }
588
589    #[test]
590    fn test_is_minutes_overdue_not_completed() {
591        // Arrange
592        let org_id = Uuid::new_v4();
593        let building_id = Uuid::new_v4();
594        let future_date = Utc::now() + Duration::days(30);
595
596        let meeting = Meeting::new(
597            org_id,
598            building_id,
599            MeetingType::Ordinary,
600            "AGO 2024".to_string(),
601            None,
602            future_date,
603            "Salle des fêtes".to_string(),
604        )
605        .unwrap();
606
607        // Act & Assert
608        assert!(!meeting.is_minutes_overdue()); // Not completed yet
609    }
610
611    #[test]
612    fn test_is_minutes_overdue_sent() {
613        // Arrange
614        let org_id = Uuid::new_v4();
615        let building_id = Uuid::new_v4();
616        let future_date = Utc::now() + Duration::days(30);
617        let doc_id = Uuid::new_v4();
618
619        let mut meeting = Meeting::new(
620            org_id,
621            building_id,
622            MeetingType::Ordinary,
623            "AGO 2024".to_string(),
624            None,
625            future_date,
626            "Salle des fêtes".to_string(),
627        )
628        .unwrap();
629
630        // Act
631        meeting.complete(45).unwrap();
632        meeting.set_minutes_sent(doc_id).unwrap();
633
634        // Assert
635        assert!(!meeting.is_minutes_overdue()); // Minutes sent
636    }
637
638    #[test]
639    fn test_is_minutes_overdue_past_30_days() {
640        // Arrange
641        let org_id = Uuid::new_v4();
642        let building_id = Uuid::new_v4();
643        let future_date = Utc::now() + Duration::days(30);
644
645        let mut meeting = Meeting::new(
646            org_id,
647            building_id,
648            MeetingType::Ordinary,
649            "AGO 2024".to_string(),
650            None,
651            future_date,
652            "Salle des fêtes".to_string(),
653        )
654        .unwrap();
655
656        // Act: Complete the meeting and manually set updated_at to >30 days ago
657        meeting.complete(45).unwrap();
658        meeting.updated_at = Utc::now() - Duration::days(31);
659
660        // Assert
661        assert!(meeting.is_minutes_overdue()); // >30 days without sending minutes
662    }
663
664    #[test]
665    fn test_is_minutes_overdue_within_30_days() {
666        // Arrange
667        let org_id = Uuid::new_v4();
668        let building_id = Uuid::new_v4();
669        let future_date = Utc::now() + Duration::days(30);
670
671        let mut meeting = Meeting::new(
672            org_id,
673            building_id,
674            MeetingType::Ordinary,
675            "AGO 2024".to_string(),
676            None,
677            future_date,
678            "Salle des fêtes".to_string(),
679        )
680        .unwrap();
681
682        // Act: Complete the meeting
683        meeting.complete(45).unwrap();
684
685        // Assert
686        assert!(!meeting.is_minutes_overdue()); // Within 30 days
687    }
688}