Skip to main content

koprogo_api/domain/copropriete/
resolution.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Type de résolution (ordinaire ou extraordinaire)
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum ResolutionType {
11    Ordinary,      // Résolution ordinaire (majorité simple)
12    Extraordinary, // Résolution extraordinaire (majorité qualifiée)
13}
14
15/// Type de majorité requise pour adoption — Art. 3.88 §1 Code Civil belge
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum MajorityType {
19    /// Plus de 50% des présents/représentés, abstentions EXCLUES — Art. 3.88 §1 (DÉFAUT).
20    /// Comptes, budget, syndic, commissaire, entretien courant, travaux imposés par la loi.
21    Absolute,
22    /// ≥2/3 des présents/représentés, abstentions EXCLUES — Art. 3.88 §1, 1°
23    /// Modification statuts (jouissance/usage), travaux parties communes, mise en concurrence
24    TwoThirds,
25    /// ≥4/5 des présents/représentés, abstentions EXCLUES — Art. 3.88 §1, 2°
26    /// Modification répartition charges, destination, reconstruction partielle, aliénation
27    FourFifths,
28    /// 100% de TOUS les tantièmes (y compris absents) — Art. 3.88 §1, 3°
29    /// Modification quotités de copropriété, reconstruction totale
30    Unanimity,
31}
32
33impl MajorityType {
34    /// Rang de sévérité croissant du seuil — Art. 3.88 §1 : absolue < deux
35    /// tiers < quatre cinquièmes < unanimité.
36    fn rang(&self) -> u8 {
37        match self {
38            MajorityType::Absolute => 0,
39            MajorityType::TwoThirds => 1,
40            MajorityType::FourFifths => 2,
41            MajorityType::Unanimity => 3,
42        }
43    }
44
45    /// La majorité obtenue (`self`) satisfait-elle au moins le seuil
46    /// `requise` ?
47    ///
48    /// Une majorité plus exigeante que celle requise satisfait toujours le
49    /// seuil — une AG qui vote à l'unanimité un fonds affecté n'a pas voté
50    /// « moins » que les deux tiers requis. Introduit pour la story #635
51    /// (création d'un fonds affecté — Art. 3.88, gros travaux — 2/3).
52    pub fn satisfait(&self, requise: &MajorityType) -> bool {
53        self.rang() >= requise.rang()
54    }
55}
56
57/// Statut d'une résolution
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
59#[serde(rename_all = "snake_case")]
60pub enum ResolutionStatus {
61    Pending,  // En attente de vote
62    Adopted,  // Adoptée
63    Rejected, // Rejetée
64}
65
66/// Nature métier d'une résolution — distincte de `ResolutionType`, qui ne dit
67/// que le seuil de majorité requis.
68///
69/// Story 4.6 (#581) : `generate_ago_resolutions(meeting_id)` ajoute d'office,
70/// à toute AGO, une résolution d'évaluation des prestataires (Art. 3.89 § 5,
71/// 12° Code Civil belge). `kind` est ce qui la distingue d'une résolution
72/// ordinaire — `is_auto_generated()` s'y fie exclusivement, pour qu'aucune
73/// autre donnée (titre, statut) ne puisse accidentellement déclencher ou
74/// lever sa protection contre la suppression/l'édition.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
76#[serde(rename_all = "snake_case")]
77pub enum ResolutionKind {
78    #[default]
79    Standard,
80    EvaluationContractorsAuto,
81}
82
83/// Résolution soumise au vote lors d'une assemblée générale
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
85pub struct Resolution {
86    pub id: Uuid,
87    pub meeting_id: Uuid,
88    pub title: String,
89    pub description: String,
90    pub resolution_type: ResolutionType,
91    pub majority_required: MajorityType,
92    /// Cf. `ResolutionKind` — Story 4.6 (#581).
93    pub kind: ResolutionKind,
94    pub vote_count_pour: i32,
95    pub vote_count_contre: i32,
96    pub vote_count_abstention: i32,
97    pub total_voting_power_pour: Decimal,
98    pub total_voting_power_contre: Decimal,
99    pub total_voting_power_abstention: Decimal,
100    pub status: ResolutionStatus,
101    // Issue #310: Link resolution to agenda item
102    pub agenda_item_index: Option<usize>, // Index into meeting.agenda Vec
103
104    /// La personne dont la mission fait l'objet de cette délibération.
105    ///
106    /// Art. 3.87 § 9 : « Aucune personne mandatée ou employée par
107    /// l'association [...] ne peut participer personnellement ou par
108    /// procuration aux délibérations et aux votes **relatifs à la mission qui
109    /// lui a été confiée**. »
110    ///
111    /// La règle est étroite : elle n'écarte pas le prestataire de toute
112    /// l'assemblée, seulement des points qui le concernent. Un entrepreneur
113    /// copropriétaire vote sur le budget ; il ne vote pas sur l'attribution du
114    /// marché qu'il brigue.
115    ///
116    /// `None` est le cas courant — la plupart des résolutions ne portent la
117    /// mission de personne.
118    pub prestataire_de_la_mission: Option<Uuid>,
119    pub created_at: DateTime<Utc>,
120    pub voted_at: Option<DateTime<Utc>>,
121}
122
123/// Un pourcentage lisible, à partir de voix exactes.
124///
125/// Le calcul se fait en `Decimal` puis se convertit à la fin : diviser des
126/// `f64` réintroduirait l'inexactitude que l'ADR-0008 bannit. La conversion
127/// finale est un **affichage**, pas une base de décision — les majorités se
128/// calculent sur les `Decimal`, jamais sur ce nombre.
129fn pourcentage(part: Decimal, total: Decimal) -> f64 {
130    if total <= Decimal::ZERO {
131        return 0.0;
132    }
133    (part * Decimal::from(100) / total)
134        .to_string()
135        .parse()
136        .unwrap_or(0.0)
137}
138
139impl Resolution {
140    /// Crée une nouvelle résolution
141    /// Issue #310: Optional agenda_item_index for Art. 3.87 CC compliance (Belgian law)
142    pub fn new(
143        meeting_id: Uuid,
144        title: String,
145        description: String,
146        resolution_type: ResolutionType,
147        majority_required: MajorityType,
148        agenda_item_index: Option<usize>,
149    ) -> Result<Self, String> {
150        Self::new_avec_prestataire(
151            meeting_id,
152            title,
153            description,
154            resolution_type,
155            majority_required,
156            agenda_item_index,
157            None,
158        )
159    }
160
161    /// La même résolution, en désignant la personne dont elle traite la
162    /// mission (Art. 3.87 § 9).
163    #[allow(clippy::too_many_arguments)]
164    pub fn new_avec_prestataire(
165        meeting_id: Uuid,
166        title: String,
167        description: String,
168        resolution_type: ResolutionType,
169        majority_required: MajorityType,
170        agenda_item_index: Option<usize>,
171        prestataire_de_la_mission: Option<Uuid>,
172    ) -> Result<Self, String> {
173        if title.is_empty() {
174            return Err("Resolution title cannot be empty".to_string());
175        }
176        if description.is_empty() {
177            return Err("Resolution description cannot be empty".to_string());
178        }
179
180        let now = Utc::now();
181        Ok(Self {
182            id: Uuid::new_v4(),
183            meeting_id,
184            title,
185            description,
186            resolution_type,
187            majority_required,
188            vote_count_pour: 0,
189            vote_count_contre: 0,
190            vote_count_abstention: 0,
191            total_voting_power_pour: Decimal::ZERO,
192            total_voting_power_contre: Decimal::ZERO,
193            total_voting_power_abstention: Decimal::ZERO,
194            status: ResolutionStatus::Pending,
195            agenda_item_index,
196            prestataire_de_la_mission,
197            created_at: now,
198            voted_at: None,
199            kind: ResolutionKind::Standard,
200        })
201    }
202
203    /// La résolution d'évaluation des prestataires que `generate_ago_resolutions`
204    /// ajoute d'office à toute AGO (Art. 3.89 § 5, 12° Code Civil belge — Story
205    /// 4.6, #581).
206    ///
207    /// Construite directement plutôt que via `new_avec_prestataire` : son
208    /// titre et sa description sont fixes et jamais vides, donc jamais
209    /// faillibles — un `Result` ici n'aurait pas d'état d'erreur atteignable,
210    /// et forcerait l'appelant à `unwrap()` un cas qui ne se produit pas.
211    pub fn new_evaluation_contractors_auto(meeting_id: Uuid) -> Self {
212        let now = Utc::now();
213        Self {
214            id: Uuid::new_v4(),
215            meeting_id,
216            title: "Évaluation des prestataires".to_string(),
217            description: "Évaluation de l'exécution des contrats en cours, inscrite d'office \
218                à l'ordre du jour de l'assemblée ordinaire (Art. 3.89 § 5, 12° Code Civil belge)."
219                .to_string(),
220            resolution_type: ResolutionType::Ordinary,
221            majority_required: MajorityType::Absolute,
222            vote_count_pour: 0,
223            vote_count_contre: 0,
224            vote_count_abstention: 0,
225            total_voting_power_pour: Decimal::ZERO,
226            total_voting_power_contre: Decimal::ZERO,
227            total_voting_power_abstention: Decimal::ZERO,
228            status: ResolutionStatus::Pending,
229            agenda_item_index: None,
230            prestataire_de_la_mission: None,
231            created_at: now,
232            voted_at: None,
233            kind: ResolutionKind::EvaluationContractorsAuto,
234        }
235    }
236
237    /// Une résolution générée d'office (Story 4.6) n'est ni supprimable ni
238    /// modifiable par l'organe qu'elle évalue — c'est le cœur de la story :
239    /// le syndic ne retire pas son propre bulletin de notes de l'ordre du
240    /// jour. Dérivé de `kind`, jamais stocké séparément, pour qu'aucune
241    /// écriture (changement de statut, de titre…) ne puisse désynchroniser
242    /// la protection de ce qu'elle protège.
243    pub fn is_auto_generated(&self) -> bool {
244        self.kind == ResolutionKind::EvaluationContractorsAuto
245    }
246
247    /// Enregistre un vote "Pour" et met à jour les compteurs
248    pub fn record_vote_pour(&mut self, voting_power: Decimal) {
249        self.vote_count_pour += 1;
250        self.total_voting_power_pour += voting_power;
251    }
252
253    /// Enregistre un vote "Contre" et met à jour les compteurs
254    pub fn record_vote_contre(&mut self, voting_power: Decimal) {
255        self.vote_count_contre += 1;
256        self.total_voting_power_contre += voting_power;
257    }
258
259    /// Enregistre une abstention et met à jour les compteurs
260    pub fn record_abstention(&mut self, voting_power: Decimal) {
261        self.vote_count_abstention += 1;
262        self.total_voting_power_abstention += voting_power;
263    }
264
265    /// Refait le décompte pondéré à partir des bulletins et de leur poids
266    /// retenu, après application du plafond de l'Art. 3.87 § 7 al. 4.
267    ///
268    /// Les compteurs accumulés au fil des votes reflètent les voix **brutes**.
269    /// Quand un votant est plafonné, ils ne valent plus rien pour établir la
270    /// majorité : il faut repartir des bulletins. Le nombre de votants, lui,
271    /// ne change pas — c'est le poids qui est réduit, pas le droit de voter.
272    ///
273    /// `poids_retenus` suit l'ordre de `votes`, tel que le rend
274    /// `repartir_le_plafond`.
275    pub fn recompter_avec(&mut self, votes: &[super::vote::Vote], poids_retenus: &[Decimal]) {
276        use super::vote::VoteChoice;
277
278        self.total_voting_power_pour = Decimal::ZERO;
279        self.total_voting_power_contre = Decimal::ZERO;
280        self.total_voting_power_abstention = Decimal::ZERO;
281
282        for (vote, poids) in votes.iter().zip(poids_retenus) {
283            match vote.vote_choice {
284                VoteChoice::Pour => self.total_voting_power_pour += poids,
285                VoteChoice::Contre => self.total_voting_power_contre += poids,
286                VoteChoice::Abstention => self.total_voting_power_abstention += poids,
287            }
288        }
289    }
290
291    /// Calcule le résultat du vote en fonction du type de majorité — Art. 3.88 §1 Code Civil belge
292    pub fn calculate_result(&self, total_voting_power: Decimal) -> ResolutionStatus {
293        let expressed = self.total_voting_power_pour + self.total_voting_power_contre;
294
295        match &self.majority_required {
296            MajorityType::Absolute => {
297                // Art. 3.88 §1: >50% des voix exprimées (hors abstentions)
298                if expressed > Decimal::ZERO && self.total_voting_power_pour > expressed / dec!(2) {
299                    ResolutionStatus::Adopted
300                } else {
301                    ResolutionStatus::Rejected
302                }
303            }
304            MajorityType::TwoThirds => {
305                // Art. 3.88 §1, 1°: ≥2/3 des voix exprimées
306                if expressed > Decimal::ZERO
307                    && self.total_voting_power_pour / expressed >= dec!(2) / dec!(3)
308                {
309                    ResolutionStatus::Adopted
310                } else {
311                    ResolutionStatus::Rejected
312                }
313            }
314            MajorityType::FourFifths => {
315                // Art. 3.88 §1, 2°: ≥4/5 des voix exprimées
316                if expressed > Decimal::ZERO
317                    && self.total_voting_power_pour / expressed >= dec!(4) / dec!(5)
318                {
319                    ResolutionStatus::Adopted
320                } else {
321                    ResolutionStatus::Rejected
322                }
323            }
324            MajorityType::Unanimity => {
325                // Art. 3.88 §1, 3°: 100% de TOUS les tantièmes (pas juste les présents)
326                // total_voting_power = total building tantièmes (e.g. 10000)
327                if total_voting_power > Decimal::ZERO
328                    && (self.total_voting_power_pour - total_voting_power).abs() < dec!(0.01)
329                {
330                    ResolutionStatus::Adopted
331                } else {
332                    ResolutionStatus::Rejected
333                }
334            }
335        }
336    }
337
338    /// Clôture le vote et finalise le statut
339    pub fn close_voting(&mut self, total_voting_power: Decimal) -> Result<(), String> {
340        if self.status != ResolutionStatus::Pending {
341            return Err("Voting already closed for this resolution".to_string());
342        }
343
344        self.status = self.calculate_result(total_voting_power);
345        self.voted_at = Some(Utc::now());
346        Ok(())
347    }
348
349    /// Retourne le nombre total de votes exprimés
350    pub fn total_votes(&self) -> i32 {
351        self.vote_count_pour + self.vote_count_contre + self.vote_count_abstention
352    }
353
354    /// Le total des voix exprimées, abstentions exclues.
355    ///
356    /// Art. 3.87 § 8 : « Les abstentions, les votes nuls et blancs ne sont pas
357    /// considérés comme des voix émises pour le calcul de la majorité
358    /// requise. » C'est donc ce total, et non celui de tous les présents, qui
359    /// sert de dénominateur à la majorité.
360    pub fn voix_exprimees(&self) -> Decimal {
361        self.total_voting_power_pour + self.total_voting_power_contre
362    }
363
364    /// La part des voix « pour », en pourcentage des voix **exprimées**.
365    ///
366    /// Art. 3.87 § 6 : « Chaque copropriétaire dispose d'un nombre de voix
367    /// correspondant à sa quote-part dans les parties communes. » Le droit
368    /// belge compte donc des **voix**, jamais des têtes.
369    ///
370    /// Ce pourcentage comptait auparavant les bulletins : trois votants dont
371    /// un majoritaire donnaient « 33 % pour » alors qu'il pesait 55 % des
372    /// tantièmes. Un syndic lisant cet écran voyait l'inverse du droit.
373    /// Constaté en recette le 2026-09-04.
374    ///
375    /// Le dénominateur est celui de la majorité, pour que le pourcentage
376    /// affiché et le résultat proclamé se répondent.
377    pub fn pour_percentage(&self) -> f64 {
378        pourcentage(self.total_voting_power_pour, self.voix_exprimees())
379    }
380
381    /// La part des voix « contre », sur la même base que `pour_percentage`.
382    pub fn contre_percentage(&self) -> f64 {
383        pourcentage(self.total_voting_power_contre, self.voix_exprimees())
384    }
385
386    /// La part des abstentions, rapportée à **toutes** les voix présentes.
387    ///
388    /// Dénominateur différent de celui des deux précédents, à dessein : une
389    /// abstention ne participe pas à la majorité (Art. 3.87 § 8), donc la
390    /// rapporter aux seules voix exprimées n'aurait aucun sens — elle en est
391    /// exclue par définition. Ce qu'un syndic veut savoir, c'est quelle part
392    /// de l'assemblée s'est abstenue.
393    pub fn abstention_percentage(&self) -> f64 {
394        let presentes = self.voix_exprimees() + self.total_voting_power_abstention;
395        pourcentage(self.total_voting_power_abstention, presentes)
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_create_resolution_success() {
405        let meeting_id = Uuid::new_v4();
406        let resolution = Resolution::new(
407            meeting_id,
408            "Approbation des comptes 2024".to_string(),
409            "Vote pour approuver les comptes annuels de l'exercice 2024".to_string(),
410            ResolutionType::Ordinary,
411            MajorityType::Absolute,
412            Some(0),
413        );
414
415        assert!(resolution.is_ok());
416        let resolution = resolution.unwrap();
417        assert_eq!(resolution.meeting_id, meeting_id);
418        assert_eq!(resolution.status, ResolutionStatus::Pending);
419        assert_eq!(resolution.total_votes(), 0);
420        assert_eq!(resolution.agenda_item_index, Some(0));
421    }
422
423    #[test]
424    fn test_create_resolution_without_agenda_item() {
425        let meeting_id = Uuid::new_v4();
426        let resolution = Resolution::new(
427            meeting_id,
428            "Approbation des comptes 2024".to_string(),
429            "Vote pour approuver les comptes annuels de l'exercice 2024".to_string(),
430            ResolutionType::Ordinary,
431            MajorityType::Absolute,
432            None,
433        );
434
435        assert!(resolution.is_ok());
436        let resolution = resolution.unwrap();
437        assert_eq!(resolution.agenda_item_index, None);
438    }
439
440    #[test]
441    fn test_create_resolution_empty_title_fails() {
442        let meeting_id = Uuid::new_v4();
443        let resolution = Resolution::new(
444            meeting_id,
445            "".to_string(),
446            "Description".to_string(),
447            ResolutionType::Ordinary,
448            MajorityType::Absolute,
449            Some(0),
450        );
451
452        assert!(resolution.is_err());
453        assert_eq!(resolution.unwrap_err(), "Resolution title cannot be empty");
454    }
455
456    #[test]
457    fn test_record_votes() {
458        let meeting_id = Uuid::new_v4();
459        let mut resolution = Resolution::new(
460            meeting_id,
461            "Test Resolution".to_string(),
462            "Description".to_string(),
463            ResolutionType::Ordinary,
464            MajorityType::Absolute,
465            Some(0),
466        )
467        .unwrap();
468
469        resolution.record_vote_pour(dec!(100));
470        resolution.record_vote_pour(dec!(150));
471        resolution.record_vote_contre(dec!(200));
472        resolution.record_abstention(dec!(50));
473
474        assert_eq!(resolution.vote_count_pour, 2);
475        assert_eq!(resolution.vote_count_contre, 1);
476        assert_eq!(resolution.vote_count_abstention, 1);
477        assert_eq!(resolution.total_voting_power_pour, dec!(250));
478        assert_eq!(resolution.total_voting_power_contre, dec!(200));
479        assert_eq!(resolution.total_voting_power_abstention, dec!(50));
480        assert_eq!(resolution.total_votes(), 4);
481    }
482
483    // ===== Absolute majority (Art. 3.88 §1) — abstentions excluded =====
484
485    #[test]
486    fn test_calculate_result_absolute_majority_adopted() {
487        let meeting_id = Uuid::new_v4();
488        let mut resolution = Resolution::new(
489            meeting_id,
490            "Test Resolution".to_string(),
491            "Description".to_string(),
492            ResolutionType::Ordinary,
493            MajorityType::Absolute,
494            Some(0),
495        )
496        .unwrap();
497
498        // Pour=300, Contre=150 → expressed=450, 300 > 225 → Adopted
499        resolution.record_vote_pour(dec!(300));
500        resolution.record_vote_contre(dec!(150));
501        resolution.record_abstention(dec!(50));
502
503        let result = resolution.calculate_result(dec!(1000));
504        assert_eq!(result, ResolutionStatus::Adopted);
505    }
506
507    #[test]
508    fn test_calculate_result_absolute_majority_rejected() {
509        let meeting_id = Uuid::new_v4();
510        let mut resolution = Resolution::new(
511            meeting_id,
512            "Test Resolution".to_string(),
513            "Description".to_string(),
514            ResolutionType::Ordinary,
515            MajorityType::Absolute,
516            Some(0),
517        )
518        .unwrap();
519
520        // Pour=150, Contre=300 → expressed=450, 150 < 225 → Rejected
521        resolution.record_vote_pour(dec!(150));
522        resolution.record_vote_contre(dec!(300));
523        resolution.record_abstention(dec!(50));
524
525        let result = resolution.calculate_result(dec!(1000));
526        assert_eq!(result, ResolutionStatus::Rejected);
527    }
528
529    #[test]
530    fn test_absolute_majority_abstentions_excluded() {
531        let meeting_id = Uuid::new_v4();
532        let mut resolution = Resolution::new(
533            meeting_id,
534            "Test Resolution".to_string(),
535            "Description".to_string(),
536            ResolutionType::Ordinary,
537            MajorityType::Absolute,
538            Some(0),
539        )
540        .unwrap();
541
542        // Pour=300, Contre=200, Abstention=500 → expressed=500, 300 > 250 → Adopted
543        // Abstentions are excluded: 300 is more than half of (300+200)
544        resolution.record_vote_pour(dec!(300));
545        resolution.record_vote_contre(dec!(200));
546        resolution.record_abstention(dec!(500));
547
548        let result = resolution.calculate_result(dec!(1000));
549        assert_eq!(result, ResolutionStatus::Adopted);
550    }
551
552    // ===== Two-thirds majority (Art. 3.88 §1, 1°) — abstentions excluded =====
553
554    #[test]
555    fn test_calculate_result_two_thirds_majority_adopted() {
556        let meeting_id = Uuid::new_v4();
557        let mut resolution = Resolution::new(
558            meeting_id,
559            "Test Resolution".to_string(),
560            "Description".to_string(),
561            ResolutionType::Extraordinary,
562            MajorityType::TwoThirds,
563            Some(0),
564        )
565        .unwrap();
566
567        // Pour=700, Contre=200 → expressed=900, 700/900 = 77.8% >= 66.7% → Adopted
568        resolution.record_vote_pour(dec!(700));
569        resolution.record_vote_contre(dec!(200));
570        resolution.record_abstention(dec!(100));
571
572        let result = resolution.calculate_result(dec!(1000));
573        assert_eq!(result, ResolutionStatus::Adopted);
574    }
575
576    #[test]
577    fn test_calculate_result_two_thirds_majority_rejected() {
578        let meeting_id = Uuid::new_v4();
579        let mut resolution = Resolution::new(
580            meeting_id,
581            "Test Resolution".to_string(),
582            "Description".to_string(),
583            ResolutionType::Extraordinary,
584            MajorityType::TwoThirds,
585            Some(0),
586        )
587        .unwrap();
588
589        // Pour=600, Contre=300 → expressed=900, 600/900 = 66.7% >= 66.7% → Adopted (boundary)
590        // Actually 600/900 = 0.6667 which is >= 2/3 = 0.6667 → Adopted
591        resolution.record_vote_pour(dec!(600));
592        resolution.record_vote_contre(dec!(300));
593        resolution.record_abstention(dec!(100));
594
595        let result = resolution.calculate_result(dec!(1000));
596        assert_eq!(result, ResolutionStatus::Adopted);
597    }
598
599    #[test]
600    fn test_two_thirds_majority_barely_rejected() {
601        let meeting_id = Uuid::new_v4();
602        let mut resolution = Resolution::new(
603            meeting_id,
604            "Test Resolution".to_string(),
605            "Description".to_string(),
606            ResolutionType::Extraordinary,
607            MajorityType::TwoThirds,
608            Some(0),
609        )
610        .unwrap();
611
612        // Pour=500, Contre=300 → expressed=800, 500/800 = 62.5% < 66.7% → Rejected
613        resolution.record_vote_pour(dec!(500));
614        resolution.record_vote_contre(dec!(300));
615        resolution.record_abstention(dec!(200));
616
617        let result = resolution.calculate_result(dec!(1000));
618        assert_eq!(result, ResolutionStatus::Rejected);
619    }
620
621    #[test]
622    fn test_two_thirds_abstentions_excluded() {
623        let meeting_id = Uuid::new_v4();
624        let mut resolution = Resolution::new(
625            meeting_id,
626            "Test Resolution".to_string(),
627            "Description".to_string(),
628            ResolutionType::Extraordinary,
629            MajorityType::TwoThirds,
630            Some(0),
631        )
632        .unwrap();
633
634        // Pour=400, Contre=100, Abstention=500 → expressed=500, 400/500 = 80% >= 66.7% → Adopted
635        resolution.record_vote_pour(dec!(400));
636        resolution.record_vote_contre(dec!(100));
637        resolution.record_abstention(dec!(500));
638
639        let result = resolution.calculate_result(dec!(1000));
640        assert_eq!(result, ResolutionStatus::Adopted);
641    }
642
643    // ===== Four-fifths majority (Art. 3.88 §1, 2°) — abstentions excluded =====
644
645    #[test]
646    fn test_calculate_result_four_fifths_majority_adopted() {
647        let meeting_id = Uuid::new_v4();
648        let mut resolution = Resolution::new(
649            meeting_id,
650            "Test Resolution".to_string(),
651            "Description".to_string(),
652            ResolutionType::Extraordinary,
653            MajorityType::FourFifths,
654            Some(0),
655        )
656        .unwrap();
657
658        // Pour=800, Contre=100 → expressed=900, 800/900 = 88.9% >= 80% → Adopted
659        resolution.record_vote_pour(dec!(800));
660        resolution.record_vote_contre(dec!(100));
661        resolution.record_abstention(dec!(100));
662
663        let result = resolution.calculate_result(dec!(1000));
664        assert_eq!(result, ResolutionStatus::Adopted);
665    }
666
667    #[test]
668    fn test_calculate_result_four_fifths_majority_rejected() {
669        let meeting_id = Uuid::new_v4();
670        let mut resolution = Resolution::new(
671            meeting_id,
672            "Test Resolution".to_string(),
673            "Description".to_string(),
674            ResolutionType::Extraordinary,
675            MajorityType::FourFifths,
676            Some(0),
677        )
678        .unwrap();
679
680        // Pour=700, Contre=200 → expressed=900, 700/900 = 77.8% < 80% → Rejected
681        resolution.record_vote_pour(dec!(700));
682        resolution.record_vote_contre(dec!(200));
683        resolution.record_abstention(dec!(100));
684
685        let result = resolution.calculate_result(dec!(1000));
686        assert_eq!(result, ResolutionStatus::Rejected);
687    }
688
689    #[test]
690    fn test_four_fifths_abstentions_excluded() {
691        let meeting_id = Uuid::new_v4();
692        let mut resolution = Resolution::new(
693            meeting_id,
694            "Test Resolution".to_string(),
695            "Description".to_string(),
696            ResolutionType::Extraordinary,
697            MajorityType::FourFifths,
698            Some(0),
699        )
700        .unwrap();
701
702        // Pour=400, Contre=50, Abstention=550 → expressed=450, 400/450 = 88.9% >= 80% → Adopted
703        resolution.record_vote_pour(dec!(400));
704        resolution.record_vote_contre(dec!(50));
705        resolution.record_abstention(dec!(550));
706
707        let result = resolution.calculate_result(dec!(1000));
708        assert_eq!(result, ResolutionStatus::Adopted);
709    }
710
711    // ===== Unanimity (Art. 3.88 §1, 3°) — requires ALL tantièmes =====
712
713    #[test]
714    fn test_calculate_result_unanimity_adopted() {
715        let meeting_id = Uuid::new_v4();
716        let mut resolution = Resolution::new(
717            meeting_id,
718            "Test Resolution".to_string(),
719            "Description".to_string(),
720            ResolutionType::Extraordinary,
721            MajorityType::Unanimity,
722            Some(0),
723        )
724        .unwrap();
725
726        // Pour=10000 == total_voting_power → Adopted
727        resolution.record_vote_pour(dec!(10000));
728
729        let result = resolution.calculate_result(dec!(10000));
730        assert_eq!(result, ResolutionStatus::Adopted);
731    }
732
733    #[test]
734    fn test_calculate_result_unanimity_rejected_missing_votes() {
735        let meeting_id = Uuid::new_v4();
736        let mut resolution = Resolution::new(
737            meeting_id,
738            "Test Resolution".to_string(),
739            "Description".to_string(),
740            ResolutionType::Extraordinary,
741            MajorityType::Unanimity,
742            Some(0),
743        )
744        .unwrap();
745
746        // Pour=9000 < total_voting_power=10000 (absent owners not accounted for) → Rejected
747        resolution.record_vote_pour(dec!(9000));
748
749        let result = resolution.calculate_result(dec!(10000));
750        assert_eq!(result, ResolutionStatus::Rejected);
751    }
752
753    #[test]
754    fn test_unanimity_requires_all_tantiemes_not_just_present() {
755        let meeting_id = Uuid::new_v4();
756        let mut resolution = Resolution::new(
757            meeting_id,
758            "Test Resolution".to_string(),
759            "Description".to_string(),
760            ResolutionType::Extraordinary,
761            MajorityType::Unanimity,
762            Some(0),
763        )
764        .unwrap();
765
766        // All present vote Pour but some owners are absent
767        // Pour=8000, Contre=0, Abstention=0, but total building = 10000 → Rejected
768        resolution.record_vote_pour(dec!(8000));
769
770        let result = resolution.calculate_result(dec!(10000));
771        assert_eq!(result, ResolutionStatus::Rejected);
772    }
773
774    #[test]
775    fn test_unanimity_rejected_with_abstention() {
776        let meeting_id = Uuid::new_v4();
777        let mut resolution = Resolution::new(
778            meeting_id,
779            "Test Resolution".to_string(),
780            "Description".to_string(),
781            ResolutionType::Extraordinary,
782            MajorityType::Unanimity,
783            Some(0),
784        )
785        .unwrap();
786
787        // Pour=9500, Abstention=500 → Pour != total → Rejected (abstentions count as NOT pour)
788        resolution.record_vote_pour(dec!(9500));
789        resolution.record_abstention(dec!(500));
790
791        let result = resolution.calculate_result(dec!(10000));
792        assert_eq!(result, ResolutionStatus::Rejected);
793    }
794
795    // ===== Close voting =====
796
797    #[test]
798    fn test_close_voting_success() {
799        let meeting_id = Uuid::new_v4();
800        let mut resolution = Resolution::new(
801            meeting_id,
802            "Test Resolution".to_string(),
803            "Description".to_string(),
804            ResolutionType::Ordinary,
805            MajorityType::Absolute,
806            Some(0),
807        )
808        .unwrap();
809
810        resolution.record_vote_pour(dec!(300));
811        resolution.record_vote_contre(dec!(150));
812
813        let result = resolution.close_voting(dec!(1000));
814        assert!(result.is_ok());
815        assert_eq!(resolution.status, ResolutionStatus::Adopted);
816        assert!(resolution.voted_at.is_some());
817    }
818
819    #[test]
820    fn test_close_voting_already_closed_fails() {
821        let meeting_id = Uuid::new_v4();
822        let mut resolution = Resolution::new(
823            meeting_id,
824            "Test Resolution".to_string(),
825            "Description".to_string(),
826            ResolutionType::Ordinary,
827            MajorityType::Absolute,
828            Some(0),
829        )
830        .unwrap();
831
832        resolution.record_vote_pour(dec!(300));
833        resolution.close_voting(dec!(1000)).unwrap();
834
835        let result = resolution.close_voting(dec!(1000));
836        assert!(result.is_err());
837        assert_eq!(
838            result.unwrap_err(),
839            "Voting already closed for this resolution"
840        );
841    }
842
843    #[test]
844    fn test_percentages() {
845        let meeting_id = Uuid::new_v4();
846        let mut resolution = Resolution::new(
847            meeting_id,
848            "Test Resolution".to_string(),
849            "Description".to_string(),
850            ResolutionType::Ordinary,
851            MajorityType::Absolute,
852            Some(0),
853        )
854        .unwrap();
855
856        resolution.record_vote_pour(dec!(100));
857        resolution.record_vote_pour(dec!(100)); // 200 voix pour
858        resolution.record_vote_contre(dec!(100)); // 100 voix contre
859        resolution.record_abstention(dec!(100)); // 100 voix d'abstention
860
861        // Sur les voix EXPRIMÉES (300), abstentions exclues par l'Art. 3.87 § 8.
862        assert!((resolution.pour_percentage() - 66.666_666).abs() < 0.001);
863        assert!((resolution.contre_percentage() - 33.333_333).abs() < 0.001);
864        // L'abstention se rapporte à toutes les voix présentes (400).
865        assert_eq!(resolution.abstention_percentage(), 25.0);
866    }
867
868    /// Art. 3.87 § 6 — le droit belge compte des voix, pas des têtes.
869    ///
870    /// Scénario de la recette du 2026-09-04 : trois votants, dont un pesant
871    /// 550 tantièmes sur 1000. L'écran affichait « 33 % pour » — le tiers des
872    /// bulletins — alors qu'il pesait 55 % des voix. Un syndic y lisait
873    /// l'inverse du droit.
874    #[test]
875    fn les_pourcentages_comptent_des_voix_pas_des_tetes() {
876        let mut resolution = Resolution::new(
877            Uuid::new_v4(),
878            "Approbation des comptes".to_string(),
879            "Comptes annuels".to_string(),
880            ResolutionType::Ordinary,
881            MajorityType::Absolute,
882            Some(1),
883        )
884        .unwrap();
885
886        resolution.record_vote_pour(dec!(550)); // un seul bulletin, mais 55 %
887        resolution.record_vote_contre(dec!(250));
888        resolution.record_vote_contre(dec!(200));
889
890        // Un tiers des bulletins, mais 55 % des voix.
891        assert_eq!(resolution.vote_count_pour, 1);
892        assert_eq!(resolution.vote_count_contre, 2);
893        assert_eq!(resolution.pour_percentage(), 55.0);
894        assert_eq!(resolution.contre_percentage(), 45.0);
895    }
896
897    // ===== MajorityType::satisfait (issue #635) =====
898
899    /// @happy — la majorité obtenue égale exactement le seuil requis.
900    #[test]
901    fn happy_deux_tiers_satisfait_deux_tiers() {
902        assert!(MajorityType::TwoThirds.satisfait(&MajorityType::TwoThirds));
903    }
904
905    /// @edge — une majorité plus exigeante que celle requise satisfait
906    /// toujours le seuil (l'unanimité n'est pas « moins » que les 2/3).
907    #[test]
908    fn edge_unanimite_satisfait_deux_tiers() {
909        assert!(MajorityType::Unanimity.satisfait(&MajorityType::TwoThirds));
910        assert!(MajorityType::FourFifths.satisfait(&MajorityType::TwoThirds));
911    }
912
913    /// @security — la majorité simple (absolue) ne satisfait PAS un seuil de
914    /// deux tiers : contourner le seuil qualifié par un vote simple doit être
915    /// détectable.
916    #[test]
917    fn security_majorite_absolue_ne_satisfait_pas_deux_tiers() {
918        assert!(!MajorityType::Absolute.satisfait(&MajorityType::TwoThirds));
919    }
920
921    /// @negative — un seuil non atteint reste non atteint quel que soit le
922    /// sens de comparaison (pas de panic, résultat booléen stable).
923    #[test]
924    fn negative_quatre_cinquiemes_ne_satisfait_pas_unanimite() {
925        assert!(!MajorityType::FourFifths.satisfait(&MajorityType::Unanimity));
926    }
927
928    // ===== Resolution::is_auto_generated (Story 4.6, #581) =====
929
930    /// @happy — `generate_ago_resolutions` construit une résolution marquée
931    /// `EvaluationContractorsAuto`, immédiatement reconnue comme auto-générée.
932    #[test]
933    fn happy_resolution_evaluation_contractors_auto_est_auto_generee() {
934        let resolution = Resolution::new_evaluation_contractors_auto(Uuid::new_v4());
935        assert_eq!(resolution.kind, ResolutionKind::EvaluationContractorsAuto);
936        assert!(resolution.is_auto_generated());
937        assert_eq!(resolution.status, ResolutionStatus::Pending);
938    }
939
940    /// @edge — un syndic peut nommer une résolution ordinaire du même titre ;
941    /// seul `kind` fait foi, jamais un intitulé qui pourrait coïncider.
942    #[test]
943    fn edge_resolution_standard_homonyme_nest_pas_auto_generee() {
944        let resolution = Resolution::new(
945            Uuid::new_v4(),
946            "Évaluation des prestataires".to_string(),
947            "Point ajouté manuellement par le syndic".to_string(),
948            ResolutionType::Ordinary,
949            MajorityType::Absolute,
950            None,
951        )
952        .unwrap();
953        assert!(!resolution.is_auto_generated());
954    }
955
956    /// @security — le prédicat qui protège la résolution contre la
957    /// suppression/l'édition ne se fie qu'à `kind` : ni un changement de
958    /// statut ni un renommage ne peuvent lever ou forger la protection.
959    #[test]
960    fn security_is_auto_generated_repose_uniquement_sur_kind() {
961        let mut resolution = Resolution::new_evaluation_contractors_auto(Uuid::new_v4());
962        resolution.status = ResolutionStatus::Adopted;
963        resolution.title = "Renommée par un tiers".to_string();
964        assert!(resolution.is_auto_generated());
965    }
966
967    /// @negative — deux résolutions auto-générées pour deux réunions
968    /// distinctes restent deux entités séparées (pas d'ID ni de réunion
969    /// partagés par erreur de construction).
970    #[test]
971    fn negative_deux_resolutions_auto_generees_ne_partagent_rien() {
972        let meeting_a = Uuid::new_v4();
973        let meeting_b = Uuid::new_v4();
974        let a = Resolution::new_evaluation_contractors_auto(meeting_a);
975        let b = Resolution::new_evaluation_contractors_auto(meeting_b);
976        assert_ne!(a.id, b.id);
977        assert_eq!(a.meeting_id, meeting_a);
978        assert_eq!(b.meeting_id, meeting_b);
979    }
980
981    /// Une résolution sans aucun vote ne divise pas par zéro.
982    #[test]
983    fn sans_vote_les_pourcentages_valent_zero() {
984        let resolution = Resolution::new(
985            Uuid::new_v4(),
986            "Rien".to_string(),
987            "Aucun vote".to_string(),
988            ResolutionType::Ordinary,
989            MajorityType::Absolute,
990            Some(1),
991        )
992        .unwrap();
993
994        assert_eq!(resolution.pour_percentage(), 0.0);
995        assert_eq!(resolution.contre_percentage(), 0.0);
996        assert_eq!(resolution.abstention_percentage(), 0.0);
997    }
998}