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)]
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
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum MajorityType {
17    Simple,         // Majorité simple: 50% + 1 des votes exprimés
18    Absolute,       // Majorité absolue: 50% + 1 de tous les votes possibles
19    Qualified(f64), // Majorité qualifiée: seuil personnalisé (ex: 0.67 pour 2/3)
20}
21
22/// Statut d'une résolution
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24#[serde(rename_all = "snake_case")]
25pub enum ResolutionStatus {
26    Pending,  // En attente de vote
27    Adopted,  // Adoptée
28    Rejected, // Rejetée
29}
30
31/// Résolution soumise au vote lors d'une assemblée générale
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct Resolution {
34    pub id: Uuid,
35    pub meeting_id: Uuid,
36    pub title: String,
37    pub description: String,
38    pub resolution_type: ResolutionType,
39    pub majority_required: MajorityType,
40    pub vote_count_pour: i32,
41    pub vote_count_contre: i32,
42    pub vote_count_abstention: i32,
43    pub total_voting_power_pour: f64,
44    pub total_voting_power_contre: f64,
45    pub total_voting_power_abstention: f64,
46    pub status: ResolutionStatus,
47    pub created_at: DateTime<Utc>,
48    pub voted_at: Option<DateTime<Utc>>,
49}
50
51impl Resolution {
52    /// Crée une nouvelle résolution
53    pub fn new(
54        meeting_id: Uuid,
55        title: String,
56        description: String,
57        resolution_type: ResolutionType,
58        majority_required: MajorityType,
59    ) -> Result<Self, String> {
60        if title.is_empty() {
61            return Err("Resolution title cannot be empty".to_string());
62        }
63        if description.is_empty() {
64            return Err("Resolution description cannot be empty".to_string());
65        }
66
67        // Validate qualified majority threshold
68        if let MajorityType::Qualified(threshold) = &majority_required {
69            if *threshold <= 0.0 || *threshold > 1.0 {
70                return Err("Qualified majority threshold must be between 0 and 1".to_string());
71            }
72        }
73
74        let now = Utc::now();
75        Ok(Self {
76            id: Uuid::new_v4(),
77            meeting_id,
78            title,
79            description,
80            resolution_type,
81            majority_required,
82            vote_count_pour: 0,
83            vote_count_contre: 0,
84            vote_count_abstention: 0,
85            total_voting_power_pour: 0.0,
86            total_voting_power_contre: 0.0,
87            total_voting_power_abstention: 0.0,
88            status: ResolutionStatus::Pending,
89            created_at: now,
90            voted_at: None,
91        })
92    }
93
94    /// Enregistre un vote "Pour" et met à jour les compteurs
95    pub fn record_vote_pour(&mut self, voting_power: f64) {
96        self.vote_count_pour += 1;
97        self.total_voting_power_pour += voting_power;
98    }
99
100    /// Enregistre un vote "Contre" et met à jour les compteurs
101    pub fn record_vote_contre(&mut self, voting_power: f64) {
102        self.vote_count_contre += 1;
103        self.total_voting_power_contre += voting_power;
104    }
105
106    /// Enregistre une abstention et met à jour les compteurs
107    pub fn record_abstention(&mut self, voting_power: f64) {
108        self.vote_count_abstention += 1;
109        self.total_voting_power_abstention += voting_power;
110    }
111
112    /// Calcule le résultat du vote en fonction du type de majorité
113    pub fn calculate_result(&self, total_voting_power: f64) -> ResolutionStatus {
114        match &self.majority_required {
115            MajorityType::Simple => {
116                // Majorité simple: plus de voix "Pour" que "Contre" + "Abstention"
117                if self.total_voting_power_pour
118                    > self.total_voting_power_contre + self.total_voting_power_abstention
119                {
120                    ResolutionStatus::Adopted
121                } else {
122                    ResolutionStatus::Rejected
123                }
124            }
125            MajorityType::Absolute => {
126                // Majorité absolue: plus de 50% du pouvoir de vote total
127                if self.total_voting_power_pour > total_voting_power / 2.0 {
128                    ResolutionStatus::Adopted
129                } else {
130                    ResolutionStatus::Rejected
131                }
132            }
133            MajorityType::Qualified(threshold) => {
134                // Majorité qualifiée: ratio >= seuil défini
135                let pour_ratio = if total_voting_power > 0.0 {
136                    self.total_voting_power_pour / total_voting_power
137                } else {
138                    0.0
139                };
140                if pour_ratio >= *threshold {
141                    ResolutionStatus::Adopted
142                } else {
143                    ResolutionStatus::Rejected
144                }
145            }
146        }
147    }
148
149    /// Clôture le vote et finalise le statut
150    pub fn close_voting(&mut self, total_voting_power: f64) -> Result<(), String> {
151        if self.status != ResolutionStatus::Pending {
152            return Err("Voting already closed for this resolution".to_string());
153        }
154
155        self.status = self.calculate_result(total_voting_power);
156        self.voted_at = Some(Utc::now());
157        Ok(())
158    }
159
160    /// Retourne le nombre total de votes exprimés
161    pub fn total_votes(&self) -> i32 {
162        self.vote_count_pour + self.vote_count_contre + self.vote_count_abstention
163    }
164
165    /// Retourne le pourcentage de votes "Pour"
166    pub fn pour_percentage(&self) -> f64 {
167        let total = self.total_votes();
168        if total > 0 {
169            (self.vote_count_pour as f64 / total as f64) * 100.0
170        } else {
171            0.0
172        }
173    }
174
175    /// Retourne le pourcentage de votes "Contre"
176    pub fn contre_percentage(&self) -> f64 {
177        let total = self.total_votes();
178        if total > 0 {
179            (self.vote_count_contre as f64 / total as f64) * 100.0
180        } else {
181            0.0
182        }
183    }
184
185    /// Retourne le pourcentage d'abstentions
186    pub fn abstention_percentage(&self) -> f64 {
187        let total = self.total_votes();
188        if total > 0 {
189            (self.vote_count_abstention as f64 / total as f64) * 100.0
190        } else {
191            0.0
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_create_resolution_success() {
202        let meeting_id = Uuid::new_v4();
203        let resolution = Resolution::new(
204            meeting_id,
205            "Approbation des comptes 2024".to_string(),
206            "Vote pour approuver les comptes annuels de l'exercice 2024".to_string(),
207            ResolutionType::Ordinary,
208            MajorityType::Simple,
209        );
210
211        assert!(resolution.is_ok());
212        let resolution = resolution.unwrap();
213        assert_eq!(resolution.meeting_id, meeting_id);
214        assert_eq!(resolution.status, ResolutionStatus::Pending);
215        assert_eq!(resolution.total_votes(), 0);
216    }
217
218    #[test]
219    fn test_create_resolution_empty_title_fails() {
220        let meeting_id = Uuid::new_v4();
221        let resolution = Resolution::new(
222            meeting_id,
223            "".to_string(),
224            "Description".to_string(),
225            ResolutionType::Ordinary,
226            MajorityType::Simple,
227        );
228
229        assert!(resolution.is_err());
230        assert_eq!(resolution.unwrap_err(), "Resolution title cannot be empty");
231    }
232
233    #[test]
234    fn test_create_resolution_invalid_qualified_threshold_fails() {
235        let meeting_id = Uuid::new_v4();
236        let resolution = Resolution::new(
237            meeting_id,
238            "Test".to_string(),
239            "Description".to_string(),
240            ResolutionType::Extraordinary,
241            MajorityType::Qualified(1.5), // Invalid: > 1.0
242        );
243
244        assert!(resolution.is_err());
245        assert!(resolution
246            .unwrap_err()
247            .contains("threshold must be between 0 and 1"));
248    }
249
250    #[test]
251    fn test_record_votes() {
252        let meeting_id = Uuid::new_v4();
253        let mut resolution = Resolution::new(
254            meeting_id,
255            "Test Resolution".to_string(),
256            "Description".to_string(),
257            ResolutionType::Ordinary,
258            MajorityType::Simple,
259        )
260        .unwrap();
261
262        resolution.record_vote_pour(100.0);
263        resolution.record_vote_pour(150.0);
264        resolution.record_vote_contre(200.0);
265        resolution.record_abstention(50.0);
266
267        assert_eq!(resolution.vote_count_pour, 2);
268        assert_eq!(resolution.vote_count_contre, 1);
269        assert_eq!(resolution.vote_count_abstention, 1);
270        assert_eq!(resolution.total_voting_power_pour, 250.0);
271        assert_eq!(resolution.total_voting_power_contre, 200.0);
272        assert_eq!(resolution.total_voting_power_abstention, 50.0);
273        assert_eq!(resolution.total_votes(), 4);
274    }
275
276    #[test]
277    fn test_calculate_result_simple_majority_adopted() {
278        let meeting_id = Uuid::new_v4();
279        let mut resolution = Resolution::new(
280            meeting_id,
281            "Test Resolution".to_string(),
282            "Description".to_string(),
283            ResolutionType::Ordinary,
284            MajorityType::Simple,
285        )
286        .unwrap();
287
288        resolution.record_vote_pour(300.0); // Pour > Contre + Abstention
289        resolution.record_vote_contre(150.0);
290        resolution.record_abstention(50.0);
291
292        let result = resolution.calculate_result(1000.0);
293        assert_eq!(result, ResolutionStatus::Adopted);
294    }
295
296    #[test]
297    fn test_calculate_result_simple_majority_rejected() {
298        let meeting_id = Uuid::new_v4();
299        let mut resolution = Resolution::new(
300            meeting_id,
301            "Test Resolution".to_string(),
302            "Description".to_string(),
303            ResolutionType::Ordinary,
304            MajorityType::Simple,
305        )
306        .unwrap();
307
308        resolution.record_vote_pour(150.0);
309        resolution.record_vote_contre(300.0); // Contre + Abstention > Pour
310        resolution.record_abstention(50.0);
311
312        let result = resolution.calculate_result(1000.0);
313        assert_eq!(result, ResolutionStatus::Rejected);
314    }
315
316    #[test]
317    fn test_calculate_result_absolute_majority_adopted() {
318        let meeting_id = Uuid::new_v4();
319        let mut resolution = Resolution::new(
320            meeting_id,
321            "Test Resolution".to_string(),
322            "Description".to_string(),
323            ResolutionType::Ordinary,
324            MajorityType::Absolute,
325        )
326        .unwrap();
327
328        resolution.record_vote_pour(600.0); // > 50% of 1000
329        resolution.record_vote_contre(200.0);
330        resolution.record_abstention(100.0);
331
332        let result = resolution.calculate_result(1000.0);
333        assert_eq!(result, ResolutionStatus::Adopted);
334    }
335
336    #[test]
337    fn test_calculate_result_absolute_majority_rejected() {
338        let meeting_id = Uuid::new_v4();
339        let mut resolution = Resolution::new(
340            meeting_id,
341            "Test Resolution".to_string(),
342            "Description".to_string(),
343            ResolutionType::Ordinary,
344            MajorityType::Absolute,
345        )
346        .unwrap();
347
348        resolution.record_vote_pour(400.0); // < 50% of 1000
349        resolution.record_vote_contre(300.0);
350        resolution.record_abstention(100.0);
351
352        let result = resolution.calculate_result(1000.0);
353        assert_eq!(result, ResolutionStatus::Rejected);
354    }
355
356    #[test]
357    fn test_calculate_result_qualified_majority_adopted() {
358        let meeting_id = Uuid::new_v4();
359        let mut resolution = Resolution::new(
360            meeting_id,
361            "Test Resolution".to_string(),
362            "Description".to_string(),
363            ResolutionType::Extraordinary,
364            MajorityType::Qualified(0.67), // 2/3 required
365        )
366        .unwrap();
367
368        resolution.record_vote_pour(700.0); // 70% > 67%
369        resolution.record_vote_contre(200.0);
370        resolution.record_abstention(100.0);
371
372        let result = resolution.calculate_result(1000.0);
373        assert_eq!(result, ResolutionStatus::Adopted);
374    }
375
376    #[test]
377    fn test_calculate_result_qualified_majority_rejected() {
378        let meeting_id = Uuid::new_v4();
379        let mut resolution = Resolution::new(
380            meeting_id,
381            "Test Resolution".to_string(),
382            "Description".to_string(),
383            ResolutionType::Extraordinary,
384            MajorityType::Qualified(0.67), // 2/3 required
385        )
386        .unwrap();
387
388        resolution.record_vote_pour(600.0); // 60% < 67%
389        resolution.record_vote_contre(300.0);
390        resolution.record_abstention(100.0);
391
392        let result = resolution.calculate_result(1000.0);
393        assert_eq!(result, ResolutionStatus::Rejected);
394    }
395
396    #[test]
397    fn test_close_voting_success() {
398        let meeting_id = Uuid::new_v4();
399        let mut resolution = Resolution::new(
400            meeting_id,
401            "Test Resolution".to_string(),
402            "Description".to_string(),
403            ResolutionType::Ordinary,
404            MajorityType::Simple,
405        )
406        .unwrap();
407
408        resolution.record_vote_pour(300.0);
409        resolution.record_vote_contre(150.0);
410
411        let result = resolution.close_voting(1000.0);
412        assert!(result.is_ok());
413        assert_eq!(resolution.status, ResolutionStatus::Adopted);
414        assert!(resolution.voted_at.is_some());
415    }
416
417    #[test]
418    fn test_close_voting_already_closed_fails() {
419        let meeting_id = Uuid::new_v4();
420        let mut resolution = Resolution::new(
421            meeting_id,
422            "Test Resolution".to_string(),
423            "Description".to_string(),
424            ResolutionType::Ordinary,
425            MajorityType::Simple,
426        )
427        .unwrap();
428
429        resolution.record_vote_pour(300.0);
430        resolution.close_voting(1000.0).unwrap();
431
432        let result = resolution.close_voting(1000.0);
433        assert!(result.is_err());
434        assert_eq!(
435            result.unwrap_err(),
436            "Voting already closed for this resolution"
437        );
438    }
439
440    #[test]
441    fn test_percentages() {
442        let meeting_id = Uuid::new_v4();
443        let mut resolution = Resolution::new(
444            meeting_id,
445            "Test Resolution".to_string(),
446            "Description".to_string(),
447            ResolutionType::Ordinary,
448            MajorityType::Simple,
449        )
450        .unwrap();
451
452        resolution.record_vote_pour(100.0);
453        resolution.record_vote_pour(100.0); // 2 votes pour
454        resolution.record_vote_contre(100.0); // 1 vote contre
455        resolution.record_abstention(100.0); // 1 abstention
456
457        assert_eq!(resolution.pour_percentage(), 50.0); // 2/4 = 50%
458        assert_eq!(resolution.contre_percentage(), 25.0); // 1/4 = 25%
459        assert_eq!(resolution.abstention_percentage(), 25.0); // 1/4 = 25%
460    }
461}