Skip to main content

koprogo_api/domain/copropriete/
age_request.rs

1use chrono::{DateTime, Duration, Utc};
2use rust_decimal::Decimal;
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Statut d'une demande d'AGE par les copropriétaires
8///
9/// Machine d'état (Art. 3.87 §2 CC):
10/// Draft → Open → Reached → Submitted → Accepted/Expired/Rejected
11/// Tout état → Withdrawn
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub enum AgeRequestStatus {
14    /// En cours de rédaction (collecte de signatures privée)
15    Draft,
16    /// Ouverte pour signatures (visible aux copropriétaires du bâtiment)
17    Open,
18    /// Seuil 1/5 atteint — prête à soumettre au syndic
19    Reached,
20    /// Soumise au syndic (délai 15j démarré)
21    Submitted,
22    /// Syndic a accepté de convoquer l'AGE
23    Accepted,
24    /// Délai syndic expiré — auto-convocation par les copropriétaires
25    Expired,
26    /// Syndic a refusé (avec motif)
27    Rejected,
28    /// Retirée par les demandeurs
29    Withdrawn,
30}
31
32impl AgeRequestStatus {
33    pub fn from_db_string(s: &str) -> Result<Self, String> {
34        match s {
35            "draft" => Ok(Self::Draft),
36            "open" => Ok(Self::Open),
37            "reached" => Ok(Self::Reached),
38            "submitted" => Ok(Self::Submitted),
39            "accepted" => Ok(Self::Accepted),
40            "expired" => Ok(Self::Expired),
41            "rejected" => Ok(Self::Rejected),
42            "withdrawn" => Ok(Self::Withdrawn),
43            _ => Err(format!("Unknown age_request_status: {}", s)),
44        }
45    }
46
47    pub fn to_db_str(&self) -> &'static str {
48        match self {
49            Self::Draft => "draft",
50            Self::Open => "open",
51            Self::Reached => "reached",
52            Self::Submitted => "submitted",
53            Self::Accepted => "accepted",
54            Self::Expired => "expired",
55            Self::Rejected => "rejected",
56            Self::Withdrawn => "withdrawn",
57        }
58    }
59
60    /// Retourne true si l'état est terminal (ne peut plus évoluer)
61    pub fn is_terminal(&self) -> bool {
62        matches!(
63            self,
64            Self::Accepted | Self::Expired | Self::Rejected | Self::Withdrawn
65        )
66    }
67}
68
69/// Cosignataire d'une demande d'AGE
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct AgeRequestCosignatory {
72    pub id: Uuid,
73    pub age_request_id: Uuid,
74    pub owner_id: Uuid,
75    /// Quote-part de ce copropriétaire (0.0 à 1.0, ex: 0.10 = 10%) — Decimal exact (ADR-0008)
76    pub shares_pct: Decimal,
77    pub signed_at: DateTime<Utc>,
78}
79
80impl AgeRequestCosignatory {
81    pub fn new(age_request_id: Uuid, owner_id: Uuid, shares_pct: Decimal) -> Result<Self, String> {
82        if shares_pct <= Decimal::ZERO || shares_pct > Decimal::ONE {
83            return Err(format!(
84                "shares_pct doit être entre 0 et 1, reçu: {}",
85                shares_pct
86            ));
87        }
88        Ok(Self {
89            id: Uuid::new_v4(),
90            age_request_id,
91            owner_id,
92            shares_pct,
93            signed_at: Utc::now(),
94        })
95    }
96}
97
98/// Demande d'Assemblée Générale Extraordinaire par les copropriétaires
99///
100/// Art. 3.87 §2 Code Civil Belge :
101/// "Tout copropriétaire peut demander au syndic de convoquer une assemblée générale.
102/// Si la demande émane d'un ou de plusieurs copropriétaires représentant au moins
103/// un cinquième des quotes-parts dans les parties communes, le syndic est tenu de
104/// convoquer cette assemblée."
105///
106/// Délai syndic : 15 jours pour répondre/agir, sinon les demandeurs peuvent
107/// convoquer eux-mêmes l'assemblée.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct AgeRequest {
110    pub id: Uuid,
111    pub organization_id: Uuid,
112    pub building_id: Uuid,
113
114    /// Objet/titre de la demande d'AGE
115    pub title: String,
116
117    /// Description détaillée des raisons de la demande
118    pub description: Option<String>,
119
120    /// Statut courant de la demande
121    pub status: AgeRequestStatus,
122
123    /// Copropriétaire initiateur
124    pub created_by: Uuid,
125
126    /// Liste des cosignataires
127    pub cosignatories: Vec<AgeRequestCosignatory>,
128
129    /// Total des quotes-parts des cosignataires (0.0 à 1.0) — Decimal exact (ADR-0008)
130    pub total_shares_pct: Decimal,
131
132    /// Seuil légal à atteindre (0.2 = 20% = 1/5, Art. 3.87 §2) — Decimal exact (ADR-0008)
133    pub threshold_pct: Decimal,
134
135    /// Seuil atteint ?
136    pub threshold_reached: bool,
137
138    /// Date à laquelle le seuil a été atteint
139    pub threshold_reached_at: Option<DateTime<Utc>>,
140
141    /// Date de soumission formelle au syndic
142    pub submitted_to_syndic_at: Option<DateTime<Utc>>,
143
144    /// Délai imparti au syndic (soumission + 15j)
145    pub syndic_deadline_at: Option<DateTime<Utc>>,
146
147    /// Date de réponse du syndic
148    pub syndic_response_at: Option<DateTime<Utc>>,
149
150    /// Notes du syndic (raison d'acceptation, de refus, etc.)
151    pub syndic_notes: Option<String>,
152
153    /// Auto-convocation déclenchée car syndic inactif > 15j
154    pub auto_convocation_triggered: bool,
155
156    /// Réunion AG convoquée (set lors de l'acceptation ou l'expiration)
157    pub meeting_id: Option<Uuid>,
158
159    /// Sondage de concertation pré-AGE (lien vers Poll)
160    pub concertation_poll_id: Option<Uuid>,
161
162    pub created_at: DateTime<Utc>,
163    pub updated_at: DateTime<Utc>,
164}
165
166impl AgeRequest {
167    /// Délai légal accordé au syndic pour agir (Art. 3.87 §2 CC)
168    pub const SYNDIC_DEADLINE_DAYS: i64 = 15;
169
170    /// Seuil légal : 1/5 des quotes-parts (Art. 3.87 §2 CC)
171    pub const DEFAULT_THRESHOLD_PCT: Decimal = dec!(0.20);
172
173    /// Crée une nouvelle demande d'AGE
174    pub fn new(
175        organization_id: Uuid,
176        building_id: Uuid,
177        title: String,
178        description: Option<String>,
179        created_by: Uuid,
180    ) -> Result<Self, String> {
181        if title.trim().is_empty() {
182            return Err("Le titre de la demande d'AGE ne peut pas être vide".to_string());
183        }
184        if title.len() > 255 {
185            return Err("Le titre ne peut pas dépasser 255 caractères".to_string());
186        }
187
188        let now = Utc::now();
189        Ok(Self {
190            id: Uuid::new_v4(),
191            organization_id,
192            building_id,
193            title: title.trim().to_string(),
194            description,
195            status: AgeRequestStatus::Draft,
196            created_by,
197            cosignatories: Vec::new(),
198            total_shares_pct: Decimal::ZERO,
199            threshold_pct: Self::DEFAULT_THRESHOLD_PCT,
200            threshold_reached: false,
201            threshold_reached_at: None,
202            submitted_to_syndic_at: None,
203            syndic_deadline_at: None,
204            syndic_response_at: None,
205            syndic_notes: None,
206            auto_convocation_triggered: false,
207            meeting_id: None,
208            concertation_poll_id: None,
209            created_at: now,
210            updated_at: now,
211        })
212    }
213
214    /// Ouvre la demande pour signatures publiques (Draft → Open)
215    pub fn open(&mut self) -> Result<(), String> {
216        if self.status != AgeRequestStatus::Draft {
217            return Err(format!(
218                "Impossible d'ouvrir une demande en statut {:?}",
219                self.status
220            ));
221        }
222        self.status = AgeRequestStatus::Open;
223        self.updated_at = Utc::now();
224        Ok(())
225    }
226
227    /// Ajoute un cosignataire et recalcule le total des quotes-parts
228    /// Retourne true si le seuil 1/5 vient d'être atteint
229    pub fn add_cosignatory(&mut self, owner_id: Uuid, shares_pct: Decimal) -> Result<bool, String> {
230        if self.status != AgeRequestStatus::Draft && self.status != AgeRequestStatus::Open {
231            return Err(format!(
232                "Impossible d'ajouter un cosignataire en statut {:?}",
233                self.status
234            ));
235        }
236
237        // Vérifier si ce copropriétaire a déjà signé
238        if self.cosignatories.iter().any(|c| c.owner_id == owner_id) {
239            return Err("Ce copropriétaire a déjà signé cette demande".to_string());
240        }
241
242        let cosignatory = AgeRequestCosignatory::new(self.id, owner_id, shares_pct)?;
243        self.cosignatories.push(cosignatory);
244
245        // Recalcul du total
246        self.total_shares_pct = self.cosignatories.iter().map(|c| c.shares_pct).sum();
247        self.updated_at = Utc::now();
248
249        // Vérification du seuil
250        let newly_reached = !self.threshold_reached && self.total_shares_pct >= self.threshold_pct;
251
252        if newly_reached {
253            self.threshold_reached = true;
254            self.threshold_reached_at = Some(Utc::now());
255            self.status = AgeRequestStatus::Reached;
256        }
257
258        Ok(newly_reached)
259    }
260
261    /// Retire un cosignataire et recalcule le total
262    pub fn remove_cosignatory(&mut self, owner_id: Uuid) -> Result<(), String> {
263        if self.status != AgeRequestStatus::Draft
264            && self.status != AgeRequestStatus::Open
265            && self.status != AgeRequestStatus::Reached
266        {
267            return Err(format!(
268                "Impossible de retirer un cosignataire en statut {:?}",
269                self.status
270            ));
271        }
272
273        let before_len = self.cosignatories.len();
274        self.cosignatories.retain(|c| c.owner_id != owner_id);
275
276        if self.cosignatories.len() == before_len {
277            return Err("Ce copropriétaire n'a pas signé cette demande".to_string());
278        }
279
280        // Recalcul
281        self.total_shares_pct = self.cosignatories.iter().map(|c| c.shares_pct).sum();
282        self.updated_at = Utc::now();
283
284        // Rétrograder si seuil plus atteint
285        if self.threshold_reached && self.total_shares_pct < self.threshold_pct {
286            self.threshold_reached = false;
287            self.threshold_reached_at = None;
288            self.status = AgeRequestStatus::Open; // Retour à Open (était peut-être Reached)
289        }
290
291        Ok(())
292    }
293
294    /// Soumet formellement la demande au syndic (Reached → Submitted)
295    pub fn submit_to_syndic(&mut self) -> Result<(), String> {
296        if self.status != AgeRequestStatus::Reached {
297            return Err(format!(
298                "La demande doit être en statut Reached pour être soumise (statut actuel: {:?}). \
299                 Le seuil d'1/5 des quotes-parts doit être atteint.",
300                self.status
301            ));
302        }
303
304        let now = Utc::now();
305        self.status = AgeRequestStatus::Submitted;
306        self.submitted_to_syndic_at = Some(now);
307        self.syndic_deadline_at = Some(now + Duration::days(Self::SYNDIC_DEADLINE_DAYS));
308        self.updated_at = now;
309        Ok(())
310    }
311
312    /// Syndic accepte la demande (Submitted → Accepted)
313    pub fn accept_by_syndic(&mut self, notes: Option<String>) -> Result<(), String> {
314        if self.status != AgeRequestStatus::Submitted {
315            return Err(format!(
316                "La demande doit être en statut Submitted pour être acceptée (statut actuel: {:?})",
317                self.status
318            ));
319        }
320        let now = Utc::now();
321        self.status = AgeRequestStatus::Accepted;
322        self.syndic_response_at = Some(now);
323        self.syndic_notes = notes;
324        self.updated_at = now;
325        Ok(())
326    }
327
328    /// Syndic rejette la demande avec motif (Submitted → Rejected)
329    pub fn reject_by_syndic(&mut self, reason: String) -> Result<(), String> {
330        if self.status != AgeRequestStatus::Submitted {
331            return Err(format!(
332                "La demande doit être en statut Submitted pour être rejetée (statut actuel: {:?})",
333                self.status
334            ));
335        }
336        if reason.trim().is_empty() {
337            return Err("Un motif de refus est obligatoire".to_string());
338        }
339        let now = Utc::now();
340        self.status = AgeRequestStatus::Rejected;
341        self.syndic_response_at = Some(now);
342        self.syndic_notes = Some(reason);
343        self.updated_at = now;
344        Ok(())
345    }
346
347    /// Déclenche l'auto-convocation car le syndic n'a pas répondu dans le délai (Submitted → Expired)
348    pub fn trigger_auto_convocation(&mut self) -> Result<(), String> {
349        if self.status != AgeRequestStatus::Submitted {
350            return Err(format!(
351                "La demande doit être en statut Submitted (statut actuel: {:?})",
352                self.status
353            ));
354        }
355
356        // Vérifier que le délai est effectivement dépassé
357        if let Some(deadline) = self.syndic_deadline_at {
358            if Utc::now() < deadline {
359                return Err(format!(
360                    "Le délai syndic n'est pas encore dépassé (expire le {})",
361                    deadline.format("%d/%m/%Y")
362                ));
363            }
364        }
365
366        self.status = AgeRequestStatus::Expired;
367        self.auto_convocation_triggered = true;
368        self.updated_at = Utc::now();
369        Ok(())
370    }
371
372    /// Retire la demande (tout état non terminal → Withdrawn)
373    pub fn withdraw(&mut self, requester_id: Uuid) -> Result<(), String> {
374        if self.status.is_terminal() {
375            return Err(format!(
376                "Impossible de retirer une demande en statut {:?}",
377                self.status
378            ));
379        }
380        // Seul l'initiateur peut retirer la demande
381        if self.created_by != requester_id {
382            return Err("Seul l'initiateur peut retirer cette demande".to_string());
383        }
384        self.status = AgeRequestStatus::Withdrawn;
385        self.updated_at = Utc::now();
386        Ok(())
387    }
388
389    /// Lie une réunion AG à cette demande
390    pub fn set_meeting(&mut self, meeting_id: Uuid) {
391        self.meeting_id = Some(meeting_id);
392        self.updated_at = Utc::now();
393    }
394
395    /// Lie un sondage de concertation pré-AGE à cette demande
396    pub fn set_concertation_poll(&mut self, poll_id: Uuid) {
397        self.concertation_poll_id = Some(poll_id);
398        self.updated_at = Utc::now();
399    }
400
401    /// Vérifie si le délai syndic est dépassé
402    pub fn is_deadline_expired(&self) -> bool {
403        self.syndic_deadline_at
404            .map(|d| Utc::now() > d)
405            .unwrap_or(false)
406    }
407
408    /// Retourne le pourcentage manquant pour atteindre le seuil (0.0 si déjà atteint)
409    pub fn shares_pct_missing(&self) -> Decimal {
410        if self.threshold_reached {
411            Decimal::ZERO
412        } else {
413            (self.threshold_pct - self.total_shares_pct).max(Decimal::ZERO)
414        }
415    }
416
417    /// Calcule le progrès vers le seuil 1/5 en pourcentage (0-100%)
418    ///
419    /// Exemple : Si 10% des quotes-parts ont signé et le seuil est 20%,
420    /// retourne 50.0 (50% du chemin vers le seuil).
421    ///
422    /// # Arguments
423    /// * `building_total_shares` - Total des quotes-parts du bâtiment (normalement 1.0)
424    ///
425    /// # Returns
426    /// Pourcentage de progression : 0.0 (0%) à 100.0 (seuil atteint ou dépassé)
427    pub fn calculate_progress_percentage(&self, _building_total_shares: f64) -> f64 {
428        use rust_decimal::prelude::ToPrimitive;
429        // Calcul exact en Decimal : (current / threshold) * 100, capped at 100%
430        // (valeur d'affichage uniquement — la décision de seuil reste en Decimal exact)
431        if self.threshold_pct == Decimal::ZERO {
432            return 0.0;
433        }
434        let progress = (self.total_shares_pct / self.threshold_pct) * dec!(100);
435        progress.min(dec!(100)).to_f64().unwrap_or(0.0)
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn make_request() -> AgeRequest {
444        AgeRequest::new(
445            Uuid::new_v4(),
446            Uuid::new_v4(),
447            "Remplacement toiture - AGE urgente".to_string(),
448            Some("La toiture présente des infiltrations importantes.".to_string()),
449            Uuid::new_v4(),
450        )
451        .unwrap()
452    }
453
454    #[test]
455    fn test_new_age_request_is_draft() {
456        let req = make_request();
457        assert_eq!(req.status, AgeRequestStatus::Draft);
458        assert_eq!(req.total_shares_pct, Decimal::ZERO);
459        assert!(!req.threshold_reached);
460        assert_eq!(req.threshold_pct, AgeRequest::DEFAULT_THRESHOLD_PCT);
461        assert!(req.cosignatories.is_empty());
462    }
463
464    #[test]
465    fn test_empty_title_rejected() {
466        let result = AgeRequest::new(
467            Uuid::new_v4(),
468            Uuid::new_v4(),
469            "   ".to_string(),
470            None,
471            Uuid::new_v4(),
472        );
473        assert!(result.is_err());
474    }
475
476    #[test]
477    fn test_open_transitions_from_draft() {
478        let mut req = make_request();
479        req.open().unwrap();
480        assert_eq!(req.status, AgeRequestStatus::Open);
481    }
482
483    #[test]
484    fn test_open_fails_if_not_draft() {
485        let mut req = make_request();
486        req.status = AgeRequestStatus::Reached;
487        assert!(req.open().is_err());
488    }
489
490    #[test]
491    fn test_add_cosignatory_accumulates_shares() {
492        let mut req = make_request();
493        req.open().unwrap();
494
495        let owner1 = Uuid::new_v4();
496        let newly_reached = req.add_cosignatory(owner1, dec!(0.10)).unwrap();
497        assert!(!newly_reached);
498        assert_eq!(req.total_shares_pct, dec!(0.10));
499        assert_eq!(req.status, AgeRequestStatus::Open);
500    }
501
502    #[test]
503    fn test_threshold_reached_at_20_percent() {
504        let mut req = make_request();
505        req.open().unwrap();
506
507        // Premier signataire : 10%
508        let o1 = Uuid::new_v4();
509        let reached = req.add_cosignatory(o1, dec!(0.10)).unwrap();
510        assert!(!reached);
511        assert_eq!(req.status, AgeRequestStatus::Open);
512
513        // Deuxième signataire : 12% → total 22% ≥ 20%
514        let o2 = Uuid::new_v4();
515        let reached = req.add_cosignatory(o2, dec!(0.12)).unwrap();
516        assert!(reached);
517        assert_eq!(req.status, AgeRequestStatus::Reached);
518        assert!(req.threshold_reached);
519        assert!(req.threshold_reached_at.is_some());
520        assert_eq!(req.total_shares_pct, dec!(0.22));
521    }
522
523    #[test]
524    fn test_duplicate_cosignatory_rejected() {
525        let mut req = make_request();
526        req.open().unwrap();
527        let owner = Uuid::new_v4();
528        req.add_cosignatory(owner, dec!(0.10)).unwrap();
529        let result = req.add_cosignatory(owner, dec!(0.05));
530        assert!(result.is_err());
531    }
532
533    #[test]
534    fn test_remove_cosignatory_reverts_status() {
535        let mut req = make_request();
536        req.open().unwrap();
537
538        let o1 = Uuid::new_v4();
539        let o2 = Uuid::new_v4();
540        req.add_cosignatory(o1, dec!(0.15)).unwrap();
541        req.add_cosignatory(o2, dec!(0.10)).unwrap(); // total 25% → Reached
542
543        assert_eq!(req.status, AgeRequestStatus::Reached);
544
545        // Retirer o2 → total 15% < 20% → retour Open
546        req.remove_cosignatory(o2).unwrap();
547        assert_eq!(req.status, AgeRequestStatus::Open);
548        assert!(!req.threshold_reached);
549    }
550
551    #[test]
552    fn test_submit_to_syndic() {
553        let mut req = make_request();
554        req.open().unwrap();
555        let o1 = Uuid::new_v4();
556        req.add_cosignatory(o1, dec!(0.25)).unwrap(); // Reached
557
558        req.submit_to_syndic().unwrap();
559        assert_eq!(req.status, AgeRequestStatus::Submitted);
560        assert!(req.submitted_to_syndic_at.is_some());
561        assert!(req.syndic_deadline_at.is_some());
562
563        // Deadline = submitted + 15j
564        let diff = req.syndic_deadline_at.unwrap() - req.submitted_to_syndic_at.unwrap();
565        assert_eq!(diff.num_days(), AgeRequest::SYNDIC_DEADLINE_DAYS);
566    }
567
568    #[test]
569    fn test_submit_fails_if_not_reached() {
570        let mut req = make_request();
571        req.open().unwrap();
572        // Sans cosignataires
573        assert!(req.submit_to_syndic().is_err());
574    }
575
576    #[test]
577    fn test_accept_by_syndic() {
578        let mut req = make_request();
579        req.open().unwrap();
580        req.add_cosignatory(Uuid::new_v4(), dec!(0.25)).unwrap();
581        req.submit_to_syndic().unwrap();
582        req.accept_by_syndic(Some("Convocation dans 3 semaines".to_string()))
583            .unwrap();
584        assert_eq!(req.status, AgeRequestStatus::Accepted);
585        assert!(req.syndic_response_at.is_some());
586    }
587
588    #[test]
589    fn test_reject_requires_reason() {
590        let mut req = make_request();
591        req.open().unwrap();
592        req.add_cosignatory(Uuid::new_v4(), dec!(0.25)).unwrap();
593        req.submit_to_syndic().unwrap();
594        assert!(req.reject_by_syndic("  ".to_string()).is_err());
595        req.reject_by_syndic("Demande insuffisamment motivée".to_string())
596            .unwrap();
597        assert_eq!(req.status, AgeRequestStatus::Rejected);
598    }
599
600    #[test]
601    fn test_withdraw_by_initiator_only() {
602        let mut req = make_request();
603        req.open().unwrap();
604
605        let other = Uuid::new_v4();
606        assert!(req.withdraw(other).is_err());
607
608        let initiator = req.created_by;
609        req.withdraw(initiator).unwrap();
610        assert_eq!(req.status, AgeRequestStatus::Withdrawn);
611    }
612
613    #[test]
614    fn test_shares_pct_missing() {
615        let mut req = make_request();
616        req.open().unwrap();
617
618        // 0 signatures → 20% manquants
619        assert_eq!(req.shares_pct_missing(), dec!(0.20));
620
621        req.add_cosignatory(Uuid::new_v4(), dec!(0.12)).unwrap();
622        // 12% → 8% manquants
623        assert_eq!(req.shares_pct_missing(), dec!(0.08));
624
625        req.add_cosignatory(Uuid::new_v4(), dec!(0.10)).unwrap();
626        // Reached → 0% manquants
627        assert_eq!(req.shares_pct_missing(), Decimal::ZERO);
628    }
629
630    #[test]
631    fn test_status_is_terminal() {
632        assert!(AgeRequestStatus::Accepted.is_terminal());
633        assert!(AgeRequestStatus::Expired.is_terminal());
634        assert!(AgeRequestStatus::Rejected.is_terminal());
635        assert!(AgeRequestStatus::Withdrawn.is_terminal());
636        assert!(!AgeRequestStatus::Draft.is_terminal());
637        assert!(!AgeRequestStatus::Open.is_terminal());
638        assert!(!AgeRequestStatus::Reached.is_terminal());
639        assert!(!AgeRequestStatus::Submitted.is_terminal());
640    }
641
642    #[test]
643    fn test_calculate_progress_percentage() {
644        let mut req = make_request();
645        req.open().unwrap();
646
647        // 0 signatures : 0% progress
648        assert_eq!(req.calculate_progress_percentage(1.0), 0.0);
649
650        // 5% des quotes-parts : 5% / 20% = 25% progress
651        req.add_cosignatory(Uuid::new_v4(), dec!(0.05)).unwrap();
652        assert!((req.calculate_progress_percentage(1.0) - 25.0).abs() < 1e-9);
653
654        // 10% des quotes-parts : 10% / 20% = 50% progress
655        req.add_cosignatory(Uuid::new_v4(), dec!(0.05)).unwrap();
656        assert!((req.calculate_progress_percentage(1.0) - 50.0).abs() < 1e-9);
657
658        // 20% des quotes-parts : 20% / 20% = 100% progress (seuil atteint)
659        req.add_cosignatory(Uuid::new_v4(), dec!(0.10)).unwrap();
660        assert_eq!(req.calculate_progress_percentage(1.0), 100.0);
661    }
662}