koprogo_api/domain/entities/
etat_date.rs

1use chrono::{DateTime, Utc};
2use f64;
3use serde::{Deserialize, Serialize};
4use std::sync::atomic::{AtomicU64, Ordering};
5use uuid::Uuid;
6
7/// Atomic counter for generating unique reference numbers
8static ETAT_DATE_COUNTER: AtomicU64 = AtomicU64::new(0);
9
10/// Statut de l'état daté (workflow de génération)
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)]
12#[sqlx(type_name = "etat_date_status", rename_all = "snake_case")]
13#[serde(rename_all = "snake_case")]
14pub enum EtatDateStatus {
15    Requested,  // Demandé par le notaire
16    InProgress, // En cours de génération
17    Generated,  // Généré, prêt à être délivré
18    Delivered,  // Délivré au notaire
19    Expired,    // Expiré (>3 mois)
20}
21
22/// Langue de génération du document (Belgique: FR/NL/DE)
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)]
24#[sqlx(type_name = "etat_date_language", rename_all = "snake_case")]
25#[serde(rename_all = "lowercase")]
26pub enum EtatDateLanguage {
27    Fr, // Français
28    Nl, // Néerlandais
29    De, // Allemand
30}
31
32/// Représente un État Daté pour mutation immobilière (Art. 577-2 Code Civil belge)
33///
34/// Un état daté est un document légal obligatoire pour toute vente de lot en copropriété.
35/// Il contient 16 sections légales détaillant la situation financière et juridique du lot.
36///
37/// **Délai légal**: Art. 3.94 CC — 15 jours ouvrables (demande simple),
38/// 30 jours (demande notaire par recommandé)
39/// **Validité**: 3 mois à partir de la date de référence (pratique professionnelle, non légale)
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub struct EtatDate {
42    pub id: Uuid,
43    pub organization_id: Uuid,
44    pub building_id: Uuid,
45    pub unit_id: Uuid,
46
47    /// Date de référence pour les calculs financiers
48    pub reference_date: DateTime<Utc>,
49
50    /// Date de demande par le notaire
51    pub requested_date: DateTime<Utc>,
52
53    /// Date de génération du document
54    pub generated_date: Option<DateTime<Utc>>,
55
56    /// Date de délivrance au notaire
57    pub delivered_date: Option<DateTime<Utc>>,
58
59    /// Statut du workflow
60    pub status: EtatDateStatus,
61
62    /// Langue du document
63    pub language: EtatDateLanguage,
64
65    /// Numéro de référence unique (ex: "ED-2025-001-BLD123-U456")
66    pub reference_number: String,
67
68    /// Informations du notaire demandeur
69    pub notary_name: String,
70    pub notary_email: String,
71    pub notary_phone: Option<String>,
72
73    // === Section 1: Identification ===
74    pub building_name: String,
75    pub building_address: String,
76    pub unit_number: String,
77    pub unit_floor: Option<String>,
78    pub unit_area: Option<f64>,
79
80    // === Section 2: Quote-parts ===
81    /// Quote-part charges ordinaires (en %)
82    pub ordinary_charges_quota: f64,
83    /// Quote-part charges extraordinaires (en %)
84    pub extraordinary_charges_quota: f64,
85
86    // === Section 3: Situation financière du propriétaire ===
87    /// Solde du propriétaire (positif = crédit, négatif = débit)
88    pub owner_balance: f64,
89    /// Montant des arriérés (dettes)
90    pub arrears_amount: f64,
91
92    // === Section 4: Provisions pour charges ===
93    /// Montant mensuel des provisions
94    pub monthly_provision_amount: f64,
95
96    // === Section 5: Solde créditeur/débiteur ===
97    /// Solde total (somme de tous les comptes)
98    pub total_balance: f64,
99
100    // === Section 6: Travaux votés non payés ===
101    /// Montant total des travaux votés mais non encore payés
102    pub approved_works_unpaid: f64,
103
104    // === Section 7-16: Données JSONB ===
105    /// Données structurées pour les sections complexes
106    /// {
107    ///   "ongoing_disputes": [...],           // Section 7: Litiges en cours
108    ///   "building_insurance": {...},         // Section 8: Assurance immeuble
109    ///   "condo_regulations": {...},          // Section 9: Règlement copropriété
110    ///   "recent_meeting_minutes": [...],     // Section 10: PV dernières AG
111    ///   "budget": {...},                     // Section 11: Budget prévisionnel
112    ///   "reserve_fund": {...},               // Section 12: Fonds de réserve
113    ///   "condo_debts_credits": {...},        // Section 13: Dettes/créances copropriété
114    ///   "works_progress": [...],             // Section 14: État d'avancement travaux
115    ///   "guarantees_mortgages": [...],       // Section 15: Garanties et hypothèques
116    ///   "additional_observations": "..."     // Section 16: Observations diverses
117    /// }
118    pub additional_data: serde_json::Value,
119
120    /// Chemin du fichier PDF généré (si généré)
121    pub pdf_file_path: Option<String>,
122
123    pub created_at: DateTime<Utc>,
124    pub updated_at: DateTime<Utc>,
125}
126
127impl EtatDate {
128    #[allow(clippy::too_many_arguments)]
129    pub fn new(
130        organization_id: Uuid,
131        building_id: Uuid,
132        unit_id: Uuid,
133        reference_date: DateTime<Utc>,
134        language: EtatDateLanguage,
135        notary_name: String,
136        notary_email: String,
137        notary_phone: Option<String>,
138        building_name: String,
139        building_address: String,
140        unit_number: String,
141        unit_floor: Option<String>,
142        unit_area: Option<f64>,
143        ordinary_charges_quota: f64,
144        extraordinary_charges_quota: f64,
145    ) -> Result<Self, String> {
146        // Validations
147        if notary_name.trim().is_empty() {
148            return Err("Notary name cannot be empty".to_string());
149        }
150        if notary_email.trim().is_empty() {
151            return Err("Notary email cannot be empty".to_string());
152        }
153        if !notary_email.contains('@') {
154            return Err("Invalid notary email".to_string());
155        }
156        if building_name.trim().is_empty() {
157            return Err("Building name cannot be empty".to_string());
158        }
159        if building_address.trim().is_empty() {
160            return Err("Building address cannot be empty".to_string());
161        }
162        if unit_number.trim().is_empty() {
163            return Err("Unit number cannot be empty".to_string());
164        }
165
166        // Quote-parts doivent être entre 0 et 100%
167        if ordinary_charges_quota < 0.0 || ordinary_charges_quota > 100.0 {
168            return Err("Ordinary charges quota must be between 0 and 100%".to_string());
169        }
170        if extraordinary_charges_quota < 0.0 || extraordinary_charges_quota > 100.0 {
171            return Err("Extraordinary charges quota must be between 0 and 100%".to_string());
172        }
173
174        let now = Utc::now();
175        let reference_number = Self::generate_reference_number(&building_id, &unit_id, &now);
176
177        Ok(Self {
178            id: Uuid::new_v4(),
179            organization_id,
180            building_id,
181            unit_id,
182            reference_date,
183            requested_date: now,
184            generated_date: None,
185            delivered_date: None,
186            status: EtatDateStatus::Requested,
187            language,
188            reference_number,
189            notary_name,
190            notary_email,
191            notary_phone,
192            building_name,
193            building_address,
194            unit_number,
195            unit_floor,
196            unit_area,
197            ordinary_charges_quota,
198            extraordinary_charges_quota,
199            owner_balance: 0.0,
200            arrears_amount: 0.0,
201            monthly_provision_amount: 0.0,
202            total_balance: 0.0,
203            approved_works_unpaid: 0.0,
204            additional_data: serde_json::json!({}),
205            pdf_file_path: None,
206            created_at: now,
207            updated_at: now,
208        })
209    }
210
211    /// Génère un numéro de référence unique
212    /// Format: ED-YYYY-NNN-BLD{building_id_short}-U{unit_id_short}
213    fn generate_reference_number(
214        building_id: &Uuid,
215        unit_id: &Uuid,
216        date: &DateTime<Utc>,
217    ) -> String {
218        let year = date.format("%Y");
219        let building_short = &building_id.to_string()[..8];
220        let unit_short = &unit_id.to_string()[..8];
221
222        // Use atomic counter + timestamp for guaranteed uniqueness
223        let seq = ETAT_DATE_COUNTER.fetch_add(1, Ordering::Relaxed);
224        let unique_id = &Uuid::new_v4().to_string()[..8];
225
226        format!(
227            "ED-{}-{:03}-{}-BLD{}-U{}",
228            year,
229            seq % 1000,
230            unique_id,
231            building_short,
232            unit_short
233        )
234    }
235
236    /// Marque l'état daté comme en cours de génération
237    pub fn mark_in_progress(&mut self) -> Result<(), String> {
238        match self.status {
239            EtatDateStatus::Requested => {
240                self.status = EtatDateStatus::InProgress;
241                self.updated_at = Utc::now();
242                Ok(())
243            }
244            _ => Err(format!(
245                "Cannot mark as in progress: current status is {:?}",
246                self.status
247            )),
248        }
249    }
250
251    /// Marque l'état daté comme généré
252    pub fn mark_generated(&mut self, pdf_file_path: String) -> Result<(), String> {
253        if pdf_file_path.trim().is_empty() {
254            return Err("PDF file path cannot be empty".to_string());
255        }
256
257        match self.status {
258            EtatDateStatus::InProgress => {
259                self.status = EtatDateStatus::Generated;
260                self.generated_date = Some(Utc::now());
261                self.pdf_file_path = Some(pdf_file_path);
262                self.updated_at = Utc::now();
263                Ok(())
264            }
265            _ => Err(format!(
266                "Cannot mark as generated: current status is {:?}",
267                self.status
268            )),
269        }
270    }
271
272    /// Marque l'état daté comme délivré au notaire
273    pub fn mark_delivered(&mut self) -> Result<(), String> {
274        match self.status {
275            EtatDateStatus::Generated => {
276                self.status = EtatDateStatus::Delivered;
277                self.delivered_date = Some(Utc::now());
278                self.updated_at = Utc::now();
279                Ok(())
280            }
281            _ => Err(format!(
282                "Cannot mark as delivered: current status is {:?}",
283                self.status
284            )),
285        }
286    }
287
288    /// Vérifie si l'état daté est expiré (>3 mois depuis la date de référence)
289    pub fn is_expired(&self) -> bool {
290        let now = Utc::now();
291        let expiration_date = self.reference_date + chrono::Duration::days(90); // 3 mois
292        now > expiration_date
293    }
294
295    /// Vérifie si la génération est en retard (>15 jours depuis la demande)
296    /// Art. 3.94 CC: le syndic doit répondre sous 15 jours (demande simple)
297    pub fn is_overdue(&self) -> bool {
298        if matches!(
299            self.status,
300            EtatDateStatus::Generated | EtatDateStatus::Delivered
301        ) {
302            return false; // Déjà généré ou délivré
303        }
304
305        let now = Utc::now();
306        let deadline = self.requested_date + chrono::Duration::days(15);
307        now > deadline
308    }
309
310    /// Calcule le nombre de jours depuis la demande
311    pub fn days_since_request(&self) -> i64 {
312        let now = Utc::now();
313        (now - self.requested_date).num_days()
314    }
315
316    /// Met à jour les données financières
317    pub fn update_financial_data(
318        &mut self,
319        owner_balance: f64,
320        arrears_amount: f64,
321        monthly_provision_amount: f64,
322        total_balance: f64,
323        approved_works_unpaid: f64,
324    ) -> Result<(), String> {
325        // Validation: les arriérés ne peuvent pas être négatifs
326        if arrears_amount < 0.0 {
327            return Err("Arrears amount cannot be negative".to_string());
328        }
329        if monthly_provision_amount < 0.0 {
330            return Err("Monthly provision amount cannot be negative".to_string());
331        }
332        if approved_works_unpaid < 0.0 {
333            return Err("Approved works unpaid cannot be negative".to_string());
334        }
335
336        self.owner_balance = owner_balance;
337        self.arrears_amount = arrears_amount;
338        self.monthly_provision_amount = monthly_provision_amount;
339        self.total_balance = total_balance;
340        self.approved_works_unpaid = approved_works_unpaid;
341        self.updated_at = Utc::now();
342
343        Ok(())
344    }
345
346    /// Met à jour les données additionnelles (sections 7-16)
347    pub fn update_additional_data(&mut self, data: serde_json::Value) -> Result<(), String> {
348        if !data.is_object() {
349            return Err("Additional data must be a JSON object".to_string());
350        }
351
352        self.additional_data = data;
353        self.updated_at = Utc::now();
354        Ok(())
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_create_etat_date_success() {
364        let org_id = Uuid::new_v4();
365        let building_id = Uuid::new_v4();
366        let unit_id = Uuid::new_v4();
367        let ref_date = Utc::now();
368
369        let etat_date = EtatDate::new(
370            org_id,
371            building_id,
372            unit_id,
373            ref_date,
374            EtatDateLanguage::Fr,
375            "Maître Dupont".to_string(),
376            "dupont@notaire.be".to_string(),
377            Some("+32 2 123 4567".to_string()),
378            "Résidence Les Jardins".to_string(),
379            "Rue de la Loi 123, 1000 Bruxelles".to_string(),
380            "101".to_string(),
381            Some("1".to_string()),
382            Some(100.0),
383            100.0, // 5%
384            100.0, // 10%
385        );
386
387        assert!(etat_date.is_ok());
388        let ed = etat_date.unwrap();
389        assert_eq!(ed.status, EtatDateStatus::Requested);
390        assert_eq!(ed.notary_name, "Maître Dupont");
391        assert!(ed.reference_number.starts_with("ED-"));
392    }
393
394    #[test]
395    fn test_create_etat_date_invalid_email() {
396        let org_id = Uuid::new_v4();
397        let building_id = Uuid::new_v4();
398        let unit_id = Uuid::new_v4();
399        let ref_date = Utc::now();
400
401        let result = EtatDate::new(
402            org_id,
403            building_id,
404            unit_id,
405            ref_date,
406            EtatDateLanguage::Fr,
407            "Maître Dupont".to_string(),
408            "invalid-email".to_string(), // Email invalide
409            None,
410            "Résidence Les Jardins".to_string(),
411            "Rue de la Loi 123".to_string(),
412            "101".to_string(),
413            None,
414            None,
415            100.0,
416            100.0,
417        );
418
419        assert!(result.is_err());
420        assert_eq!(result.unwrap_err(), "Invalid notary email");
421    }
422
423    #[test]
424    fn test_create_etat_date_invalid_quota() {
425        let org_id = Uuid::new_v4();
426        let building_id = Uuid::new_v4();
427        let unit_id = Uuid::new_v4();
428        let ref_date = Utc::now();
429
430        let result = EtatDate::new(
431            org_id,
432            building_id,
433            unit_id,
434            ref_date,
435            EtatDateLanguage::Fr,
436            "Maître Dupont".to_string(),
437            "dupont@notaire.be".to_string(),
438            None,
439            "Résidence Les Jardins".to_string(),
440            "Rue de la Loi 123".to_string(),
441            "101".to_string(),
442            None,
443            None,
444            150.0, // 150% - invalide
445            100.0,
446        );
447
448        assert!(result.is_err());
449        assert!(result.unwrap_err().contains("between 0 and 100%"));
450    }
451
452    #[test]
453    fn test_workflow_transitions() {
454        let org_id = Uuid::new_v4();
455        let building_id = Uuid::new_v4();
456        let unit_id = Uuid::new_v4();
457        let ref_date = Utc::now();
458
459        let mut ed = EtatDate::new(
460            org_id,
461            building_id,
462            unit_id,
463            ref_date,
464            EtatDateLanguage::Fr,
465            "Maître Dupont".to_string(),
466            "dupont@notaire.be".to_string(),
467            None,
468            "Résidence Les Jardins".to_string(),
469            "Rue de la Loi 123".to_string(),
470            "101".to_string(),
471            None,
472            None,
473            100.0,
474            100.0,
475        )
476        .unwrap();
477
478        // Requested → InProgress
479        assert!(ed.mark_in_progress().is_ok());
480        assert_eq!(ed.status, EtatDateStatus::InProgress);
481
482        // InProgress → Generated
483        assert!(ed
484            .mark_generated("/path/to/etat_date_001.pdf".to_string())
485            .is_ok());
486        assert_eq!(ed.status, EtatDateStatus::Generated);
487        assert!(ed.generated_date.is_some());
488        assert!(ed.pdf_file_path.is_some());
489
490        // Generated → Delivered
491        assert!(ed.mark_delivered().is_ok());
492        assert_eq!(ed.status, EtatDateStatus::Delivered);
493        assert!(ed.delivered_date.is_some());
494    }
495
496    #[test]
497    fn test_invalid_workflow_transition() {
498        let org_id = Uuid::new_v4();
499        let building_id = Uuid::new_v4();
500        let unit_id = Uuid::new_v4();
501        let ref_date = Utc::now();
502
503        let mut ed = EtatDate::new(
504            org_id,
505            building_id,
506            unit_id,
507            ref_date,
508            EtatDateLanguage::Fr,
509            "Maître Dupont".to_string(),
510            "dupont@notaire.be".to_string(),
511            None,
512            "Résidence Les Jardins".to_string(),
513            "Rue de la Loi 123".to_string(),
514            "101".to_string(),
515            None,
516            None,
517            100.0,
518            100.0,
519        )
520        .unwrap();
521
522        // Cannot go directly from Requested to Delivered
523        let result = ed.mark_delivered();
524        assert!(result.is_err());
525    }
526
527    #[test]
528    fn test_update_financial_data() {
529        let org_id = Uuid::new_v4();
530        let building_id = Uuid::new_v4();
531        let unit_id = Uuid::new_v4();
532        let ref_date = Utc::now();
533
534        let mut ed = EtatDate::new(
535            org_id,
536            building_id,
537            unit_id,
538            ref_date,
539            EtatDateLanguage::Fr,
540            "Maître Dupont".to_string(),
541            "dupont@notaire.be".to_string(),
542            None,
543            "Résidence Les Jardins".to_string(),
544            "Rue de la Loi 123".to_string(),
545            "101".to_string(),
546            None,
547            None,
548            100.0,
549            100.0,
550        )
551        .unwrap();
552
553        let result = ed.update_financial_data(
554            -500.00, // -500.00 EUR (débit)
555            100.0,   // 500.00 EUR arriérés
556            100.0,   // 150.00 EUR/mois
557            -500.00, // -500.00 EUR total
558            100.0,   // 2000.00 EUR travaux votés
559        );
560
561        assert!(result.is_ok());
562        assert_eq!(ed.owner_balance, -500.00);
563        assert_eq!(ed.arrears_amount, 100.0);
564    }
565
566    #[test]
567    fn test_is_overdue() {
568        let org_id = Uuid::new_v4();
569        let building_id = Uuid::new_v4();
570        let unit_id = Uuid::new_v4();
571        let ref_date = Utc::now();
572
573        let mut ed = EtatDate::new(
574            org_id,
575            building_id,
576            unit_id,
577            ref_date,
578            EtatDateLanguage::Fr,
579            "Maître Dupont".to_string(),
580            "dupont@notaire.be".to_string(),
581            None,
582            "Résidence Les Jardins".to_string(),
583            "Rue de la Loi 123".to_string(),
584            "101".to_string(),
585            None,
586            None,
587            100.0,
588            100.0,
589        )
590        .unwrap();
591
592        // Simuler une demande vieille de 16 jours (>15 jours Art. 3.94 CC)
593        ed.requested_date = Utc::now() - chrono::Duration::days(16);
594
595        assert!(ed.is_overdue());
596    }
597
598    #[test]
599    fn test_days_since_request() {
600        let org_id = Uuid::new_v4();
601        let building_id = Uuid::new_v4();
602        let unit_id = Uuid::new_v4();
603        let ref_date = Utc::now();
604
605        let mut ed = EtatDate::new(
606            org_id,
607            building_id,
608            unit_id,
609            ref_date,
610            EtatDateLanguage::Fr,
611            "Maître Dupont".to_string(),
612            "dupont@notaire.be".to_string(),
613            None,
614            "Résidence Les Jardins".to_string(),
615            "Rue de la Loi 123".to_string(),
616            "101".to_string(),
617            None,
618            None,
619            100.0,
620            100.0,
621        )
622        .unwrap();
623
624        // Simuler une demande vieille de 5 jours
625        ed.requested_date = Utc::now() - chrono::Duration::days(5);
626
627        assert_eq!(ed.days_since_request(), 5);
628    }
629}