Skip to main content

koprogo_api/domain/copropriete/
vote.rs

1use super::meeting::MeetingMode;
2use chrono::{DateTime, Utc};
3use rust_decimal::Decimal;
4use rust_decimal_macros::dec;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Voting-power upper bound (Art. 3.87 §7 CC envelope, 10000 dix-millièmes).
9const MAX_VOTING_POWER: Decimal = dec!(10000);
10
11/// Choix de vote d'un copropriétaire
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum VoteChoice {
15    Pour,       // Vote en faveur (For)
16    Contre,     // Vote contre (Against)
17    Abstention, // Abstention
18}
19
20/// Méthode d'authentification du votant (Story 4.2, Art. 3.87 §1er, §4 CC,
21/// #48). `Presence` couvre la signature de la feuille de présence en AG
22/// physique ; `Proxy` une procuration papier en bonne et due forme ;
23/// `Itsme`/`Eid` l'authentification forte requise pour un vote à distance
24/// (Art. 3.87 §1er : « à distance au moyen d'une communication
25/// électronique » suppose de savoir QUI a voté).
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
27#[serde(rename_all = "snake_case")]
28pub enum VoteAuthMethod {
29    Presence,
30    Proxy,
31    Itsme,
32    Eid,
33}
34
35impl VoteAuthMethod {
36    pub fn from_db_string(s: &str) -> Result<Self, String> {
37        match s {
38            "presence" => Ok(Self::Presence),
39            "proxy" => Ok(Self::Proxy),
40            "itsme" => Ok(Self::Itsme),
41            "eid" => Ok(Self::Eid),
42            other => Err(format!("Unknown vote auth method: {other}")),
43        }
44    }
45
46    pub fn to_db_str(&self) -> &'static str {
47        match self {
48            Self::Presence => "presence",
49            Self::Proxy => "proxy",
50            Self::Itsme => "itsme",
51            Self::Eid => "eid",
52        }
53    }
54
55    /// Authentification qui engage réellement le votant, indépendamment de
56    /// toute procuration (itsme/eID). `Presence` ne l'est pas : c'est une
57    /// simple déclaration, vérifiable seulement par la présence physique
58    /// qu'un vote à distance ne permet justement pas de constater.
59    pub fn is_strong(&self) -> bool {
60        matches!(self, Self::Itsme | Self::Eid)
61    }
62}
63
64/// Story 4.2 — refus opposé par `assert_vote_auth_sufficient`. Mappé vers
65/// AppError (`application/error.rs`) : `Missing` en 422, `Insufficient` en
66/// 403.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
68pub enum VoteAuthError {
69    /// Un vote sans méthode déclarée n'est pas exploitable en cas de
70    /// contestation : on ne sait même pas comment le votant a été identifié.
71    #[error("La méthode d'authentification du vote est obligatoire")]
72    Missing,
73
74    /// Le mode de l'AG (remote/hybrid, Art. 3.87 §1er CC) exige une méthode
75    /// qui engage le votant : itsme/eID, ou une procuration en bonne et due
76    /// forme (Art. 3.87 §4). `presence` ne fait qu'affirmer une présence que
77    /// la modalité distancielle ne permet justement pas de vérifier.
78    #[error(
79        "Authentification insuffisante pour un vote en mode {mode:?} : {auth_method:?} \
80         n'engage pas le votant (Art. 3.87 §1er, §4 CC)"
81    )]
82    Insufficient {
83        mode: MeetingMode,
84        auth_method: VoteAuthMethod,
85    },
86}
87
88/// Story 4.2 — Art. 3.87 §1er, §4 CC : valide `auth_method` contre la
89/// modalité de l'AG avant d'autoriser un vote.
90///
91/// `is_proxy_vote` distingue un `auth_method: Proxy` réel (le bulletin porte
92/// effectivement un `proxy_owner_id`, dont les conditions — plafond de trois
93/// procurations — se vérifient par ailleurs) d'une simple étiquette : se
94/// déclarer mandataire sans l'être ne peut pas suffire à voter à distance.
95///
96/// En AG physique (`InPerson`), aucune méthode n'est jugée insuffisante :
97/// c'est la présence elle-même qui authentifie.
98pub fn assert_vote_auth_sufficient(
99    mode: MeetingMode,
100    auth_method: Option<VoteAuthMethod>,
101    is_proxy_vote: bool,
102) -> Result<VoteAuthMethod, VoteAuthError> {
103    let auth_method = auth_method.ok_or(VoteAuthError::Missing)?;
104
105    if !mode.requires_strong_vote_auth() {
106        return Ok(auth_method);
107    }
108
109    let suffisant = match auth_method {
110        VoteAuthMethod::Proxy => is_proxy_vote,
111        other => other.is_strong(),
112    };
113
114    if suffisant {
115        Ok(auth_method)
116    } else {
117        Err(VoteAuthError::Insufficient { mode, auth_method })
118    }
119}
120
121/// Vote d'un propriétaire sur une résolution
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
123pub struct Vote {
124    pub id: Uuid,
125    pub resolution_id: Uuid,
126    pub owner_id: Uuid,
127    pub unit_id: Uuid,
128    pub vote_choice: VoteChoice,
129    pub voting_power: Decimal, // Tantièmes/millièmes du lot (Decimal exact — ADR-0008)
130    pub proxy_owner_id: Option<Uuid>, // ID du mandataire si vote par procuration
131    pub voted_at: DateTime<Utc>,
132    /// Story 4.2 — comment le votant a été authentifié (#48). Par défaut
133    /// `Presence` (`Vote::new`) : seul `cast_vote`, une fois `auth_method`
134    /// validé contre la modalité de l'AG, appelle `new_with_auth_method`.
135    pub auth_method: VoteAuthMethod,
136}
137
138impl Vote {
139    /// Crée un nouveau vote
140    pub fn new(
141        resolution_id: Uuid,
142        owner_id: Uuid,
143        unit_id: Uuid,
144        vote_choice: VoteChoice,
145        voting_power: Decimal,
146        proxy_owner_id: Option<Uuid>,
147    ) -> Result<Self, String> {
148        // Validation du pouvoir de vote
149        if voting_power <= Decimal::ZERO {
150            return Err("Voting power must be positive".to_string());
151        }
152        if voting_power > MAX_VOTING_POWER {
153            return Err("Voting power exceeds maximum (10000 dix-millièmes)".to_string());
154        }
155
156        // Validation de la procuration
157        if let Some(proxy_id) = proxy_owner_id {
158            if proxy_id == owner_id {
159                return Err("Owner cannot be their own proxy".to_string());
160            }
161        }
162
163        Ok(Self {
164            id: Uuid::new_v4(),
165            resolution_id,
166            owner_id,
167            unit_id,
168            vote_choice,
169            voting_power,
170            proxy_owner_id,
171            voted_at: Utc::now(),
172            auth_method: VoteAuthMethod::Presence,
173        })
174    }
175
176    /// Story 4.2 — variante de `new()` qui pose explicitement `auth_method`.
177    ///
178    /// Employée par le cas d'usage `cast_vote`, une fois la méthode validée
179    /// contre la modalité de l'AG (`assert_vote_auth_sufficient`). Les autres
180    /// appelants (tests de plafonnement, procurations, conflits d'intérêts)
181    /// ne portent pas cette dimension et gardent `new()`, qui vaut
182    /// `Presence` par défaut.
183    #[allow(clippy::too_many_arguments)]
184    pub fn new_with_auth_method(
185        resolution_id: Uuid,
186        owner_id: Uuid,
187        unit_id: Uuid,
188        vote_choice: VoteChoice,
189        voting_power: Decimal,
190        proxy_owner_id: Option<Uuid>,
191        auth_method: VoteAuthMethod,
192    ) -> Result<Self, String> {
193        let mut vote = Self::new(
194            resolution_id,
195            owner_id,
196            unit_id,
197            vote_choice,
198            voting_power,
199            proxy_owner_id,
200        )?;
201        vote.auth_method = auth_method;
202        Ok(vote)
203    }
204
205    /// Vérifie si le vote est exprimé par procuration
206    pub fn is_proxy_vote(&self) -> bool {
207        self.proxy_owner_id.is_some()
208    }
209
210    /// Retourne l'ID du votant effectif (propriétaire ou mandataire)
211    pub fn effective_voter_id(&self) -> Uuid {
212        self.proxy_owner_id.unwrap_or(self.owner_id)
213    }
214
215    /// Modifie le choix de vote (seulement si pas encore enregistré)
216    pub fn change_vote(&mut self, new_choice: VoteChoice) -> Result<(), String> {
217        // En pratique, cette méthode ne serait appelée que pendant une fenêtre de temps limitée
218        // Ici on autorise le changement, mais dans l'application on pourrait ajouter une validation
219        // basée sur le timing (ex: vote modifiable uniquement dans les 5 minutes)
220        self.vote_choice = new_choice;
221        self.voted_at = Utc::now();
222        Ok(())
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_create_vote_success() {
232        let resolution_id = Uuid::new_v4();
233        let owner_id = Uuid::new_v4();
234        let unit_id = Uuid::new_v4();
235
236        let vote = Vote::new(
237            resolution_id,
238            owner_id,
239            unit_id,
240            VoteChoice::Pour,
241            dec!(150), // 150 millièmes
242            None,
243        );
244
245        assert!(vote.is_ok());
246        let vote = vote.unwrap();
247        assert_eq!(vote.resolution_id, resolution_id);
248        assert_eq!(vote.owner_id, owner_id);
249        assert_eq!(vote.unit_id, unit_id);
250        assert_eq!(vote.vote_choice, VoteChoice::Pour);
251        assert_eq!(vote.voting_power, dec!(150));
252        assert!(!vote.is_proxy_vote());
253        assert_eq!(vote.effective_voter_id(), owner_id);
254    }
255
256    #[test]
257    fn test_create_vote_with_proxy() {
258        let resolution_id = Uuid::new_v4();
259        let owner_id = Uuid::new_v4();
260        let unit_id = Uuid::new_v4();
261        let proxy_id = Uuid::new_v4();
262
263        let vote = Vote::new(
264            resolution_id,
265            owner_id,
266            unit_id,
267            VoteChoice::Contre,
268            dec!(200),
269            Some(proxy_id),
270        );
271
272        assert!(vote.is_ok());
273        let vote = vote.unwrap();
274        assert!(vote.is_proxy_vote());
275        assert_eq!(vote.effective_voter_id(), proxy_id);
276        assert_eq!(vote.proxy_owner_id, Some(proxy_id));
277    }
278
279    #[test]
280    fn test_create_vote_zero_voting_power_fails() {
281        let resolution_id = Uuid::new_v4();
282        let owner_id = Uuid::new_v4();
283        let unit_id = Uuid::new_v4();
284
285        let vote = Vote::new(
286            resolution_id,
287            owner_id,
288            unit_id,
289            VoteChoice::Pour,
290            dec!(0),
291            None,
292        );
293
294        assert!(vote.is_err());
295        assert_eq!(vote.unwrap_err(), "Voting power must be positive");
296    }
297
298    #[test]
299    fn test_create_vote_negative_voting_power_fails() {
300        let resolution_id = Uuid::new_v4();
301        let owner_id = Uuid::new_v4();
302        let unit_id = Uuid::new_v4();
303
304        let vote = Vote::new(
305            resolution_id,
306            owner_id,
307            unit_id,
308            VoteChoice::Pour,
309            dec!(-50),
310            None,
311        );
312
313        assert!(vote.is_err());
314        assert_eq!(vote.unwrap_err(), "Voting power must be positive");
315    }
316
317    #[test]
318    fn test_create_vote_excessive_voting_power_fails() {
319        let resolution_id = Uuid::new_v4();
320        let owner_id = Uuid::new_v4();
321        let unit_id = Uuid::new_v4();
322
323        let vote = Vote::new(
324            resolution_id,
325            owner_id,
326            unit_id,
327            VoteChoice::Pour,
328            dec!(15000), // Exceeds max
329            None,
330        );
331
332        assert!(vote.is_err());
333        assert!(vote.unwrap_err().contains("exceeds maximum"));
334    }
335
336    #[test]
337    fn test_create_vote_self_proxy_fails() {
338        let resolution_id = Uuid::new_v4();
339        let owner_id = Uuid::new_v4();
340        let unit_id = Uuid::new_v4();
341
342        let vote = Vote::new(
343            resolution_id,
344            owner_id,
345            unit_id,
346            VoteChoice::Pour,
347            dec!(150),
348            Some(owner_id), // Self as proxy
349        );
350
351        assert!(vote.is_err());
352        assert_eq!(vote.unwrap_err(), "Owner cannot be their own proxy");
353    }
354
355    #[test]
356    fn test_change_vote() {
357        let resolution_id = Uuid::new_v4();
358        let owner_id = Uuid::new_v4();
359        let unit_id = Uuid::new_v4();
360
361        let mut vote = Vote::new(
362            resolution_id,
363            owner_id,
364            unit_id,
365            VoteChoice::Pour,
366            dec!(150),
367            None,
368        )
369        .unwrap();
370
371        assert_eq!(vote.vote_choice, VoteChoice::Pour);
372
373        let result = vote.change_vote(VoteChoice::Contre);
374        assert!(result.is_ok());
375        assert_eq!(vote.vote_choice, VoteChoice::Contre);
376    }
377
378    #[test]
379    fn test_vote_choice_serialization() {
380        // Test serialization of VoteChoice enum
381        let pour = VoteChoice::Pour;
382        let contre = VoteChoice::Contre;
383        let abstention = VoteChoice::Abstention;
384
385        let json_pour = serde_json::to_string(&pour).unwrap();
386        let json_contre = serde_json::to_string(&contre).unwrap();
387        let json_abstention = serde_json::to_string(&abstention).unwrap();
388
389        assert_eq!(json_pour, "\"pour\"");
390        assert_eq!(json_contre, "\"contre\"");
391        assert_eq!(json_abstention, "\"abstention\"");
392    }
393
394    #[test]
395    fn test_vote_choice_deserialization() {
396        // Test deserialization of VoteChoice enum
397        let pour: VoteChoice = serde_json::from_str("\"pour\"").unwrap();
398        let contre: VoteChoice = serde_json::from_str("\"contre\"").unwrap();
399        let abstention: VoteChoice = serde_json::from_str("\"abstention\"").unwrap();
400
401        assert_eq!(pour, VoteChoice::Pour);
402        assert_eq!(contre, VoteChoice::Contre);
403        assert_eq!(abstention, VoteChoice::Abstention);
404    }
405
406    // ------------------------------------------------------------------------
407    // Story 4.2 — `assert_vote_auth_sufficient` (Art. 3.87 §1er, §4 CC, #48)
408    // ------------------------------------------------------------------------
409
410    /// @happy — un vote distant authentifié par itsme est accepté.
411    #[test]
412    fn happy_itsme_suffit_pour_un_vote_distant() {
413        let resultat =
414            assert_vote_auth_sufficient(MeetingMode::Remote, Some(VoteAuthMethod::Itsme), false);
415        assert_eq!(resultat, Ok(VoteAuthMethod::Itsme));
416    }
417
418    /// @happy — eID est équivalent à itsme pour l'authentification forte.
419    #[test]
420    fn happy_eid_suffit_pour_un_vote_hybride() {
421        let resultat =
422            assert_vote_auth_sufficient(MeetingMode::Hybrid, Some(VoteAuthMethod::Eid), false);
423        assert_eq!(resultat, Ok(VoteAuthMethod::Eid));
424    }
425
426    /// @edge — une procuration en bonne et due forme (le bulletin porte
427    /// effectivement un mandataire) est autorisée à distance : Art. 3.87 §4
428    /// régit la procuration elle-même, la limite des trois mandats se
429    /// vérifiant par ailleurs (`validate_proxy_limit`).
430    #[test]
431    fn edge_procuration_reelle_autorisee_a_distance() {
432        let resultat =
433            assert_vote_auth_sufficient(MeetingMode::Remote, Some(VoteAuthMethod::Proxy), true);
434        assert_eq!(resultat, Ok(VoteAuthMethod::Proxy));
435    }
436
437    /// @edge — se déclarer `auth_method: proxy` sans que le bulletin porte
438    /// réellement un mandataire n'est qu'une étiquette : ça n'engage
439    /// personne de plus qu'une simple déclaration de présence.
440    #[test]
441    fn edge_proxy_declare_sans_mandat_reel_est_insuffisant() {
442        let resultat =
443            assert_vote_auth_sufficient(MeetingMode::Remote, Some(VoteAuthMethod::Proxy), false);
444        assert_eq!(
445            resultat,
446            Err(VoteAuthError::Insufficient {
447                mode: MeetingMode::Remote,
448                auth_method: VoteAuthMethod::Proxy,
449            })
450        );
451    }
452
453    /// @edge — une AG en présentiel n'exige aucune authentification forte :
454    /// `presence` y suffit toujours, quel que soit le mode déclaré ailleurs.
455    #[test]
456    fn edge_presence_suffit_en_ag_physique() {
457        let resultat = assert_vote_auth_sufficient(
458            MeetingMode::InPerson,
459            Some(VoteAuthMethod::Presence),
460            false,
461        );
462        assert_eq!(resultat, Ok(VoteAuthMethod::Presence));
463    }
464
465    /// @security — déclarer sa présence pour un vote à distance est
466    /// exactement la fraude que l'authentification forte doit rendre
467    /// impossible (#48) : refusé, pas silencieusement accepté.
468    #[test]
469    fn security_presence_insuffisante_pour_un_vote_distant() {
470        let resultat =
471            assert_vote_auth_sufficient(MeetingMode::Remote, Some(VoteAuthMethod::Presence), false);
472        assert_eq!(
473            resultat,
474            Err(VoteAuthError::Insufficient {
475                mode: MeetingMode::Remote,
476                auth_method: VoteAuthMethod::Presence,
477            })
478        );
479    }
480
481    /// @negative — un vote sans `auth_method` du tout n'est pas exploitable
482    /// en cas de contestation, quel que soit le mode de l'AG.
483    #[test]
484    fn negative_auth_method_absent_est_refuse() {
485        let resultat = assert_vote_auth_sufficient(MeetingMode::InPerson, None, false);
486        assert_eq!(resultat, Err(VoteAuthError::Missing));
487    }
488
489    #[test]
490    fn edge_vote_auth_method_db_round_trip() {
491        for m in [
492            VoteAuthMethod::Presence,
493            VoteAuthMethod::Proxy,
494            VoteAuthMethod::Itsme,
495            VoteAuthMethod::Eid,
496        ] {
497            assert_eq!(VoteAuthMethod::from_db_string(m.to_db_str()), Ok(m));
498        }
499    }
500
501    #[test]
502    fn negative_vote_auth_method_unknown_db_string_is_rejected() {
503        assert!(VoteAuthMethod::from_db_string("carrier_pigeon").is_err());
504    }
505
506    /// @happy — `Vote::new` (chemin historique) vaut `Presence` par défaut :
507    /// les appelants antérieurs à Story 4.2 ne changent pas de comportement.
508    #[test]
509    fn happy_vote_new_defaults_to_presence() {
510        let vote = Vote::new(
511            Uuid::new_v4(),
512            Uuid::new_v4(),
513            Uuid::new_v4(),
514            VoteChoice::Pour,
515            dec!(100),
516            None,
517        )
518        .expect("vote valide");
519        assert_eq!(vote.auth_method, VoteAuthMethod::Presence);
520    }
521
522    #[test]
523    fn happy_vote_new_with_auth_method_sets_field() {
524        let vote = Vote::new_with_auth_method(
525            Uuid::new_v4(),
526            Uuid::new_v4(),
527            Uuid::new_v4(),
528            VoteChoice::Pour,
529            dec!(100),
530            None,
531            VoteAuthMethod::Itsme,
532        )
533        .expect("vote valide");
534        assert_eq!(vote.auth_method, VoteAuthMethod::Itsme);
535    }
536}