koprogo_api/domain/entities/
resolution.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Type de résolution (ordinaire ou extraordinaire)
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
7#[serde(rename_all = "snake_case")]
8pub enum ResolutionType {
9    Ordinary,      // Résolution ordinaire (majorité simple)
10    Extraordinary, // Résolution extraordinaire (majorité qualifiée)
11}
12
13/// Type de majorité requise pour adoption — Art. 3.88 §1 Code Civil belge
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
15#[serde(rename_all = "snake_case")]
16pub enum MajorityType {
17    /// Plus de 50% des présents/représentés, abstentions EXCLUES — Art. 3.88 §1 (DÉFAUT).
18    /// Comptes, budget, syndic, commissaire, entretien courant, travaux imposés par la loi.
19    Absolute,
20    /// ≥2/3 des présents/représentés, abstentions EXCLUES — Art. 3.88 §1, 1°
21    /// Modification statuts (jouissance/usage), travaux parties communes, mise en concurrence
22    TwoThirds,
23    /// ≥4/5 des présents/représentés, abstentions EXCLUES — Art. 3.88 §1, 2°
24    /// Modification répartition charges, destination, reconstruction partielle, aliénation
25    FourFifths,
26    /// 100% de TOUS les tantièmes (y compris absents) — Art. 3.88 §1, 3°
27    /// Modification quotités de copropriété, reconstruction totale
28    Unanimity,
29}
30
31/// Statut d'une résolution
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum ResolutionStatus {
35    Pending,  // En attente de vote
36    Adopted,  // Adoptée
37    Rejected, // Rejetée
38}
39
40/// Résolution soumise au vote lors d'une assemblée générale
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
42pub struct Resolution {
43    pub id: Uuid,
44    pub meeting_id: Uuid,
45    pub title: String,
46    pub description: String,
47    pub resolution_type: ResolutionType,
48    pub majority_required: MajorityType,
49    pub vote_count_pour: i32,
50    pub vote_count_contre: i32,
51    pub vote_count_abstention: i32,
52    pub total_voting_power_pour: f64,
53    pub total_voting_power_contre: f64,
54    pub total_voting_power_abstention: f64,
55    pub status: ResolutionStatus,
56    // Issue #310: Link resolution to agenda item
57    pub agenda_item_index: Option<usize>, // Index into meeting.agenda Vec
58    pub created_at: DateTime<Utc>,
59    pub voted_at: Option<DateTime<Utc>>,
60}
61
62impl Resolution {
63    /// Crée une nouvelle résolution
64    /// Issue #310: Optional agenda_item_index for Art. 3.87 CC compliance (Belgian law)
65    pub fn new(
66        meeting_id: Uuid,
67        title: String,
68        description: String,
69        resolution_type: ResolutionType,
70        majority_required: MajorityType,
71        agenda_item_index: Option<usize>,
72    ) -> Result<Self, String> {
73        if title.is_empty() {
74            return Err("Resolution title cannot be empty".to_string());
75        }
76        if description.is_empty() {
77            return Err("Resolution description cannot be empty".to_string());
78        }
79
80        let now = Utc::now();
81        Ok(Self {
82            id: Uuid::new_v4(),
83            meeting_id,
84            title,
85            description,
86            resolution_type,
87            majority_required,
88            vote_count_pour: 0,
89            vote_count_contre: 0,
90            vote_count_abstention: 0,
91            total_voting_power_pour: 0.0,
92            total_voting_power_contre: 0.0,
93            total_voting_power_abstention: 0.0,
94            status: ResolutionStatus::Pending,
95            agenda_item_index,
96            created_at: now,
97            voted_at: None,
98        })
99    }
100
101    /// Enregistre un vote "Pour" et met à jour les compteurs
102    pub fn record_vote_pour(&mut self, voting_power: f64) {
103        self.vote_count_pour += 1;
104        self.total_voting_power_pour += voting_power;
105    }
106
107    /// Enregistre un vote "Contre" et met à jour les compteurs
108    pub fn record_vote_contre(&mut self, voting_power: f64) {
109        self.vote_count_contre += 1;
110        self.total_voting_power_contre += voting_power;
111    }
112
113    /// Enregistre une abstention et met à jour les compteurs
114    pub fn record_abstention(&mut self, voting_power: f64) {
115        self.vote_count_abstention += 1;
116        self.total_voting_power_abstention += voting_power;
117    }
118
119    /// Calcule le résultat du vote en fonction du type de majorité — Art. 3.88 §1 Code Civil belge
120    pub fn calculate_result(&self, total_voting_power: f64) -> ResolutionStatus {
121        let expressed = self.total_voting_power_pour + self.total_voting_power_contre;
122
123        match &self.majority_required {
124            MajorityType::Absolute => {
125                // Art. 3.88 §1: >50% des voix exprimées (hors abstentions)
126                if expressed > 0.0 && self.total_voting_power_pour > expressed / 2.0 {
127                    ResolutionStatus::Adopted
128                } else {
129                    ResolutionStatus::Rejected
130                }
131            }
132            MajorityType::TwoThirds => {
133                // Art. 3.88 §1, 1°: ≥2/3 des voix exprimées
134                if expressed > 0.0 && self.total_voting_power_pour / expressed >= 2.0 / 3.0 {
135                    ResolutionStatus::Adopted
136                } else {
137                    ResolutionStatus::Rejected
138                }
139            }
140            MajorityType::FourFifths => {
141                // Art. 3.88 §1, 2°: ≥4/5 des voix exprimées
142                if expressed > 0.0 && self.total_voting_power_pour / expressed >= 4.0 / 5.0 {
143                    ResolutionStatus::Adopted
144                } else {
145                    ResolutionStatus::Rejected
146                }
147            }
148            MajorityType::Unanimity => {
149                // Art. 3.88 §1, 3°: 100% de TOUS les tantièmes (pas juste les présents)
150                // total_voting_power = total building tantièmes (e.g. 10000)
151                if total_voting_power > 0.0
152                    && (self.total_voting_power_pour - total_voting_power).abs() < 0.01
153                {
154                    ResolutionStatus::Adopted
155                } else {
156                    ResolutionStatus::Rejected
157                }
158            }
159        }
160    }
161
162    /// Clôture le vote et finalise le statut
163    pub fn close_voting(&mut self, total_voting_power: f64) -> Result<(), String> {
164        if self.status != ResolutionStatus::Pending {
165            return Err("Voting already closed for this resolution".to_string());
166        }
167
168        self.status = self.calculate_result(total_voting_power);
169        self.voted_at = Some(Utc::now());
170        Ok(())
171    }
172
173    /// Retourne le nombre total de votes exprimés
174    pub fn total_votes(&self) -> i32 {
175        self.vote_count_pour + self.vote_count_contre + self.vote_count_abstention
176    }
177
178    /// Retourne le pourcentage de votes "Pour"
179    pub fn pour_percentage(&self) -> f64 {
180        let total = self.total_votes();
181        if total > 0 {
182            (self.vote_count_pour as f64 / total as f64) * 100.0
183        } else {
184            0.0
185        }
186    }
187
188    /// Retourne le pourcentage de votes "Contre"
189    pub fn contre_percentage(&self) -> f64 {
190        let total = self.total_votes();
191        if total > 0 {
192            (self.vote_count_contre as f64 / total as f64) * 100.0
193        } else {
194            0.0
195        }
196    }
197
198    /// Retourne le pourcentage d'abstentions
199    pub fn abstention_percentage(&self) -> f64 {
200        let total = self.total_votes();
201        if total > 0 {
202            (self.vote_count_abstention as f64 / total as f64) * 100.0
203        } else {
204            0.0
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_create_resolution_success() {
215        let meeting_id = Uuid::new_v4();
216        let resolution = Resolution::new(
217            meeting_id,
218            "Approbation des comptes 2024".to_string(),
219            "Vote pour approuver les comptes annuels de l'exercice 2024".to_string(),
220            ResolutionType::Ordinary,
221            MajorityType::Absolute,
222            Some(0),
223        );
224
225        assert!(resolution.is_ok());
226        let resolution = resolution.unwrap();
227        assert_eq!(resolution.meeting_id, meeting_id);
228        assert_eq!(resolution.status, ResolutionStatus::Pending);
229        assert_eq!(resolution.total_votes(), 0);
230        assert_eq!(resolution.agenda_item_index, Some(0));
231    }
232
233    #[test]
234    fn test_create_resolution_without_agenda_item() {
235        let meeting_id = Uuid::new_v4();
236        let resolution = Resolution::new(
237            meeting_id,
238            "Approbation des comptes 2024".to_string(),
239            "Vote pour approuver les comptes annuels de l'exercice 2024".to_string(),
240            ResolutionType::Ordinary,
241            MajorityType::Absolute,
242            None,
243        );
244
245        assert!(resolution.is_ok());
246        let resolution = resolution.unwrap();
247        assert_eq!(resolution.agenda_item_index, None);
248    }
249
250    #[test]
251    fn test_create_resolution_empty_title_fails() {
252        let meeting_id = Uuid::new_v4();
253        let resolution = Resolution::new(
254            meeting_id,
255            "".to_string(),
256            "Description".to_string(),
257            ResolutionType::Ordinary,
258            MajorityType::Absolute,
259            Some(0),
260        );
261
262        assert!(resolution.is_err());
263        assert_eq!(resolution.unwrap_err(), "Resolution title cannot be empty");
264    }
265
266    #[test]
267    fn test_record_votes() {
268        let meeting_id = Uuid::new_v4();
269        let mut resolution = Resolution::new(
270            meeting_id,
271            "Test Resolution".to_string(),
272            "Description".to_string(),
273            ResolutionType::Ordinary,
274            MajorityType::Absolute,
275            Some(0),
276        )
277        .unwrap();
278
279        resolution.record_vote_pour(100.0);
280        resolution.record_vote_pour(150.0);
281        resolution.record_vote_contre(200.0);
282        resolution.record_abstention(50.0);
283
284        assert_eq!(resolution.vote_count_pour, 2);
285        assert_eq!(resolution.vote_count_contre, 1);
286        assert_eq!(resolution.vote_count_abstention, 1);
287        assert_eq!(resolution.total_voting_power_pour, 250.0);
288        assert_eq!(resolution.total_voting_power_contre, 200.0);
289        assert_eq!(resolution.total_voting_power_abstention, 50.0);
290        assert_eq!(resolution.total_votes(), 4);
291    }
292
293    // ===== Absolute majority (Art. 3.88 §1) — abstentions excluded =====
294
295    #[test]
296    fn test_calculate_result_absolute_majority_adopted() {
297        let meeting_id = Uuid::new_v4();
298        let mut resolution = Resolution::new(
299            meeting_id,
300            "Test Resolution".to_string(),
301            "Description".to_string(),
302            ResolutionType::Ordinary,
303            MajorityType::Absolute,
304            Some(0),
305        )
306        .unwrap();
307
308        // Pour=300, Contre=150 → expressed=450, 300 > 225 → Adopted
309        resolution.record_vote_pour(300.0);
310        resolution.record_vote_contre(150.0);
311        resolution.record_abstention(50.0);
312
313        let result = resolution.calculate_result(1000.0);
314        assert_eq!(result, ResolutionStatus::Adopted);
315    }
316
317    #[test]
318    fn test_calculate_result_absolute_majority_rejected() {
319        let meeting_id = Uuid::new_v4();
320        let mut resolution = Resolution::new(
321            meeting_id,
322            "Test Resolution".to_string(),
323            "Description".to_string(),
324            ResolutionType::Ordinary,
325            MajorityType::Absolute,
326            Some(0),
327        )
328        .unwrap();
329
330        // Pour=150, Contre=300 → expressed=450, 150 < 225 → Rejected
331        resolution.record_vote_pour(150.0);
332        resolution.record_vote_contre(300.0);
333        resolution.record_abstention(50.0);
334
335        let result = resolution.calculate_result(1000.0);
336        assert_eq!(result, ResolutionStatus::Rejected);
337    }
338
339    #[test]
340    fn test_absolute_majority_abstentions_excluded() {
341        let meeting_id = Uuid::new_v4();
342        let mut resolution = Resolution::new(
343            meeting_id,
344            "Test Resolution".to_string(),
345            "Description".to_string(),
346            ResolutionType::Ordinary,
347            MajorityType::Absolute,
348            Some(0),
349        )
350        .unwrap();
351
352        // Pour=300, Contre=200, Abstention=500 → expressed=500, 300 > 250 → Adopted
353        // Abstentions are excluded: 300 is more than half of (300+200)
354        resolution.record_vote_pour(300.0);
355        resolution.record_vote_contre(200.0);
356        resolution.record_abstention(500.0);
357
358        let result = resolution.calculate_result(1000.0);
359        assert_eq!(result, ResolutionStatus::Adopted);
360    }
361
362    // ===== Two-thirds majority (Art. 3.88 §1, 1°) — abstentions excluded =====
363
364    #[test]
365    fn test_calculate_result_two_thirds_majority_adopted() {
366        let meeting_id = Uuid::new_v4();
367        let mut resolution = Resolution::new(
368            meeting_id,
369            "Test Resolution".to_string(),
370            "Description".to_string(),
371            ResolutionType::Extraordinary,
372            MajorityType::TwoThirds,
373            Some(0),
374        )
375        .unwrap();
376
377        // Pour=700, Contre=200 → expressed=900, 700/900 = 77.8% >= 66.7% → Adopted
378        resolution.record_vote_pour(700.0);
379        resolution.record_vote_contre(200.0);
380        resolution.record_abstention(100.0);
381
382        let result = resolution.calculate_result(1000.0);
383        assert_eq!(result, ResolutionStatus::Adopted);
384    }
385
386    #[test]
387    fn test_calculate_result_two_thirds_majority_rejected() {
388        let meeting_id = Uuid::new_v4();
389        let mut resolution = Resolution::new(
390            meeting_id,
391            "Test Resolution".to_string(),
392            "Description".to_string(),
393            ResolutionType::Extraordinary,
394            MajorityType::TwoThirds,
395            Some(0),
396        )
397        .unwrap();
398
399        // Pour=600, Contre=300 → expressed=900, 600/900 = 66.7% >= 66.7% → Adopted (boundary)
400        // Actually 600/900 = 0.6667 which is >= 2/3 = 0.6667 → Adopted
401        resolution.record_vote_pour(600.0);
402        resolution.record_vote_contre(300.0);
403        resolution.record_abstention(100.0);
404
405        let result = resolution.calculate_result(1000.0);
406        assert_eq!(result, ResolutionStatus::Adopted);
407    }
408
409    #[test]
410    fn test_two_thirds_majority_barely_rejected() {
411        let meeting_id = Uuid::new_v4();
412        let mut resolution = Resolution::new(
413            meeting_id,
414            "Test Resolution".to_string(),
415            "Description".to_string(),
416            ResolutionType::Extraordinary,
417            MajorityType::TwoThirds,
418            Some(0),
419        )
420        .unwrap();
421
422        // Pour=500, Contre=300 → expressed=800, 500/800 = 62.5% < 66.7% → Rejected
423        resolution.record_vote_pour(500.0);
424        resolution.record_vote_contre(300.0);
425        resolution.record_abstention(200.0);
426
427        let result = resolution.calculate_result(1000.0);
428        assert_eq!(result, ResolutionStatus::Rejected);
429    }
430
431    #[test]
432    fn test_two_thirds_abstentions_excluded() {
433        let meeting_id = Uuid::new_v4();
434        let mut resolution = Resolution::new(
435            meeting_id,
436            "Test Resolution".to_string(),
437            "Description".to_string(),
438            ResolutionType::Extraordinary,
439            MajorityType::TwoThirds,
440            Some(0),
441        )
442        .unwrap();
443
444        // Pour=400, Contre=100, Abstention=500 → expressed=500, 400/500 = 80% >= 66.7% → Adopted
445        resolution.record_vote_pour(400.0);
446        resolution.record_vote_contre(100.0);
447        resolution.record_abstention(500.0);
448
449        let result = resolution.calculate_result(1000.0);
450        assert_eq!(result, ResolutionStatus::Adopted);
451    }
452
453    // ===== Four-fifths majority (Art. 3.88 §1, 2°) — abstentions excluded =====
454
455    #[test]
456    fn test_calculate_result_four_fifths_majority_adopted() {
457        let meeting_id = Uuid::new_v4();
458        let mut resolution = Resolution::new(
459            meeting_id,
460            "Test Resolution".to_string(),
461            "Description".to_string(),
462            ResolutionType::Extraordinary,
463            MajorityType::FourFifths,
464            Some(0),
465        )
466        .unwrap();
467
468        // Pour=800, Contre=100 → expressed=900, 800/900 = 88.9% >= 80% → Adopted
469        resolution.record_vote_pour(800.0);
470        resolution.record_vote_contre(100.0);
471        resolution.record_abstention(100.0);
472
473        let result = resolution.calculate_result(1000.0);
474        assert_eq!(result, ResolutionStatus::Adopted);
475    }
476
477    #[test]
478    fn test_calculate_result_four_fifths_majority_rejected() {
479        let meeting_id = Uuid::new_v4();
480        let mut resolution = Resolution::new(
481            meeting_id,
482            "Test Resolution".to_string(),
483            "Description".to_string(),
484            ResolutionType::Extraordinary,
485            MajorityType::FourFifths,
486            Some(0),
487        )
488        .unwrap();
489
490        // Pour=700, Contre=200 → expressed=900, 700/900 = 77.8% < 80% → Rejected
491        resolution.record_vote_pour(700.0);
492        resolution.record_vote_contre(200.0);
493        resolution.record_abstention(100.0);
494
495        let result = resolution.calculate_result(1000.0);
496        assert_eq!(result, ResolutionStatus::Rejected);
497    }
498
499    #[test]
500    fn test_four_fifths_abstentions_excluded() {
501        let meeting_id = Uuid::new_v4();
502        let mut resolution = Resolution::new(
503            meeting_id,
504            "Test Resolution".to_string(),
505            "Description".to_string(),
506            ResolutionType::Extraordinary,
507            MajorityType::FourFifths,
508            Some(0),
509        )
510        .unwrap();
511
512        // Pour=400, Contre=50, Abstention=550 → expressed=450, 400/450 = 88.9% >= 80% → Adopted
513        resolution.record_vote_pour(400.0);
514        resolution.record_vote_contre(50.0);
515        resolution.record_abstention(550.0);
516
517        let result = resolution.calculate_result(1000.0);
518        assert_eq!(result, ResolutionStatus::Adopted);
519    }
520
521    // ===== Unanimity (Art. 3.88 §1, 3°) — requires ALL tantièmes =====
522
523    #[test]
524    fn test_calculate_result_unanimity_adopted() {
525        let meeting_id = Uuid::new_v4();
526        let mut resolution = Resolution::new(
527            meeting_id,
528            "Test Resolution".to_string(),
529            "Description".to_string(),
530            ResolutionType::Extraordinary,
531            MajorityType::Unanimity,
532            Some(0),
533        )
534        .unwrap();
535
536        // Pour=10000 == total_voting_power → Adopted
537        resolution.record_vote_pour(10000.0);
538
539        let result = resolution.calculate_result(10000.0);
540        assert_eq!(result, ResolutionStatus::Adopted);
541    }
542
543    #[test]
544    fn test_calculate_result_unanimity_rejected_missing_votes() {
545        let meeting_id = Uuid::new_v4();
546        let mut resolution = Resolution::new(
547            meeting_id,
548            "Test Resolution".to_string(),
549            "Description".to_string(),
550            ResolutionType::Extraordinary,
551            MajorityType::Unanimity,
552            Some(0),
553        )
554        .unwrap();
555
556        // Pour=9000 < total_voting_power=10000 (absent owners not accounted for) → Rejected
557        resolution.record_vote_pour(9000.0);
558
559        let result = resolution.calculate_result(10000.0);
560        assert_eq!(result, ResolutionStatus::Rejected);
561    }
562
563    #[test]
564    fn test_unanimity_requires_all_tantiemes_not_just_present() {
565        let meeting_id = Uuid::new_v4();
566        let mut resolution = Resolution::new(
567            meeting_id,
568            "Test Resolution".to_string(),
569            "Description".to_string(),
570            ResolutionType::Extraordinary,
571            MajorityType::Unanimity,
572            Some(0),
573        )
574        .unwrap();
575
576        // All present vote Pour but some owners are absent
577        // Pour=8000, Contre=0, Abstention=0, but total building = 10000 → Rejected
578        resolution.record_vote_pour(8000.0);
579
580        let result = resolution.calculate_result(10000.0);
581        assert_eq!(result, ResolutionStatus::Rejected);
582    }
583
584    #[test]
585    fn test_unanimity_rejected_with_abstention() {
586        let meeting_id = Uuid::new_v4();
587        let mut resolution = Resolution::new(
588            meeting_id,
589            "Test Resolution".to_string(),
590            "Description".to_string(),
591            ResolutionType::Extraordinary,
592            MajorityType::Unanimity,
593            Some(0),
594        )
595        .unwrap();
596
597        // Pour=9500, Abstention=500 → Pour != total → Rejected (abstentions count as NOT pour)
598        resolution.record_vote_pour(9500.0);
599        resolution.record_abstention(500.0);
600
601        let result = resolution.calculate_result(10000.0);
602        assert_eq!(result, ResolutionStatus::Rejected);
603    }
604
605    // ===== Close voting =====
606
607    #[test]
608    fn test_close_voting_success() {
609        let meeting_id = Uuid::new_v4();
610        let mut resolution = Resolution::new(
611            meeting_id,
612            "Test Resolution".to_string(),
613            "Description".to_string(),
614            ResolutionType::Ordinary,
615            MajorityType::Absolute,
616            Some(0),
617        )
618        .unwrap();
619
620        resolution.record_vote_pour(300.0);
621        resolution.record_vote_contre(150.0);
622
623        let result = resolution.close_voting(1000.0);
624        assert!(result.is_ok());
625        assert_eq!(resolution.status, ResolutionStatus::Adopted);
626        assert!(resolution.voted_at.is_some());
627    }
628
629    #[test]
630    fn test_close_voting_already_closed_fails() {
631        let meeting_id = Uuid::new_v4();
632        let mut resolution = Resolution::new(
633            meeting_id,
634            "Test Resolution".to_string(),
635            "Description".to_string(),
636            ResolutionType::Ordinary,
637            MajorityType::Absolute,
638            Some(0),
639        )
640        .unwrap();
641
642        resolution.record_vote_pour(300.0);
643        resolution.close_voting(1000.0).unwrap();
644
645        let result = resolution.close_voting(1000.0);
646        assert!(result.is_err());
647        assert_eq!(
648            result.unwrap_err(),
649            "Voting already closed for this resolution"
650        );
651    }
652
653    #[test]
654    fn test_percentages() {
655        let meeting_id = Uuid::new_v4();
656        let mut resolution = Resolution::new(
657            meeting_id,
658            "Test Resolution".to_string(),
659            "Description".to_string(),
660            ResolutionType::Ordinary,
661            MajorityType::Absolute,
662            Some(0),
663        )
664        .unwrap();
665
666        resolution.record_vote_pour(100.0);
667        resolution.record_vote_pour(100.0); // 2 votes pour
668        resolution.record_vote_contre(100.0); // 1 vote contre
669        resolution.record_abstention(100.0); // 1 abstention
670
671        assert_eq!(resolution.pour_percentage(), 50.0); // 2/4 = 50%
672        assert_eq!(resolution.contre_percentage(), 25.0); // 1/4 = 25%
673        assert_eq!(resolution.abstention_percentage(), 25.0); // 1/4 = 25%
674    }
675}