Skip to main content

koprogo_api/application/use_cases/
compute_quorum_use_case.rs

1//! Story 4.1 — quorum agrégé (présentiel + distanciel + procurations) en
2//! `Decimal` strict, `AppError` typé (cluster coord #433 Decimal, #555
3//! Result typé — cf. `.claude/rules/CRITICAL.md` §4).
4//!
5//! Le seuil appliqué est celui de `Meeting::quotas_half_reached` : « au
6//! moins la moitié » (Art. 3.87 §5 CC) — borne INCLUSIVE, 50,0 % pile
7//! suffit. Aucun seuil légal n'est ré-écrit ici : cf. le commentaire de
8//! `AgSession::is_combined_quorum_reached` (`domain/copropriete/ag_session.rs`)
9//! sur le prix d'un seuil dupliqué (#661).
10//!
11//! Ce use-case ne juge que le volet **quotités** agrégé. Le volet **têtes**
12//! du quorum double (Art. 3.87 §5) reste porté par
13//! `Meeting::assert_can_complete()` / `MeetingCompletionChecklist`, qui
14//! comptent les copropriétaires — pas les millièmes — et ne sont pas dans
15//! le périmètre de cette story.
16
17use crate::application::error::AppError;
18use crate::domain::entities::{Meeting, MeetingMode};
19use rust_decimal::Decimal;
20use rust_decimal_macros::dec;
21use serde::{Deserialize, Serialize};
22
23/// Entrées agrégées du quorum d'une AG hybride (Story 4.1).
24#[derive(Debug, Clone)]
25pub struct ComputeQuorumInput {
26    pub mode: MeetingMode,
27    /// Millièmes des copropriétaires présents physiquement.
28    pub in_person_quotas: Decimal,
29    /// Millièmes des copropriétaires connectés à distance.
30    pub remote_quotas: Decimal,
31    /// Millièmes représentés par procuration (Art. 3.87 §7 CC).
32    pub proxy_quotas: Decimal,
33    /// Total des millièmes du bâtiment/ACP.
34    pub total_quotas: Decimal,
35    /// Art. 3.87 §1er CC : la participation à distance suppose une identité
36    /// authentifiée fortement — cf. Story 4.2 (#577). Tant que 4.2 n'est pas
37    /// câblée, cette confirmation vient de l'appelant (session distancielle
38    /// vérifiée ou non). `compute_quorum` REFUSE de compter des quotités
39    /// distancielles non authentifiées plutôt que de les ignorer en
40    /// silence : un quorum silencieusement amputé serait aussi faux qu'un
41    /// quorum gonflé.
42    pub remote_strong_auth_confirmed: bool,
43}
44
45/// Résultat du calcul de quorum agrégé.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct QuorumResult {
48    pub attended_quotas: Decimal,
49    pub total_quotas: Decimal,
50    pub quorum_percentage: Decimal,
51    pub quorum_reached: bool,
52}
53
54/// Agrège présentiel + distanciel + procurations et juge le quorum
55/// (Art. 3.87 §5 CC — « au moins la moitié », borne inclusive).
56pub fn compute_quorum(input: ComputeQuorumInput) -> Result<QuorumResult, AppError> {
57    if input.total_quotas <= Decimal::ZERO {
58        return Err(AppError::Validation(
59            "Le total des quotités doit être positif".to_string(),
60        ));
61    }
62    for (label, value) in [
63        ("présentielles", input.in_person_quotas),
64        ("distancielles", input.remote_quotas),
65        ("de procuration", input.proxy_quotas),
66    ] {
67        if value < Decimal::ZERO {
68            return Err(AppError::Validation(format!(
69                "Les quotités {label} ne peuvent pas être négatives"
70            )));
71        }
72    }
73
74    // @security — Art. 3.87 §1er CC : une AG remote/hybride ne peut compter
75    // des quotités distancielles que si la connexion est authentifiée
76    // fortement (cf. Story 4.2 #577).
77    if matches!(input.mode, MeetingMode::Remote | MeetingMode::Hybrid)
78        && input.remote_quotas > Decimal::ZERO
79        && !input.remote_strong_auth_confirmed
80    {
81        return Err(AppError::Forbidden(
82            "Participation à distance sans authentification forte \
83             (Art. 3.87 §1er CC — cf. Story 4.2)"
84                .to_string(),
85        ));
86    }
87
88    let attended_quotas = input.in_person_quotas + input.remote_quotas + input.proxy_quotas;
89    if attended_quotas > input.total_quotas {
90        return Err(AppError::Validation(format!(
91            "Quorum agrégé invalide : {attended_quotas} quotités pour un total de {}",
92            input.total_quotas
93        )));
94    }
95
96    let quorum_percentage = (attended_quotas / input.total_quotas) * dec!(100);
97    let quorum_reached = Meeting::quotas_half_reached(attended_quotas, input.total_quotas);
98
99    Ok(QuorumResult {
100        attended_quotas,
101        total_quotas: input.total_quotas,
102        quorum_percentage,
103        quorum_reached,
104    })
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn base_input() -> ComputeQuorumInput {
112        ComputeQuorumInput {
113            mode: MeetingMode::Hybrid,
114            in_person_quotas: Decimal::ZERO,
115            remote_quotas: Decimal::ZERO,
116            proxy_quotas: Decimal::ZERO,
117            total_quotas: dec!(1000),
118            remote_strong_auth_confirmed: true,
119        }
120    }
121
122    // ------------------------------------------------------------------
123    // @happy
124    // ------------------------------------------------------------------
125
126    /// AC Story 4.1 : "10 présentiels + 5 distants + 3 procurations →
127    /// quorum agrégé OK selon Decimal somme".
128    #[test]
129    fn happy_hybrid_10_in_person_5_remote_3_proxy_matches_story_ac() {
130        let input = ComputeQuorumInput {
131            in_person_quotas: dec!(10),
132            remote_quotas: dec!(5),
133            proxy_quotas: dec!(3),
134            total_quotas: dec!(1000),
135            ..base_input()
136        };
137        let result = compute_quorum(input).unwrap();
138        assert_eq!(result.attended_quotas, dec!(18));
139        assert_eq!(result.quorum_percentage, dec!(1.8));
140    }
141
142    #[test]
143    fn happy_hybrid_quorum_reached_above_half() {
144        let input = ComputeQuorumInput {
145            in_person_quotas: dec!(400),
146            remote_quotas: dec!(150),
147            proxy_quotas: dec!(60),
148            ..base_input()
149        };
150        let result = compute_quorum(input).unwrap();
151        assert_eq!(result.attended_quotas, dec!(610));
152        assert!(result.quorum_reached);
153    }
154
155    // ------------------------------------------------------------------
156    // @edge — les deux côtés du seuil de 50%, testés séparément (sinon la
157    // borne n'est pas vraiment testée).
158    // ------------------------------------------------------------------
159
160    #[test]
161    fn edge_exactly_50_percent_is_reached() {
162        let input = ComputeQuorumInput {
163            in_person_quotas: dec!(300),
164            remote_quotas: dec!(150),
165            proxy_quotas: dec!(50),
166            ..base_input()
167        };
168        let result = compute_quorum(input).unwrap();
169        assert_eq!(result.quorum_percentage, dec!(50));
170        assert!(
171            result.quorum_reached,
172            "50,0% pile doit être respecté (Art. 3.87 §5 — au moins la moitié)"
173        );
174    }
175
176    #[test]
177    fn edge_49_99_percent_is_refused() {
178        let input = ComputeQuorumInput {
179            in_person_quotas: dec!(300),
180            remote_quotas: dec!(150),
181            proxy_quotas: dec!(49.9),
182            ..base_input()
183        };
184        let result = compute_quorum(input).unwrap();
185        assert_eq!(result.quorum_percentage, dec!(49.99));
186        assert!(!result.quorum_reached, "49,99% doit être refusé");
187    }
188
189    #[test]
190    fn edge_zero_total_quotas_is_rejected_not_panic() {
191        let input = ComputeQuorumInput {
192            total_quotas: Decimal::ZERO,
193            ..base_input()
194        };
195        let err = compute_quorum(input).unwrap_err();
196        assert!(matches!(err, AppError::Validation(_)));
197    }
198
199    // ------------------------------------------------------------------
200    // @security
201    // ------------------------------------------------------------------
202
203    #[test]
204    fn security_remote_without_strong_auth_is_forbidden() {
205        let input = ComputeQuorumInput {
206            mode: MeetingMode::Remote,
207            remote_quotas: dec!(500),
208            remote_strong_auth_confirmed: false,
209            ..base_input()
210        };
211        let err = compute_quorum(input).unwrap_err();
212        assert!(matches!(err, AppError::Forbidden(_)));
213    }
214
215    #[test]
216    fn security_hybrid_without_strong_auth_but_zero_remote_quotas_is_allowed() {
217        // Un hybride où personne ne s'est connecté à distance n'a rien à
218        // authentifier : refuser ici punirait une AG qui n'a jamais ouvert
219        // de session distancielle.
220        let input = ComputeQuorumInput {
221            mode: MeetingMode::Hybrid,
222            in_person_quotas: dec!(600),
223            remote_quotas: Decimal::ZERO,
224            remote_strong_auth_confirmed: false,
225            ..base_input()
226        };
227        assert!(compute_quorum(input).is_ok());
228    }
229
230    #[test]
231    fn security_forged_negative_proxy_quotas_are_rejected() {
232        let input = ComputeQuorumInput {
233            proxy_quotas: dec!(-1),
234            ..base_input()
235        };
236        let err = compute_quorum(input).unwrap_err();
237        assert!(matches!(err, AppError::Validation(_)));
238    }
239
240    // ------------------------------------------------------------------
241    // @negative
242    // ------------------------------------------------------------------
243
244    #[test]
245    fn negative_attended_exceeds_total_is_rejected() {
246        let input = ComputeQuorumInput {
247            in_person_quotas: dec!(900),
248            remote_quotas: dec!(200),
249            ..base_input()
250        };
251        let err = compute_quorum(input).unwrap_err();
252        assert!(matches!(err, AppError::Validation(_)));
253    }
254
255    #[test]
256    fn negative_negative_total_quotas_is_rejected() {
257        let input = ComputeQuorumInput {
258            total_quotas: dec!(-1000),
259            ..base_input()
260        };
261        let err = compute_quorum(input).unwrap_err();
262        assert!(matches!(err, AppError::Validation(_)));
263    }
264}