Skip to main content

koprogo_api/domain/copropriete/
procurations.rs

1//! Les plafonds de procuration à l'assemblée générale.
2//!
3//! Art. 3.87 § 7 énonce trois règles distinctes, souvent confondues :
4//!
5//! > « Nul ne peut prendre part au vote, même comme mandant ou mandataire,
6//! > pour un nombre de voix **supérieur à la somme des voix dont disposent les
7//! > autres copropriétaires** présents ou représentés. »
8//!
9//! > « Nul ne peut accepter **plus de trois procurations** de vote. Toutefois,
10//! > un mandataire peut recevoir plus de trois procurations de vote si le total
11//! > des voix dont il dispose lui-même et de celles de ses mandants **n'excède
12//! > pas 10 %** du total des voix affectées à l'ensemble des lots de la
13//! > copropriété. »
14//!
15//! > « Le syndic ne peut intervenir comme mandataire d'un copropriétaire à
16//! > l'assemblée générale, nonobstant le droit pour lui, s'il est
17//! > copropriétaire, de participer à ce titre aux délibérations. »
18//!
19//! Ces règles ne portent pas sur un vote isolé mais sur **l'ensemble des voix
20//! exprimées** : on ne peut pas les vérifier en construisant un `Vote`, il
21//! faut regarder la séance entière. D'où un service de domaine plutôt qu'une
22//! validation d'entité.
23//!
24//! Elles ne sont pas décoratives. Une assemblée tenue en violation de l'une
25//! d'elles est attaquable, et ce sont ses décisions — donc des travaux, des
26//! budgets, des mandats — qui tombent avec elle.
27//!
28//! Voir issue #742.
29
30use super::vote::Vote;
31use rust_decimal::Decimal;
32use std::collections::HashMap;
33use uuid::Uuid;
34
35/// Ce que la loi refuse, avec de quoi le dire à celui qui préside.
36#[derive(Debug, Clone, PartialEq)]
37pub enum ProcurationRefusee {
38    /// Plus de trois procurations, sans bénéficier de l'exception des 10 %.
39    TropDeProcurations {
40        mandataire: Uuid,
41        recues: usize,
42        part_des_voix: Decimal,
43    },
44    /// Le syndic a voté comme mandataire d'un copropriétaire.
45    SyndicMandataire { mandats: usize },
46}
47
48impl std::fmt::Display for ProcurationRefusee {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::TropDeProcurations {
52                mandataire,
53                recues,
54                part_des_voix,
55            } => write!(
56                f,
57                "Art. 3.87 § 7 : le mandataire {mandataire} détient {recues} procurations \
58                 et pèse {part_des_voix} % des voix. Au-delà de trois procurations, \
59                 l'exception ne joue que sous 10 %."
60            ),
61            Self::SyndicMandataire { mandats } => write!(
62                f,
63                "Art. 3.87 § 7 : le syndic ne peut être mandataire d'un copropriétaire \
64                 ({mandats} mandat(s) détenu(s))."
65            ),
66        }
67    }
68}
69
70/// Le plafond de procurations, hors exception.
71const PROCURATIONS_MAX: usize = 3;
72
73/// Le seuil de l'exception, en pourcentage du total des lots.
74fn seuil_exception(total_des_lots: Decimal) -> Decimal {
75    total_des_lots * Decimal::from(10) / Decimal::from(100)
76}
77
78/// Les voix rassemblées par chaque personne qui prend part au vote.
79///
80/// Un mandataire pèse ses propres voix **et** celles de ses mandants : c'est
81/// bien « le total des voix dont il dispose lui-même et de celles de ses
82/// mandants » que la loi vise.
83fn voix_par_votant(votes: &[Vote]) -> HashMap<Uuid, Decimal> {
84    let mut par_votant: HashMap<Uuid, Decimal> = HashMap::new();
85    for vote in votes {
86        *par_votant
87            .entry(vote.effective_voter_id())
88            .or_insert(Decimal::ZERO) += vote.voting_power;
89    }
90    par_votant
91}
92
93/// Les voix qu'une personne engage dans le vote, à quelque titre que ce soit.
94///
95/// Le texte vise « même comme **mandant** ou mandataire ». Une personne engage
96/// donc : ses propres lots, même confiés à un mandataire, **et** les lots
97/// qu'elle porte pour autrui.
98///
99/// Grouper par mandataire seul laissait passer un contournement documenté :
100/// un copropriétaire majoritaire désignait un mandataire différent par lot,
101/// de sorte qu'aucun d'eux ne dépassait le seuil pris isolément. La doctrine
102/// belge le juge non conforme, le mandat étant lié à la personne du
103/// copropriétaire et non au bien.
104///
105/// Pour une personne X donnée, chaque bulletin tombe d'un seul côté : soit X y
106/// est engagée, soit non. Il n'y a donc pas de double compte dans la
107/// comparaison entre X et le reste.
108fn voix_engagees_par_personne(votes: &[Vote]) -> HashMap<Uuid, Decimal> {
109    let mut engagees: HashMap<Uuid, Decimal> = HashMap::new();
110    for vote in votes {
111        *engagees.entry(vote.owner_id).or_insert(Decimal::ZERO) += vote.voting_power;
112        if let Some(mandataire) = vote.proxy_owner_id {
113            if mandataire != vote.owner_id {
114                *engagees.entry(mandataire).or_insert(Decimal::ZERO) += vote.voting_power;
115            }
116        }
117    }
118    engagees
119}
120
121/// Le nombre de procurations acceptées par chaque mandataire.
122fn procurations_par_mandataire(votes: &[Vote]) -> HashMap<Uuid, usize> {
123    let mut par_mandataire: HashMap<Uuid, usize> = HashMap::new();
124    for vote in votes.iter().filter(|v| v.is_proxy_vote()) {
125        if let Some(mandataire) = vote.proxy_owner_id {
126            *par_mandataire.entry(mandataire).or_insert(0) += 1;
127        }
128    }
129    par_mandataire
130}
131
132/// L'écart entre ce qu'un votant pesait et ce qui lui a été retenu.
133///
134/// Conservé pour que l'ACP puisse répondre de son décompte : si la décision
135/// est attaquée, il faut pouvoir montrer que la règle a été appliquée, et de
136/// combien. Un plafonnement silencieux serait indéfendable.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct EcartDePlafond {
139    pub votant: Uuid,
140    /// Ce dont le votant disposait, procurations comprises.
141    pub voix_brutes: Decimal,
142    /// Ce qui a été retenu : la somme des voix des autres.
143    pub voix_retenues: Decimal,
144}
145
146/// Le décompte d'une séance après application de l'Art. 3.87 § 7 al. 4.
147#[derive(Debug, Clone, Default)]
148pub struct DecompteDesVoix {
149    retenues: HashMap<Uuid, Decimal>,
150    ecarts: Vec<EcartDePlafond>,
151}
152
153impl DecompteDesVoix {
154    /// Les voix retenues pour un votant, plafonnement compris.
155    pub fn voix(&self, votant: Uuid) -> Decimal {
156        self.retenues.get(&votant).copied().unwrap_or(Decimal::ZERO)
157    }
158
159    /// Les plafonnements appliqués. Vide quand personne n'était majoritaire.
160    pub fn ecarts(&self) -> &[EcartDePlafond] {
161        &self.ecarts
162    }
163}
164
165/// Applique l'Art. 3.87 § 7 al. 4 : « Nul ne peut prendre part au vote, même
166/// comme mandant ou mandataire, pour un nombre de voix supérieur à la somme
167/// des voix dont disposent les autres copropriétaires présents ou
168/// représentés. »
169///
170/// Le texte interdit de voter **pour** un nombre de voix supérieur ; il ne
171/// frappe pas la séance de nullité. Le décompte du majoritaire est ramené à la
172/// somme des autres, et l'assemblée délibère là-dessus. Refuser de clore
173/// rendrait ingouvernable toute copropriété où un seul détient la majorité,
174/// situation licite et fréquente.
175///
176/// Au plus un votant peut être plafonné : dépasser la somme des autres, c'est
177/// dépasser la moitié du total, et deux personnes ne le peuvent pas ensemble.
178///
179/// Cas limite du votant unique : la somme des autres vaut zéro. Le ramener à
180/// zéro viderait la séance de tout sens. C'est le quorum de l'Art. 3.87 § 5
181/// qui traite ce cas, pas cet alinéa-ci.
182pub fn plafonner_les_voix(votes: &[Vote]) -> DecompteDesVoix {
183    let brutes = voix_engagees_par_personne(votes);
184    // Le dénominateur est l'ensemble des voix présentes ou représentées, une
185    // seule fois chacune — pas la somme des engagements, qui compte deux fois
186    // un lot confié à un mandataire.
187    let total: Decimal = votes.iter().map(|v| v.voting_power).sum();
188
189    let mut retenues = HashMap::with_capacity(brutes.len());
190    let mut ecarts = Vec::new();
191
192    for (votant, poids) in brutes {
193        let reste = total - poids;
194        if poids > reste && !reste.is_zero() {
195            ecarts.push(EcartDePlafond {
196                votant,
197                voix_brutes: poids,
198                voix_retenues: reste,
199            });
200            retenues.insert(votant, reste);
201        } else {
202            retenues.insert(votant, poids);
203        }
204    }
205
206    DecompteDesVoix { retenues, ecarts }
207}
208
209/// Répartit le plafonnement sur chaque bulletin, dans l'ordre des votes reçus.
210///
211/// Un mandataire peut voter « pour » son propre lot et « contre » celui d'un
212/// mandant. Quand il est plafonné, il faut décider comment l'écart se répartit
213/// entre ces sens. **La loi ne le dit pas.** Le choix retenu est la réduction
214/// proportionnelle : chaque bulletin conserve la même part relative, donc
215/// l'arbitrage du votant est préservé. Les deux autres lectures possibles —
216/// retrancher d'abord des « pour », ou d'abord des « contre » — feraient
217/// pencher le résultat dans un sens que rien ne justifie.
218///
219/// Rend un poids retenu par vote, dans le même ordre que `votes`.
220pub fn repartir_le_plafond(votes: &[Vote], decompte: &DecompteDesVoix) -> Vec<Decimal> {
221    let brutes = voix_engagees_par_personne(votes);
222
223    // Le couple (retenu, brut) d'une personne. `None` si elle n'est pas
224    // plafonnée.
225    let plafond = |personne: Uuid| -> Option<(Decimal, Decimal)> {
226        let brut = brutes.get(&personne).copied().unwrap_or(Decimal::ZERO);
227        if brut.is_zero() {
228            return None;
229        }
230        let retenu = decompte.voix(personne);
231        if retenu == brut {
232            None
233        } else {
234            Some((retenu, brut))
235        }
236    };
237
238    votes
239        .iter()
240        .map(|v| {
241            // Un bulletin engage son propriétaire ET son mandataire. Si les
242            // deux sont plafonnés, c'est la réduction la plus forte qui
243            // s'applique : retenir la plus douce laisserait l'un des deux
244            // dépasser la somme des autres, ce que le texte interdit à chacun
245            // pour son propre compte.
246            let mut retenu_brut = plafond(v.owner_id);
247            if let Some(mandataire) = v.proxy_owner_id {
248                if let Some((r2, b2)) = plafond(mandataire) {
249                    // Comparaison de deux fractions sans les évaluer :
250                    // r2/b2 < r1/b1  ⟺  r2·b1 < r1·b2.
251                    let plus_severe = match retenu_brut {
252                        None => true,
253                        Some((r1, b1)) => r2 * b1 < r1 * b2,
254                    };
255                    if plus_severe {
256                        retenu_brut = Some((r2, b2));
257                    }
258                }
259            }
260
261            match retenu_brut {
262                None => v.voting_power,
263                // MULTIPLIER AVANT DE DIVISER.
264                //
265                // Calculer d'abord le ratio `retenu / brut` puis multiplier
266                // laisse une traîne d'arrondi : 550 × (450/550) donne
267                // 450,00000000000000000000000001. Ce résidu de 10⁻²⁶ suffit à
268                // transformer une ÉGALITÉ en majorité — 450 contre 450
269                // devenait « adopté » alors que l'Art. 3.88 § 1er exige PLUS
270                // de la moitié des voix exprimées.
271                //
272                // Constaté en recette le 2026-09-04 sur une vraie résolution.
273                // L'ADR-0008 impose `Decimal` pour éviter exactement cela ; le
274                // type ne suffit pas, l'ordre des opérations compte aussi.
275                Some((retenu, brut)) => v.voting_power * retenu / brut,
276            }
277        })
278        .collect()
279}
280
281/// Vérifie les trois plafonds de l'Art. 3.87 § 7 sur une séance.
282///
283/// `total_des_lots` est le dénominateur de l'acte de base (Art. 3.85 § 1er),
284/// pas la somme des voix présentes : l'exception des 10 % se calcule sur
285/// « l'ensemble des lots de la copropriété », absents compris.
286///
287/// `syndic_owner_id` est l'identifiant du syndic **s'il est aussi
288/// copropriétaire**. Il peut voter pour son propre lot ; il ne peut pas porter
289/// celui d'un autre.
290pub fn verifier_procurations(
291    votes: &[Vote],
292    total_des_lots: Decimal,
293    syndic_owner_id: Option<Uuid>,
294) -> Result<(), ProcurationRefusee> {
295    let voix = voix_par_votant(votes);
296    let seuil = seuil_exception(total_des_lots);
297
298    // 1. Le syndic ne porte pas le lot d'un autre.
299    if let Some(syndic) = syndic_owner_id {
300        let mandats = votes
301            .iter()
302            .filter(|v| v.proxy_owner_id == Some(syndic) && v.owner_id != syndic)
303            .count();
304        if mandats > 0 {
305            return Err(ProcurationRefusee::SyndicMandataire { mandats });
306        }
307    }
308
309    // 2. Trois procurations, sauf à peser moins de 10 % du total des lots.
310    for (mandataire, recues) in procurations_par_mandataire(votes) {
311        if recues <= PROCURATIONS_MAX {
312            continue;
313        }
314        let poids = voix.get(&mandataire).copied().unwrap_or(Decimal::ZERO);
315        if poids > seuil {
316            let part = if total_des_lots.is_zero() {
317                Decimal::ZERO
318            } else {
319                poids * Decimal::from(100) / total_des_lots
320            };
321            return Err(ProcurationRefusee::TropDeProcurations {
322                mandataire,
323                recues,
324                part_des_voix: part.round_dp(2),
325            });
326        }
327    }
328
329    // L'alinéa 4 — « nul ne prend part au vote pour un nombre de voix
330    // supérieur à la somme des autres » — n'est PAS vérifié ici : ce n'est pas
331    // un refus mais un plafonnement, appliqué par `plafonner_les_voix`.
332    // Arbitrage humain du 2026-09-04 : le texte interdit de voter POUR un
333    // nombre de voix supérieur, il ne frappe pas la séance de nullité.
334
335    Ok(())
336}
337
338#[cfg(test)]
339mod tests {
340    // ── Art. 3.87 § 7 al. 4 — le plafonnement des voix ──────────────────────
341    //
342    // Le texte dit : « Nul ne peut prendre part au vote, même comme mandant ou
343    // mandataire, pour un nombre de voix supérieur à la somme des voix dont
344    // disposent les autres copropriétaires présents ou représentés. »
345    //
346    // Il interdit de VOTER POUR un nombre de voix supérieur ; il ne frappe pas
347    // la séance de nullité. Le décompte du majoritaire est donc ramené à la
348    // somme des autres, et l'assemblée délibère sur ce décompte corrigé. Une
349    // copropriété où un seul détient la majorité est licite et courante :
350    // refuser de clore la rendrait ingouvernable.
351    //
352    // L'écart est conservé. Si la décision est attaquée, l'ACP doit pouvoir
353    // montrer qu'elle a appliqué la règle, et de combien.
354
355    #[test]
356    fn security_le_majoritaire_ne_contourne_pas_le_plafond_en_eclatant_ses_procurations() {
357        // Le texte vise « même comme MANDANT ou mandataire ». Le mandant reste
358        // donc plafonné pour ses propres voix, quel que soit le nombre de
359        // mandataires entre lesquels il les répartit.
360        //
361        // Le contournement a été tenté en pratique : désigner un mandataire
362        // distinct par lot, de sorte qu'aucun d'eux ne dépasse le seuil pris
363        // isolément. La doctrine belge le juge non conforme — le mandat est
364        // lié à la personne du copropriétaire, pas au bien.
365        // Voir propertytoday.be, « Sens et non-sens de la réduction de vote de
366        // l'art. 3.87 § 7 Cc », consulté le 2026-09-04.
367        let majoritaire = Uuid::new_v4();
368        let autre_a = Uuid::new_v4();
369        let autre_b = Uuid::new_v4();
370        let votes = vec![
371            // 600 voix éclatées entre trois mandataires différents.
372            vote(majoritaire, dec!(200), Some(Uuid::new_v4())),
373            vote(majoritaire, dec!(200), Some(Uuid::new_v4())),
374            vote(majoritaire, dec!(200), Some(Uuid::new_v4())),
375            vote(autre_a, dec!(250), None),
376            vote(autre_b, dec!(150), None),
377        ];
378
379        let decompte = plafonner_les_voix(&votes);
380
381        let ecarts = decompte.ecarts();
382        assert_eq!(
383            ecarts.len(),
384            1,
385            "le mandant est plafonné, pas ses mandataires"
386        );
387        assert_eq!(ecarts[0].votant, majoritaire);
388        assert_eq!(ecarts[0].voix_brutes, dec!(600));
389        assert_eq!(ecarts[0].voix_retenues, dec!(400), "250 + 150");
390    }
391
392    #[test]
393    fn le_plafond_se_repartit_proportionnellement_entre_les_sens() {
394        // Le mandataire pèse 800 (200 pour lui, 600 portés), les autres 400.
395        // Il est ramené à 400, soit la moitié. Chacun de ses bulletins est
396        // réduit dans la même proportion : son arbitrage relatif entre
397        // « pour » et « contre » reste intact.
398        //
399        // Le rapport est choisi exact (1/2) à dessein. Avec 2/3, la valeur
400        // attendue et la valeur calculée diffèrent au dernier chiffre selon
401        // l'ordre des opérations, et le test mesurerait l'arrondi de
402        // `Decimal` plutôt que la règle de droit.
403        let mandataire = Uuid::new_v4();
404        let mandant = Uuid::new_v4();
405        let autre = Uuid::new_v4();
406        let votes = vec![
407            vote(mandataire, dec!(200), None),
408            vote(mandant, dec!(600), Some(mandataire)),
409            vote(autre, dec!(400), None),
410        ];
411
412        let decompte = plafonner_les_voix(&votes);
413        let retenus = repartir_le_plafond(&votes, &decompte);
414
415        assert_eq!(retenus[0], dec!(100), "200 ramenés de moitié");
416        assert_eq!(retenus[1], dec!(300), "600 ramenés de moitié");
417        assert_eq!(retenus[2], dec!(400), "les autres ne bougent pas");
418        assert_eq!(retenus[0] + retenus[1], dec!(400), "total ramené au reste");
419    }
420
421    #[test]
422    fn le_majoritaire_est_ramene_a_la_somme_des_autres() {
423        let majoritaire = Uuid::new_v4();
424        let autre_a = Uuid::new_v4();
425        let autre_b = Uuid::new_v4();
426        let votes = vec![
427            vote(majoritaire, dec!(600), None),
428            vote(autre_a, dec!(250), None),
429            vote(autre_b, dec!(150), None),
430        ];
431
432        let decompte = plafonner_les_voix(&votes);
433
434        assert_eq!(decompte.voix(majoritaire), dec!(400), "ramené à 250 + 150");
435        assert_eq!(decompte.voix(autre_a), dec!(250), "les autres sont intacts");
436        assert_eq!(decompte.voix(autre_b), dec!(150));
437    }
438
439    #[test]
440    fn lecart_est_conserve_pour_pouvoir_en_repondre() {
441        let majoritaire = Uuid::new_v4();
442        let autre = Uuid::new_v4();
443        let votes = vec![
444            vote(majoritaire, dec!(600), None),
445            vote(autre, dec!(400), None),
446        ];
447
448        let decompte = plafonner_les_voix(&votes);
449
450        let ecarts = decompte.ecarts();
451        assert_eq!(ecarts.len(), 1, "un seul votant plafonné");
452        assert_eq!(ecarts[0].votant, majoritaire);
453        assert_eq!(ecarts[0].voix_brutes, dec!(600));
454        assert_eq!(ecarts[0].voix_retenues, dec!(400));
455    }
456
457    #[test]
458    fn sans_majoritaire_rien_nest_plafonne() {
459        let a = Uuid::new_v4();
460        let b = Uuid::new_v4();
461        let c = Uuid::new_v4();
462        let votes = vec![
463            vote(a, dec!(400), None),
464            vote(b, dec!(350), None),
465            vote(c, dec!(250), None),
466        ];
467
468        let decompte = plafonner_les_voix(&votes);
469
470        assert!(
471            decompte.ecarts().is_empty(),
472            "personne ne pèse plus que le reste"
473        );
474        assert_eq!(decompte.voix(a), dec!(400));
475    }
476
477    #[test]
478    fn le_mandataire_pese_ses_voix_et_celles_de_ses_mandants() {
479        // Un mandataire qui porte assez de procurations pour devenir
480        // majoritaire est plafonné comme n'importe qui : la loi vise le
481        // mandant COMME le mandataire.
482        let mandataire = Uuid::new_v4();
483        let mandant_a = Uuid::new_v4();
484        let mandant_b = Uuid::new_v4();
485        let isole = Uuid::new_v4();
486        let votes = vec![
487            vote(mandataire, dec!(200), None),
488            vote(mandant_a, dec!(250), Some(mandataire)),
489            vote(mandant_b, dec!(250), Some(mandataire)),
490            vote(isole, dec!(300), None),
491        ];
492
493        let decompte = plafonner_les_voix(&votes);
494
495        assert_eq!(decompte.voix(mandataire), dec!(300), "700 ramenés à 300");
496        assert_eq!(decompte.voix(isole), dec!(300));
497    }
498
499    #[test]
500    fn une_seule_voix_exprimee_nest_pas_plafonnee_a_zero() {
501        // Cas limite : un unique votant présent. La somme des autres vaut
502        // zéro. Le plafonner à zéro viderait la séance de tout sens ; le
503        // quorum de l'Art. 3.87 § 5 est le garde-fou qui vaut ici, pas celui-ci.
504        let seul = Uuid::new_v4();
505        let votes = vec![vote(seul, dec!(500), None)];
506
507        let decompte = plafonner_les_voix(&votes);
508
509        assert_eq!(decompte.voix(seul), dec!(500));
510        assert!(decompte.ecarts().is_empty());
511    }
512
513    use super::*;
514    use crate::domain::copropriete::vote::VoteChoice;
515    use rust_decimal_macros::dec;
516
517    /// Mille millièmes, la convention la plus courante (Art. 3.85 § 1er).
518    const TOTAL_DES_LOTS: Decimal = dec!(1000);
519
520    fn vote(proprietaire: Uuid, voix: Decimal, mandataire: Option<Uuid>) -> Vote {
521        Vote::new(
522            Uuid::new_v4(),
523            proprietaire,
524            Uuid::new_v4(),
525            VoteChoice::Pour,
526            voix,
527            mandataire,
528        )
529        .expect("vote valide")
530    }
531
532    // ── Le syndic n'est pas mandataire (Art. 3.87 § 7, dernier alinéa) ──
533
534    #[test]
535    fn security_le_syndic_ne_porte_pas_le_lot_dun_autre() {
536        let syndic = Uuid::new_v4();
537        let votes = vec![
538            vote(Uuid::new_v4(), dec!(100), Some(syndic)),
539            vote(Uuid::new_v4(), dec!(100), None),
540        ];
541
542        let refus =
543            verifier_procurations(&votes, TOTAL_DES_LOTS, Some(syndic)).expect_err("doit refuser");
544        assert_eq!(refus, ProcurationRefusee::SyndicMandataire { mandats: 1 });
545    }
546
547    #[test]
548    fn happy_le_syndic_copropriétaire_vote_pour_son_propre_lot() {
549        let syndic = Uuid::new_v4();
550        // Il vote en son nom propre, sans procuration : `Vote::new` refuse
551        // d'ailleurs qu'on soit son propre mandataire, et c'est juste — se
552        // donner procuration à soi-même n'a pas de sens. La loi le prévoit
553        // expressément : « nonobstant le droit pour lui, s'il est
554        // copropriétaire, de participer à ce titre aux délibérations ».
555        let votes = vec![
556            vote(syndic, dec!(100), None),
557            vote(Uuid::new_v4(), dec!(120), None),
558            vote(Uuid::new_v4(), dec!(110), None),
559        ];
560
561        assert!(verifier_procurations(&votes, TOTAL_DES_LOTS, Some(syndic)).is_ok());
562    }
563
564    // ── Le plafond de trois procurations et son exception ──────────────
565
566    #[test]
567    fn happy_trois_procurations_passent_quel_que_soit_le_poids() {
568        let mandataire = Uuid::new_v4();
569        let mut votes: Vec<Vote> = (0..3)
570            .map(|_| vote(Uuid::new_v4(), dec!(150), Some(mandataire)))
571            .collect();
572        // 450/1000 = 45 %, bien au-dessus des 10 %, et pourtant licite :
573        // l'exception ne sert qu'À PARTIR de la quatrième procuration.
574        //
575        // Le reste est réparti sur trois votants plutôt que concentré sur un
576        // seul : un copropriétaire pesant 550 sur 1000 tomberait sous la
577        // troisième règle, celle du poids. Elle est plus mordante qu'il n'y
578        // paraît — un majoritaire ne peut jamais emporter un vote seul.
579        votes.push(vote(Uuid::new_v4(), dec!(200), None));
580        votes.push(vote(Uuid::new_v4(), dec!(200), None));
581        votes.push(vote(Uuid::new_v4(), dec!(150), None));
582
583        assert!(verifier_procurations(&votes, TOTAL_DES_LOTS, None).is_ok());
584    }
585
586    #[test]
587    fn negative_quatre_procurations_au_dessus_de_dix_pourcents_sont_refusees() {
588        let mandataire = Uuid::new_v4();
589        let mut votes: Vec<Vote> = (0..4)
590            .map(|_| vote(Uuid::new_v4(), dec!(50), Some(mandataire)))
591            .collect();
592        votes.push(vote(Uuid::new_v4(), dec!(800), None));
593
594        // 200/1000 = 20 % > 10 %.
595        let refus = verifier_procurations(&votes, TOTAL_DES_LOTS, None).expect_err("doit refuser");
596        match refus {
597            ProcurationRefusee::TropDeProcurations {
598                recues,
599                part_des_voix,
600                ..
601            } => {
602                assert_eq!(recues, 4);
603                assert_eq!(part_des_voix, dec!(20.00));
604            }
605            autre => panic!("mauvais refus : {autre}"),
606        }
607    }
608
609    #[test]
610    fn happy_dix_procurations_sous_dix_pourcents_passent() {
611        let mandataire = Uuid::new_v4();
612        let mut votes: Vec<Vote> = (0..10)
613            .map(|_| vote(Uuid::new_v4(), dec!(9), Some(mandataire)))
614            .collect();
615        // 90/1000 = 9 % : l'exception joue, malgré dix procurations.
616        votes.push(vote(Uuid::new_v4(), dec!(310), None));
617        votes.push(vote(Uuid::new_v4(), dec!(300), None));
618        votes.push(vote(Uuid::new_v4(), dec!(300), None));
619
620        assert!(verifier_procurations(&votes, TOTAL_DES_LOTS, None).is_ok());
621    }
622
623    #[test]
624    fn edge_exactement_dix_pourcents_passe() {
625        let mandataire = Uuid::new_v4();
626        let mut votes: Vec<Vote> = (0..4)
627            .map(|_| vote(Uuid::new_v4(), dec!(25), Some(mandataire)))
628            .collect();
629        // 100/1000 = 10 % PILE. La loi écrit « n'excède pas 10 % » : la borne
630        // est inclusive, contrairement au quorum des trois quarts (Art. 3.87
631        // § 5) où elle ne l'est pas.
632        votes.push(vote(Uuid::new_v4(), dec!(300), None));
633        votes.push(vote(Uuid::new_v4(), dec!(300), None));
634        votes.push(vote(Uuid::new_v4(), dec!(300), None));
635
636        assert!(verifier_procurations(&votes, TOTAL_DES_LOTS, None).is_ok());
637    }
638
639    // ── Nul ne pèse plus que tous les autres réunis ────────────────────
640
641    #[test]
642    fn un_votant_qui_depasse_la_somme_des_autres_est_plafonne_pas_refuse() {
643        // Anciennement `negative_un_votant_ne_depasse_pas_la_somme_des_autres`,
644        // qui attendait un refus. Arbitrage humain du 2026-09-04 : l'Art. 3.87
645        // § 7 al. 4 plafonne, il n'annule pas. La séance reste valide.
646        let dominant = Uuid::new_v4();
647        let votes = vec![
648            vote(dominant, dec!(600), None),
649            vote(Uuid::new_v4(), dec!(200), None),
650            vote(Uuid::new_v4(), dec!(199), None),
651        ];
652
653        verifier_procurations(&votes, TOTAL_DES_LOTS, None)
654            .expect("le poids ne fait plus obstacle à la clôture");
655
656        let decompte = plafonner_les_voix(&votes);
657        assert_eq!(decompte.voix(dominant), dec!(399), "ramené à 200 + 199");
658        assert_eq!(decompte.ecarts().len(), 1);
659    }
660
661    #[test]
662    fn edge_une_egalite_parfaite_passe() {
663        let votes = vec![
664            vote(Uuid::new_v4(), dec!(500), None),
665            vote(Uuid::new_v4(), dec!(300), None),
666            vote(Uuid::new_v4(), dec!(200), None),
667        ];
668        // 500 contre 500 : « supérieur à » exclut l'égalité.
669        assert!(verifier_procurations(&votes, TOTAL_DES_LOTS, None).is_ok());
670    }
671
672    #[test]
673    fn security_un_mandataire_ne_contourne_pas_le_plafond_en_cumulant() {
674        // Trois procurations seulement — le plafond des procurations passe —
675        // mais le mandataire rassemble la majorité absolue des voix exprimées.
676        // C'est le plafonnement de l'alinéa 4 qui le ramène à sa place.
677        let mandataire = Uuid::new_v4();
678        let mut votes = vec![vote(mandataire, dec!(100), None)];
679        votes.extend((0..3).map(|_| vote(Uuid::new_v4(), dec!(150), Some(mandataire))));
680        votes.push(vote(Uuid::new_v4(), dec!(300), None));
681
682        // 550 pour le mandataire, 300 pour le reste. La propriété de sécurité
683        // tient toujours : il ne peut pas emporter le vote à lui seul. Ce qui
684        // change depuis l'arbitrage du 2026-09-04, c'est le mécanisme — il est
685        // ramené au poids des autres, il n'est plus opposé un refus de clore.
686        verifier_procurations(&votes, TOTAL_DES_LOTS, None)
687            .expect("trois procurations : les plafonds de procuration passent");
688
689        let decompte = plafonner_les_voix(&votes);
690        assert_eq!(decompte.voix(mandataire), dec!(300), "550 ramenés à 300");
691    }
692
693    #[test]
694    fn happy_une_seance_ordinaire_ne_declenche_rien() {
695        let votes = vec![
696            vote(Uuid::new_v4(), dec!(250), None),
697            vote(Uuid::new_v4(), dec!(250), None),
698            vote(Uuid::new_v4(), dec!(250), None),
699            vote(Uuid::new_v4(), dec!(150), Some(Uuid::new_v4())),
700        ];
701        assert!(verifier_procurations(&votes, TOTAL_DES_LOTS, None).is_ok());
702    }
703}