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}