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