koprogo_api/domain/entities/
age_request.rs

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