koprogo_api/domain/entities/
etat_date.rs

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