koprogo_api/domain/entities/
vote.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Choix de vote d'un copropriétaire
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
7#[serde(rename_all = "snake_case")]
8pub enum VoteChoice {
9    Pour,       // Vote en faveur (For)
10    Contre,     // Vote contre (Against)
11    Abstention, // Abstention
12}
13
14/// Vote d'un propriétaire sur une résolution
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
16pub struct Vote {
17    pub id: Uuid,
18    pub resolution_id: Uuid,
19    pub owner_id: Uuid,
20    pub unit_id: Uuid,
21    pub vote_choice: VoteChoice,
22    pub voting_power: f64,            // Tantièmes/millièmes du lot
23    pub proxy_owner_id: Option<Uuid>, // ID du mandataire si vote par procuration
24    pub voted_at: DateTime<Utc>,
25}
26
27impl Vote {
28    /// Crée un nouveau vote
29    pub fn new(
30        resolution_id: Uuid,
31        owner_id: Uuid,
32        unit_id: Uuid,
33        vote_choice: VoteChoice,
34        voting_power: f64,
35        proxy_owner_id: Option<Uuid>,
36    ) -> Result<Self, String> {
37        // Validation du pouvoir de vote
38        if voting_power <= 0.0 {
39            return Err("Voting power must be positive".to_string());
40        }
41        if voting_power > 1000.0 {
42            return Err("Voting power exceeds maximum (1000 millièmes)".to_string());
43        }
44
45        // Validation de la procuration
46        if let Some(proxy_id) = proxy_owner_id {
47            if proxy_id == owner_id {
48                return Err("Owner cannot be their own proxy".to_string());
49            }
50        }
51
52        Ok(Self {
53            id: Uuid::new_v4(),
54            resolution_id,
55            owner_id,
56            unit_id,
57            vote_choice,
58            voting_power,
59            proxy_owner_id,
60            voted_at: Utc::now(),
61        })
62    }
63
64    /// Vérifie si le vote est exprimé par procuration
65    pub fn is_proxy_vote(&self) -> bool {
66        self.proxy_owner_id.is_some()
67    }
68
69    /// Retourne l'ID du votant effectif (propriétaire ou mandataire)
70    pub fn effective_voter_id(&self) -> Uuid {
71        self.proxy_owner_id.unwrap_or(self.owner_id)
72    }
73
74    /// Modifie le choix de vote (seulement si pas encore enregistré)
75    pub fn change_vote(&mut self, new_choice: VoteChoice) -> Result<(), String> {
76        // En pratique, cette méthode ne serait appelée que pendant une fenêtre de temps limitée
77        // Ici on autorise le changement, mais dans l'application on pourrait ajouter une validation
78        // basée sur le timing (ex: vote modifiable uniquement dans les 5 minutes)
79        self.vote_choice = new_choice;
80        self.voted_at = Utc::now();
81        Ok(())
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_create_vote_success() {
91        let resolution_id = Uuid::new_v4();
92        let owner_id = Uuid::new_v4();
93        let unit_id = Uuid::new_v4();
94
95        let vote = Vote::new(
96            resolution_id,
97            owner_id,
98            unit_id,
99            VoteChoice::Pour,
100            150.0, // 150 millièmes
101            None,
102        );
103
104        assert!(vote.is_ok());
105        let vote = vote.unwrap();
106        assert_eq!(vote.resolution_id, resolution_id);
107        assert_eq!(vote.owner_id, owner_id);
108        assert_eq!(vote.unit_id, unit_id);
109        assert_eq!(vote.vote_choice, VoteChoice::Pour);
110        assert_eq!(vote.voting_power, 150.0);
111        assert!(!vote.is_proxy_vote());
112        assert_eq!(vote.effective_voter_id(), owner_id);
113    }
114
115    #[test]
116    fn test_create_vote_with_proxy() {
117        let resolution_id = Uuid::new_v4();
118        let owner_id = Uuid::new_v4();
119        let unit_id = Uuid::new_v4();
120        let proxy_id = Uuid::new_v4();
121
122        let vote = Vote::new(
123            resolution_id,
124            owner_id,
125            unit_id,
126            VoteChoice::Contre,
127            200.0,
128            Some(proxy_id),
129        );
130
131        assert!(vote.is_ok());
132        let vote = vote.unwrap();
133        assert!(vote.is_proxy_vote());
134        assert_eq!(vote.effective_voter_id(), proxy_id);
135        assert_eq!(vote.proxy_owner_id, Some(proxy_id));
136    }
137
138    #[test]
139    fn test_create_vote_zero_voting_power_fails() {
140        let resolution_id = Uuid::new_v4();
141        let owner_id = Uuid::new_v4();
142        let unit_id = Uuid::new_v4();
143
144        let vote = Vote::new(
145            resolution_id,
146            owner_id,
147            unit_id,
148            VoteChoice::Pour,
149            0.0,
150            None,
151        );
152
153        assert!(vote.is_err());
154        assert_eq!(vote.unwrap_err(), "Voting power must be positive");
155    }
156
157    #[test]
158    fn test_create_vote_negative_voting_power_fails() {
159        let resolution_id = Uuid::new_v4();
160        let owner_id = Uuid::new_v4();
161        let unit_id = Uuid::new_v4();
162
163        let vote = Vote::new(
164            resolution_id,
165            owner_id,
166            unit_id,
167            VoteChoice::Pour,
168            -50.0,
169            None,
170        );
171
172        assert!(vote.is_err());
173        assert_eq!(vote.unwrap_err(), "Voting power must be positive");
174    }
175
176    #[test]
177    fn test_create_vote_excessive_voting_power_fails() {
178        let resolution_id = Uuid::new_v4();
179        let owner_id = Uuid::new_v4();
180        let unit_id = Uuid::new_v4();
181
182        let vote = Vote::new(
183            resolution_id,
184            owner_id,
185            unit_id,
186            VoteChoice::Pour,
187            1500.0, // Exceeds max
188            None,
189        );
190
191        assert!(vote.is_err());
192        assert!(vote.unwrap_err().contains("exceeds maximum"));
193    }
194
195    #[test]
196    fn test_create_vote_self_proxy_fails() {
197        let resolution_id = Uuid::new_v4();
198        let owner_id = Uuid::new_v4();
199        let unit_id = Uuid::new_v4();
200
201        let vote = Vote::new(
202            resolution_id,
203            owner_id,
204            unit_id,
205            VoteChoice::Pour,
206            150.0,
207            Some(owner_id), // Self as proxy
208        );
209
210        assert!(vote.is_err());
211        assert_eq!(vote.unwrap_err(), "Owner cannot be their own proxy");
212    }
213
214    #[test]
215    fn test_change_vote() {
216        let resolution_id = Uuid::new_v4();
217        let owner_id = Uuid::new_v4();
218        let unit_id = Uuid::new_v4();
219
220        let mut vote = Vote::new(
221            resolution_id,
222            owner_id,
223            unit_id,
224            VoteChoice::Pour,
225            150.0,
226            None,
227        )
228        .unwrap();
229
230        assert_eq!(vote.vote_choice, VoteChoice::Pour);
231
232        let result = vote.change_vote(VoteChoice::Contre);
233        assert!(result.is_ok());
234        assert_eq!(vote.vote_choice, VoteChoice::Contre);
235    }
236
237    #[test]
238    fn test_vote_choice_serialization() {
239        // Test serialization of VoteChoice enum
240        let pour = VoteChoice::Pour;
241        let contre = VoteChoice::Contre;
242        let abstention = VoteChoice::Abstention;
243
244        let json_pour = serde_json::to_string(&pour).unwrap();
245        let json_contre = serde_json::to_string(&contre).unwrap();
246        let json_abstention = serde_json::to_string(&abstention).unwrap();
247
248        assert_eq!(json_pour, "\"pour\"");
249        assert_eq!(json_contre, "\"contre\"");
250        assert_eq!(json_abstention, "\"abstention\"");
251    }
252
253    #[test]
254    fn test_vote_choice_deserialization() {
255        // Test deserialization of VoteChoice enum
256        let pour: VoteChoice = serde_json::from_str("\"pour\"").unwrap();
257        let contre: VoteChoice = serde_json::from_str("\"contre\"").unwrap();
258        let abstention: VoteChoice = serde_json::from_str("\"abstention\"").unwrap();
259
260        assert_eq!(pour, VoteChoice::Pour);
261        assert_eq!(contre, VoteChoice::Contre);
262        assert_eq!(abstention, VoteChoice::Abstention);
263    }
264}
265
266/// Belgian law Art. 3.87 §6 CC proxy validation error
267#[derive(Debug, Clone, PartialEq)]
268pub enum ProxyValidationError {
269    /// Proxy holder already has 3 mandates for this meeting
270    TooManyMandates { current: usize, max: usize },
271    /// Proxy holder would represent more than 10% of total quotas
272    ExceedsQuotaThreshold {
273        current_pct: f64,
274        max_pct: f64,
275        total_quotas: i32,
276    },
277}
278
279impl std::fmt::Display for ProxyValidationError {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        match self {
282            Self::TooManyMandates { current, max } => {
283                write!(
284                    f,
285                    "Le mandataire détient déjà {} mandats (maximum: {} selon Art. 3.87 §6 CC)",
286                    current, max
287                )
288            }
289            Self::ExceedsQuotaThreshold {
290                current_pct,
291                max_pct,
292                total_quotas: _,
293            } => {
294                write!(f, "Le mandataire représenterait {:.1}% des quotités (maximum: {:.0}% selon Art. 3.87 §6 CC)", current_pct, max_pct)
295            }
296        }
297    }
298}
299
300/// Validate proxy mandate constraints for Belgian AG (Art. 3.87 §6 CC)
301///
302/// Rules:
303/// 1. A proxy holder cannot hold more than 3 mandates per AG
304/// 2. A proxy holder cannot represent more than 10% of total building quotas
305///
306/// Returns `Ok(())` if valid, or `Err(ProxyValidationError)` if constraint violated.
307pub fn validate_proxy_mandate(
308    existing_mandate_count: usize,
309    existing_delegated_quotas: i32,
310    new_voting_power: i32,
311    total_building_quotas: i32,
312    max_mandates: usize, // typically 3 per Belgian law
313    max_quota_pct: f64,  // typically 0.10 (10%) per Belgian law
314) -> Result<(), ProxyValidationError> {
315    // Check mandate count limit
316    if existing_mandate_count >= max_mandates {
317        return Err(ProxyValidationError::TooManyMandates {
318            current: existing_mandate_count,
319            max: max_mandates,
320        });
321    }
322
323    // Check quota percentage limit
324    if total_building_quotas > 0 {
325        let new_total_quotas = existing_delegated_quotas + new_voting_power;
326        let new_pct = new_total_quotas as f64 / total_building_quotas as f64;
327        if new_pct > max_quota_pct {
328            return Err(ProxyValidationError::ExceedsQuotaThreshold {
329                current_pct: new_pct * 100.0,
330                max_pct: max_quota_pct * 100.0,
331                total_quotas: total_building_quotas,
332            });
333        }
334    }
335
336    Ok(())
337}
338
339#[cfg(test)]
340mod proxy_validation_tests {
341    use super::*;
342
343    #[test]
344    fn test_proxy_mandate_count_limit() {
345        // 3 existing mandates → should fail
346        let result = validate_proxy_mandate(3, 150, 50, 1000, 3, 0.10);
347        assert!(result.is_err());
348        assert!(matches!(
349            result,
350            Err(ProxyValidationError::TooManyMandates { current: 3, max: 3 })
351        ));
352    }
353
354    #[test]
355    fn test_proxy_mandate_count_ok() {
356        // 2 existing mandates → should succeed
357        let result = validate_proxy_mandate(2, 40, 50, 1000, 3, 0.10);
358        assert!(result.is_ok());
359    }
360
361    #[test]
362    fn test_proxy_quota_threshold_exceeded() {
363        // Existing: 80 quotas (8%), adding 50 → 130/1000 = 13% > 10%
364        let result = validate_proxy_mandate(1, 80, 50, 1000, 3, 0.10);
365        assert!(result.is_err());
366        assert!(matches!(
367            result,
368            Err(ProxyValidationError::ExceedsQuotaThreshold { .. })
369        ));
370    }
371
372    #[test]
373    fn test_proxy_quota_threshold_ok() {
374        // Existing: 50 quotas (5%), adding 40 → 90/1000 = 9% < 10%
375        let result = validate_proxy_mandate(1, 50, 40, 1000, 3, 0.10);
376        assert!(result.is_ok());
377    }
378
379    #[test]
380    fn test_proxy_exactly_at_limit() {
381        // Exactly at 10% → should succeed (boundary condition, not exceeded)
382        let result = validate_proxy_mandate(2, 60, 40, 1000, 3, 0.10);
383        assert!(result.is_ok()); // 100/1000 = 10.0% == 10.0% (not strictly greater)
384    }
385}