Skip to main content

koprogo_api/domain/comptabilite/
etat_date.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use std::sync::atomic::{AtomicU64, Ordering};
6use uuid::Uuid;
7
8/// Atomic counter for generating unique reference numbers
9static ETAT_DATE_COUNTER: AtomicU64 = AtomicU64::new(0);
10
11/// Statut de l'état daté (workflow de génération)
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type, utoipa::ToSchema)]
13#[sqlx(type_name = "etat_date_status", rename_all = "snake_case")]
14#[serde(rename_all = "snake_case")]
15pub enum EtatDateStatus {
16    Requested,  // Demandé par le notaire
17    InProgress, // En cours de génération
18    Generated,  // Généré, prêt à être délivré
19    Delivered,  // Délivré au notaire
20    Expired,    // Expiré (>3 mois)
21}
22
23/// Langue de génération du document (Belgique: FR/NL/DE)
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type, utoipa::ToSchema)]
25#[sqlx(type_name = "etat_date_language", rename_all = "snake_case")]
26#[serde(rename_all = "lowercase")]
27pub enum EtatDateLanguage {
28    Fr, // Français
29    Nl, // Néerlandais
30    De, // Allemand
31}
32
33/// Représente un État Daté pour mutation immobilière (Art. 577-2 Code Civil belge)
34///
35/// Un état daté est un document légal obligatoire pour toute vente de lot en copropriété.
36/// Il contient 16 sections légales détaillant la situation financière et juridique du lot.
37///
38/// **Délai légal**: Art. 3.94 CC — 15 jours CALENDAIRES sur simple demande
39/// (§ 1er), 30 jours si le notaire adresse sa demande par recommandé (§ 2).
40///
41/// Texte officiel (Justel, SPF Justice — base consolidée du Code civil) :
42///
43/// > § 1er. […] les informations et documents suivants, que le syndic lui
44/// > communique **sur simple demande, dans un délai de quinze jours** […]
45/// > A défaut de réponse du syndic **dans les quinze jours** de la demande,
46/// > le notaire […] avise les parties de la carence de celui-ci.
47///
48/// > § 2. […] le notaire instrumentant demande au syndic […] **par envoi
49/// > recommandé** […] A défaut de réponse du syndic **dans les trente jours**
50/// > de la demande, le notaire avise les parties de la carence de celui-ci.
51///
52/// Ni l'un ni l'autre ne dit « ouvrables ». Et l'argument décisif n'est pas
53/// cette absence, c'est que le législateur emploie le terme AILLEURS dans le
54/// même livre quand il le veut — Art. 3.31, § 2 : « Le délai […] est prolongé
55/// jusqu'au premier **jour ouvrable** suivant lorsque le dernier jour dudit
56/// délai est un jour de fermeture des bureaux. » Son absence à l'Art. 3.94
57/// est donc un choix, pas un oubli.
58///
59/// Ce commentaire annonçait « jours ouvrables », soit environ 21 jours
60/// calendaires : la documentation était fausse, pas le calcul.
61/// **Validité**: 3 mois à partir de la date de référence (pratique professionnelle, non légale)
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct EtatDate {
64    pub id: Uuid,
65
66    /// L'ACP dont l'état daté rend compte.
67    ///
68    /// Art. 3.94 : à la transmission d'un lot, le notaire réclame au syndic
69    /// l'état de la situation du copropriétaire cédant vis-à-vis de
70    /// **l'association**. Les sommes dues le sont à l'ACP ; le syndic ne fait
71    /// que les attester. Cf. ADR-0045.
72    pub acp_id: Uuid,
73
74    /// Le syndic qui a établi l'état daté, conservé comme trace d'auteur.
75    pub organization_id: Uuid,
76    pub building_id: Uuid,
77    pub unit_id: Uuid,
78
79    /// Date de référence pour les calculs financiers
80    pub reference_date: DateTime<Utc>,
81
82    /// Date de demande par le notaire
83    pub requested_date: DateTime<Utc>,
84
85    /// Date de génération du document
86    pub generated_date: Option<DateTime<Utc>>,
87
88    /// Date de délivrance au notaire
89    pub delivered_date: Option<DateTime<Utc>>,
90
91    /// Statut du workflow
92    pub status: EtatDateStatus,
93
94    /// Langue du document
95    pub language: EtatDateLanguage,
96
97    /// Numéro de référence unique (ex: "ED-2025-001-BLD123-U456")
98    pub reference_number: String,
99
100    /// Informations du notaire demandeur
101    pub notary_name: String,
102    pub notary_email: String,
103    pub notary_phone: Option<String>,
104
105    // === Section 1: Identification ===
106    pub building_name: String,
107    pub building_address: String,
108    pub unit_number: String,
109    pub unit_floor: Option<String>,
110    pub unit_area: Option<f64>,
111
112    // === Section 2: Quote-parts ===
113    /// Quote-part charges ordinaires (en %) — Decimal exact (ADR-0008)
114    pub ordinary_charges_quota: Decimal,
115    /// Quote-part charges extraordinaires (en %) — Decimal exact (ADR-0008)
116    pub extraordinary_charges_quota: Decimal,
117
118    // === Section 3: Situation financière du propriétaire ===
119    // MONÉTAIRE : Decimal exact (ADR-0007/0008). Document légal Art. 577-2
120    // CC — toute dérive d'arrondi f64 vicie l'état daté. Colonnes DB déjà
121    // `DECIMAL(12,2)` (#433 / WP-A5 EXP-007).
122    /// Solde du propriétaire (positif = crédit, négatif = débit)
123    pub owner_balance: Decimal,
124    /// Montant des arriérés (dettes)
125    pub arrears_amount: Decimal,
126
127    // === Section 4: Provisions pour charges ===
128    /// Montant mensuel des provisions
129    pub monthly_provision_amount: Decimal,
130
131    // === Section 5: Solde créditeur/débiteur ===
132    /// Solde total (somme de tous les comptes)
133    pub total_balance: Decimal,
134
135    // === Section 6: Travaux votés non payés ===
136    /// Montant total des travaux votés mais non encore payés
137    pub approved_works_unpaid: Decimal,
138
139    // === Section 7-16: Données JSONB ===
140    /// Données structurées pour les sections complexes
141    /// {
142    ///   "ongoing_disputes": [...],           // Section 7: Litiges en cours
143    ///   "building_insurance": {...},         // Section 8: Assurance immeuble
144    ///   "condo_regulations": {...},          // Section 9: Règlement copropriété
145    ///   "recent_meeting_minutes": [...],     // Section 10: PV dernières AG
146    ///   "budget": {...},                     // Section 11: Budget prévisionnel
147    ///   "reserve_fund": {...},               // Section 12: Fonds de réserve
148    ///   "condo_debts_credits": {...},        // Section 13: Dettes/créances copropriété
149    ///   "works_progress": [...],             // Section 14: État d'avancement travaux
150    ///   "guarantees_mortgages": [...],       // Section 15: Garanties et hypothèques
151    ///   "additional_observations": "..."     // Section 16: Observations diverses
152    /// }
153    pub additional_data: serde_json::Value,
154
155    /// Chemin du fichier PDF généré (si généré)
156    pub pdf_file_path: Option<String>,
157
158    pub created_at: DateTime<Utc>,
159    pub updated_at: DateTime<Utc>,
160}
161
162/// Domain-typed validation error for États Datés (Art. 577-2 CC).
163///
164/// Pure domain type — no infra/application dependency (hexagonal purity).
165/// Suit le précédent `JournalEntryError` (journal_entry.rs) : l'entité
166/// renvoie son erreur typée, l'application la mappe vers `AppError`
167/// (#433 / WP-A5 EXP-007) → 400 validation, jamais 500 Internal.
168#[derive(Debug, Clone, PartialEq)]
169pub enum EtatDateError {
170    /// Un champ texte obligatoire est vide (nom/email notaire, immeuble…).
171    EmptyField(&'static str),
172    /// Email notaire syntaxiquement invalide.
173    InvalidNotaryEmail,
174    /// Quote-part hors bornes [0, 100] %.
175    QuotaOutOfRange(&'static str),
176    /// Montant monétaire négatif là où c'est interdit (arriérés, provisions,
177    /// travaux non payés ≥ 0).
178    NegativeAmount(&'static str),
179    /// Transition de workflow interdite depuis le statut courant.
180    InvalidTransition {
181        from: EtatDateStatus,
182        to: &'static str,
183    },
184    /// Chemin du PDF généré vide.
185    EmptyPdfPath,
186    /// `additional_data` n'est pas un objet JSON.
187    AdditionalDataNotObject,
188}
189
190impl std::fmt::Display for EtatDateError {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        match self {
193            Self::EmptyField(name) => write!(f, "{} cannot be empty", name),
194            Self::InvalidNotaryEmail => write!(f, "Invalid notary email"),
195            Self::QuotaOutOfRange(name) => {
196                write!(f, "{} must be between 0 and 100%", name)
197            }
198            Self::NegativeAmount(name) => write!(f, "{} cannot be negative", name),
199            Self::InvalidTransition { from, to } => {
200                write!(f, "Cannot mark as {}: current status is {:?}", to, from)
201            }
202            Self::EmptyPdfPath => write!(f, "PDF file path cannot be empty"),
203            Self::AdditionalDataNotObject => {
204                write!(f, "Additional data must be a JSON object")
205            }
206        }
207    }
208}
209
210impl std::error::Error for EtatDateError {}
211
212/// Bridge : les use-cases/ports `Result<_, String>` compilent inchangés
213/// pendant que l'entité est typée (cascade String→AppError = slice plus
214/// large, hors scope WP-A5 — précédent WP-A3/A4). Pur, std-only.
215impl From<EtatDateError> for String {
216    fn from(e: EtatDateError) -> String {
217        e.to_string()
218    }
219}
220
221impl EtatDate {
222    #[allow(clippy::too_many_arguments)]
223    pub fn new(
224        acp_id: Uuid,
225        organization_id: Uuid,
226        building_id: Uuid,
227        unit_id: Uuid,
228        reference_date: DateTime<Utc>,
229        language: EtatDateLanguage,
230        notary_name: String,
231        notary_email: String,
232        notary_phone: Option<String>,
233        building_name: String,
234        building_address: String,
235        unit_number: String,
236        unit_floor: Option<String>,
237        unit_area: Option<f64>,
238        ordinary_charges_quota: Decimal,
239        extraordinary_charges_quota: Decimal,
240    ) -> Result<Self, EtatDateError> {
241        // Validations
242        if notary_name.trim().is_empty() {
243            return Err(EtatDateError::EmptyField("Notary name"));
244        }
245        if notary_email.trim().is_empty() {
246            return Err(EtatDateError::EmptyField("Notary email"));
247        }
248        if !notary_email.contains('@') {
249            return Err(EtatDateError::InvalidNotaryEmail);
250        }
251        if building_name.trim().is_empty() {
252            return Err(EtatDateError::EmptyField("Building name"));
253        }
254        if building_address.trim().is_empty() {
255            return Err(EtatDateError::EmptyField("Building address"));
256        }
257        if unit_number.trim().is_empty() {
258            return Err(EtatDateError::EmptyField("Unit number"));
259        }
260
261        // Quote-parts doivent être entre 0 et 100%
262        if ordinary_charges_quota < Decimal::ZERO || ordinary_charges_quota > dec!(100) {
263            return Err(EtatDateError::QuotaOutOfRange("Ordinary charges quota"));
264        }
265        if extraordinary_charges_quota < Decimal::ZERO || extraordinary_charges_quota > dec!(100) {
266            return Err(EtatDateError::QuotaOutOfRange(
267                "Extraordinary charges quota",
268            ));
269        }
270
271        let now = Utc::now();
272        let reference_number = Self::generate_reference_number(&building_id, &unit_id, &now);
273
274        Ok(Self {
275            id: Uuid::new_v4(),
276            acp_id,
277            organization_id,
278            building_id,
279            unit_id,
280            reference_date,
281            requested_date: now,
282            generated_date: None,
283            delivered_date: None,
284            status: EtatDateStatus::Requested,
285            language,
286            reference_number,
287            notary_name,
288            notary_email,
289            notary_phone,
290            building_name,
291            building_address,
292            unit_number,
293            unit_floor,
294            unit_area,
295            ordinary_charges_quota,
296            extraordinary_charges_quota,
297            owner_balance: Decimal::ZERO,
298            arrears_amount: Decimal::ZERO,
299            monthly_provision_amount: Decimal::ZERO,
300            total_balance: Decimal::ZERO,
301            approved_works_unpaid: Decimal::ZERO,
302            additional_data: serde_json::json!({}),
303            pdf_file_path: None,
304            created_at: now,
305            updated_at: now,
306        })
307    }
308
309    /// Génère un numéro de référence unique
310    /// Format: ED-YYYY-NNN-BLD{building_id_short}-U{unit_id_short}
311    fn generate_reference_number(
312        building_id: &Uuid,
313        unit_id: &Uuid,
314        date: &DateTime<Utc>,
315    ) -> String {
316        let year = date.format("%Y");
317        let building_short = &building_id.to_string()[..8];
318        let unit_short = &unit_id.to_string()[..8];
319
320        // Use atomic counter + timestamp for guaranteed uniqueness
321        let seq = ETAT_DATE_COUNTER.fetch_add(1, Ordering::Relaxed);
322        let unique_id = &Uuid::new_v4().to_string()[..8];
323
324        format!(
325            "ED-{}-{:03}-{}-BLD{}-U{}",
326            year,
327            seq % 1000,
328            unique_id,
329            building_short,
330            unit_short
331        )
332    }
333
334    /// Marque l'état daté comme en cours de génération
335    pub fn mark_in_progress(&mut self) -> Result<(), EtatDateError> {
336        match self.status {
337            EtatDateStatus::Requested => {
338                self.status = EtatDateStatus::InProgress;
339                self.updated_at = Utc::now();
340                Ok(())
341            }
342            _ => Err(EtatDateError::InvalidTransition {
343                from: self.status.clone(),
344                to: "in progress",
345            }),
346        }
347    }
348
349    /// Marque l'état daté comme généré
350    pub fn mark_generated(&mut self, pdf_file_path: String) -> Result<(), EtatDateError> {
351        if pdf_file_path.trim().is_empty() {
352            return Err(EtatDateError::EmptyPdfPath);
353        }
354
355        match self.status {
356            EtatDateStatus::InProgress => {
357                self.status = EtatDateStatus::Generated;
358                self.generated_date = Some(Utc::now());
359                self.pdf_file_path = Some(pdf_file_path);
360                self.updated_at = Utc::now();
361                Ok(())
362            }
363            _ => Err(EtatDateError::InvalidTransition {
364                from: self.status.clone(),
365                to: "generated",
366            }),
367        }
368    }
369
370    /// Marque l'état daté comme délivré au notaire
371    pub fn mark_delivered(&mut self) -> Result<(), EtatDateError> {
372        match self.status {
373            EtatDateStatus::Generated => {
374                self.status = EtatDateStatus::Delivered;
375                self.delivered_date = Some(Utc::now());
376                self.updated_at = Utc::now();
377                Ok(())
378            }
379            _ => Err(EtatDateError::InvalidTransition {
380                from: self.status.clone(),
381                to: "delivered",
382            }),
383        }
384    }
385
386    /// Vérifie si l'état daté est expiré (>3 mois depuis la date de référence)
387    pub fn is_expired(&self) -> bool {
388        let now = Utc::now();
389        let expiration_date = self.reference_date + chrono::Duration::days(90); // 3 mois
390        now > expiration_date
391    }
392
393    /// Vérifie si la génération est en retard.
394    ///
395    /// Art. 3.94 § 1er CC : le syndic répond « sur simple demande endéans les
396    /// quinze jours » — quinze jours CALENDAIRES. Le § 2 porte le délai à
397    /// trente jours lorsque le notaire adresse sa demande par lettre
398    /// recommandée.
399    ///
400    /// LIMITE ASSUMÉE : l'entité ne mémorise pas le CANAL de la demande
401    /// (simple ou recommandé), seulement l'identité du notaire. Le délai le
402    /// plus court est donc appliqué à tous les cas. C'est le sens prudent —
403    /// on n'annonce jamais un retard trop tard — mais un syndic qui répond au
404    /// vingtième jour à une demande recommandée sera signalé en retard alors
405    /// qu'il est dans les temps. Ajouter le canal à la demande lèverait la
406    /// restriction.
407    pub fn is_overdue(&self) -> bool {
408        if matches!(
409            self.status,
410            EtatDateStatus::Generated | EtatDateStatus::Delivered
411        ) {
412            return false; // Déjà généré ou délivré
413        }
414
415        let now = Utc::now();
416        let deadline = self.requested_date + chrono::Duration::days(15);
417        now > deadline
418    }
419
420    /// Calcule le nombre de jours depuis la demande
421    pub fn days_since_request(&self) -> i64 {
422        let now = Utc::now();
423        (now - self.requested_date).num_days()
424    }
425
426    /// Met à jour les données financières
427    pub fn update_financial_data(
428        &mut self,
429        owner_balance: Decimal,
430        arrears_amount: Decimal,
431        monthly_provision_amount: Decimal,
432        total_balance: Decimal,
433        approved_works_unpaid: Decimal,
434    ) -> Result<(), EtatDateError> {
435        // Validation: les arriérés ne peuvent pas être négatifs
436        if arrears_amount < Decimal::ZERO {
437            return Err(EtatDateError::NegativeAmount("Arrears amount"));
438        }
439        if monthly_provision_amount < Decimal::ZERO {
440            return Err(EtatDateError::NegativeAmount("Monthly provision amount"));
441        }
442        if approved_works_unpaid < Decimal::ZERO {
443            return Err(EtatDateError::NegativeAmount("Approved works unpaid"));
444        }
445
446        self.owner_balance = owner_balance;
447        self.arrears_amount = arrears_amount;
448        self.monthly_provision_amount = monthly_provision_amount;
449        self.total_balance = total_balance;
450        self.approved_works_unpaid = approved_works_unpaid;
451        self.updated_at = Utc::now();
452
453        Ok(())
454    }
455
456    /// Met à jour les données additionnelles (sections 7-16)
457    pub fn update_additional_data(&mut self, data: serde_json::Value) -> Result<(), EtatDateError> {
458        if !data.is_object() {
459            return Err(EtatDateError::AdditionalDataNotObject);
460        }
461
462        self.additional_data = data;
463        self.updated_at = Utc::now();
464        Ok(())
465    }
466}
467
468impl crate::domain::services::PieceDeGestion for EtatDate {
469    fn acp_id(&self) -> Uuid {
470        self.acp_id
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_create_etat_date_success() {
480        let org_id = Uuid::new_v4();
481        let building_id = Uuid::new_v4();
482        let unit_id = Uuid::new_v4();
483        let ref_date = Utc::now();
484
485        let etat_date = EtatDate::new(
486            Uuid::new_v4(), // acp_id
487            org_id,
488            building_id,
489            unit_id,
490            ref_date,
491            EtatDateLanguage::Fr,
492            "Maître Dupont".to_string(),
493            "dupont@notaire.be".to_string(),
494            Some("+32 2 123 4567".to_string()),
495            "Résidence Les Jardins".to_string(),
496            "Rue de la Loi 123, 1000 Bruxelles".to_string(),
497            "101".to_string(),
498            Some("1".to_string()),
499            Some(100.0),
500            dec!(100), // 5%
501            dec!(100), // 10%
502        );
503
504        assert!(etat_date.is_ok());
505        let ed = etat_date.unwrap();
506        assert_eq!(ed.status, EtatDateStatus::Requested);
507        assert_eq!(ed.notary_name, "Maître Dupont");
508        assert!(ed.reference_number.starts_with("ED-"));
509    }
510
511    #[test]
512    fn test_create_etat_date_invalid_email() {
513        let org_id = Uuid::new_v4();
514        let building_id = Uuid::new_v4();
515        let unit_id = Uuid::new_v4();
516        let ref_date = Utc::now();
517
518        let result = EtatDate::new(
519            Uuid::new_v4(), // acp_id
520            org_id,
521            building_id,
522            unit_id,
523            ref_date,
524            EtatDateLanguage::Fr,
525            "Maître Dupont".to_string(),
526            "invalid-email".to_string(), // Email invalide
527            None,
528            "Résidence Les Jardins".to_string(),
529            "Rue de la Loi 123".to_string(),
530            "101".to_string(),
531            None,
532            None,
533            dec!(100),
534            dec!(100),
535        );
536
537        assert!(matches!(
538            result.unwrap_err(),
539            EtatDateError::InvalidNotaryEmail
540        ));
541    }
542
543    #[test]
544    fn test_create_etat_date_invalid_quota() {
545        let org_id = Uuid::new_v4();
546        let building_id = Uuid::new_v4();
547        let unit_id = Uuid::new_v4();
548        let ref_date = Utc::now();
549
550        let result = EtatDate::new(
551            Uuid::new_v4(), // acp_id
552            org_id,
553            building_id,
554            unit_id,
555            ref_date,
556            EtatDateLanguage::Fr,
557            "Maître Dupont".to_string(),
558            "dupont@notaire.be".to_string(),
559            None,
560            "Résidence Les Jardins".to_string(),
561            "Rue de la Loi 123".to_string(),
562            "101".to_string(),
563            None,
564            None,
565            dec!(150), // 150% - invalide
566            dec!(100),
567        );
568
569        assert!(matches!(
570            result.unwrap_err(),
571            EtatDateError::QuotaOutOfRange(_)
572        ));
573    }
574
575    #[test]
576    fn test_workflow_transitions() {
577        let org_id = Uuid::new_v4();
578        let building_id = Uuid::new_v4();
579        let unit_id = Uuid::new_v4();
580        let ref_date = Utc::now();
581
582        let mut ed = EtatDate::new(
583            Uuid::new_v4(), // acp_id
584            org_id,
585            building_id,
586            unit_id,
587            ref_date,
588            EtatDateLanguage::Fr,
589            "Maître Dupont".to_string(),
590            "dupont@notaire.be".to_string(),
591            None,
592            "Résidence Les Jardins".to_string(),
593            "Rue de la Loi 123".to_string(),
594            "101".to_string(),
595            None,
596            None,
597            dec!(100),
598            dec!(100),
599        )
600        .unwrap();
601
602        // Requested → InProgress
603        assert!(ed.mark_in_progress().is_ok());
604        assert_eq!(ed.status, EtatDateStatus::InProgress);
605
606        // InProgress → Generated
607        assert!(ed
608            .mark_generated("/path/to/etat_date_001.pdf".to_string())
609            .is_ok());
610        assert_eq!(ed.status, EtatDateStatus::Generated);
611        assert!(ed.generated_date.is_some());
612        assert!(ed.pdf_file_path.is_some());
613
614        // Generated → Delivered
615        assert!(ed.mark_delivered().is_ok());
616        assert_eq!(ed.status, EtatDateStatus::Delivered);
617        assert!(ed.delivered_date.is_some());
618    }
619
620    #[test]
621    fn test_invalid_workflow_transition() {
622        let org_id = Uuid::new_v4();
623        let building_id = Uuid::new_v4();
624        let unit_id = Uuid::new_v4();
625        let ref_date = Utc::now();
626
627        let mut ed = EtatDate::new(
628            Uuid::new_v4(), // acp_id
629            org_id,
630            building_id,
631            unit_id,
632            ref_date,
633            EtatDateLanguage::Fr,
634            "Maître Dupont".to_string(),
635            "dupont@notaire.be".to_string(),
636            None,
637            "Résidence Les Jardins".to_string(),
638            "Rue de la Loi 123".to_string(),
639            "101".to_string(),
640            None,
641            None,
642            dec!(100),
643            dec!(100),
644        )
645        .unwrap();
646
647        // Cannot go directly from Requested to Delivered
648        let result = ed.mark_delivered();
649        assert!(result.is_err());
650    }
651
652    #[test]
653    fn test_update_financial_data() {
654        let org_id = Uuid::new_v4();
655        let building_id = Uuid::new_v4();
656        let unit_id = Uuid::new_v4();
657        let ref_date = Utc::now();
658
659        let mut ed = EtatDate::new(
660            Uuid::new_v4(), // acp_id
661            org_id,
662            building_id,
663            unit_id,
664            ref_date,
665            EtatDateLanguage::Fr,
666            "Maître Dupont".to_string(),
667            "dupont@notaire.be".to_string(),
668            None,
669            "Résidence Les Jardins".to_string(),
670            "Rue de la Loi 123".to_string(),
671            "101".to_string(),
672            None,
673            None,
674            dec!(100),
675            dec!(100),
676        )
677        .unwrap();
678
679        let result = ed.update_financial_data(
680            dec!(-500.00), // débit
681            dec!(100.0),   // arriérés
682            dec!(100.0),   // provision/mois
683            dec!(-500.00), // total
684            dec!(100.0),   // travaux votés non payés
685        );
686
687        assert!(result.is_ok());
688        assert_eq!(ed.owner_balance, dec!(-500.00));
689        assert_eq!(ed.arrears_amount, dec!(100.0));
690    }
691
692    #[test]
693    fn test_is_overdue() {
694        let org_id = Uuid::new_v4();
695        let building_id = Uuid::new_v4();
696        let unit_id = Uuid::new_v4();
697        let ref_date = Utc::now();
698
699        let mut ed = EtatDate::new(
700            Uuid::new_v4(), // acp_id
701            org_id,
702            building_id,
703            unit_id,
704            ref_date,
705            EtatDateLanguage::Fr,
706            "Maître Dupont".to_string(),
707            "dupont@notaire.be".to_string(),
708            None,
709            "Résidence Les Jardins".to_string(),
710            "Rue de la Loi 123".to_string(),
711            "101".to_string(),
712            None,
713            None,
714            dec!(100),
715            dec!(100),
716        )
717        .unwrap();
718
719        // Simuler une demande vieille de 16 jours (>15 jours Art. 3.94 CC)
720        ed.requested_date = Utc::now() - chrono::Duration::days(16);
721
722        assert!(ed.is_overdue());
723    }
724
725    /// Le délai de l'Art. 3.94 § 1er CC se compte en jours CALENDAIRES.
726    ///
727    /// Ce test existe parce que la documentation de l'entité annonçait
728    /// « 15 jours ouvrables », soit environ 21 jours calendaires — le calcul
729    /// et sa description se contredisaient, et une seule des deux lectures
730    /// pouvait être juridiquement correcte.
731    ///
732    /// Tranché le 2026-09-02 sur le texte officiel (Justel, SPF Justice) :
733    ///
734    ///   « le syndic lui communique sur simple demande, dans un délai de
735    ///     quinze jours »  —  Art. 3.94, § 1er
736    ///
737    /// Ni « ouvrables », ni « werkdagen » dans la version néerlandaise
738    /// (« binnen een termijn van vijftien dagen »), qui fait également foi.
739    ///
740    /// L'argument décisif n'est pas l'absence du mot mais son emploi AILLEURS :
741    /// l'Art. 3.31, § 2 du même livre écrit « prolongé jusqu'au premier jour
742    /// ouvrable suivant ». Le législateur sait donc le dire quand il le veut ;
743    /// son silence à l'Art. 3.94 est un choix, pas un oubli.
744    ///
745    /// Le calcul était juste, la documentation fausse.
746    ///
747    /// Les bornes ci-dessous verrouillent cette lecture : au quatorzième jour
748    /// on est dans les temps, au seizième on ne l'est plus. Si quelqu'un
749    /// bascule un jour sur les jours ouvrables, ce test le forcera à
750    /// argumenter plutôt qu'à le faire en passant.
751    #[test]
752    fn test_delai_art_3_94_se_compte_en_jours_calendaires() {
753        let ed_neuf = || {
754            EtatDate::new(
755                Uuid::new_v4(), // acp_id
756                Uuid::new_v4(),
757                Uuid::new_v4(),
758                Uuid::new_v4(),
759                Utc::now(),
760                EtatDateLanguage::Fr,
761                "Maître Dupont".to_string(),
762                "dupont@notaire.be".to_string(),
763                None,
764                "Résidence Les Jardins".to_string(),
765                "Rue de la Loi 123".to_string(),
766                "101".to_string(),
767                None,
768                None,
769                dec!(100),
770                dec!(100),
771            )
772            .unwrap()
773        };
774
775        // 14 jours calendaires : dans les temps.
776        let mut avant = ed_neuf();
777        avant.requested_date = Utc::now() - chrono::Duration::days(14);
778        assert!(
779            !avant.is_overdue(),
780            "quatorze jours calendaires restent dans le délai légal"
781        );
782
783        // 16 jours calendaires : hors délai.
784        let mut apres = ed_neuf();
785        apres.requested_date = Utc::now() - chrono::Duration::days(16);
786        assert!(
787            apres.is_overdue(),
788            "seize jours calendaires dépassent le délai légal"
789        );
790
791        // 18 jours : encore dans les temps SI l'on comptait en jours
792        // ouvrables (≈ 21 jours calendaires pour 15 ouvrables). Le test
793        // affirme le contraire — c'est là que se joue la différence.
794        let mut ouvrables = ed_neuf();
795        ouvrables.requested_date = Utc::now() - chrono::Duration::days(18);
796        assert!(
797            ouvrables.is_overdue(),
798            "le délai se compte en jours calendaires, pas en jours ouvrables"
799        );
800    }
801
802    #[test]
803    fn test_days_since_request() {
804        let org_id = Uuid::new_v4();
805        let building_id = Uuid::new_v4();
806        let unit_id = Uuid::new_v4();
807        let ref_date = Utc::now();
808
809        let mut ed = EtatDate::new(
810            Uuid::new_v4(), // acp_id
811            org_id,
812            building_id,
813            unit_id,
814            ref_date,
815            EtatDateLanguage::Fr,
816            "Maître Dupont".to_string(),
817            "dupont@notaire.be".to_string(),
818            None,
819            "Résidence Les Jardins".to_string(),
820            "Rue de la Loi 123".to_string(),
821            "101".to_string(),
822            None,
823            None,
824            dec!(100),
825            dec!(100),
826        )
827        .unwrap();
828
829        // Simuler une demande vieille de 5 jours
830        ed.requested_date = Utc::now() - chrono::Duration::days(5);
831
832        assert_eq!(ed.days_since_request(), 5);
833    }
834
835    // ------------------------------------------------------------------------
836    // 4 catégories #433/WP-A5 EXP-007 — erreur typée + exactitude Decimal
837    // (CRITICAL.md #3). Document légal Art. 577-2 CC : zéro dérive d'arrondi.
838    // ------------------------------------------------------------------------
839
840    fn sample() -> EtatDate {
841        EtatDate::new(
842            Uuid::new_v4(), // acp_id
843            Uuid::new_v4(),
844            Uuid::new_v4(),
845            Uuid::new_v4(),
846            Utc::now(),
847            EtatDateLanguage::Fr,
848            "Maître Dupont".to_string(),
849            "dupont@notaire.be".to_string(),
850            None,
851            "Résidence Les Jardins".to_string(),
852            "Rue de la Loi 123".to_string(),
853            "101".to_string(),
854            None,
855            None,
856            dec!(50),
857            dec!(50),
858        )
859        .unwrap()
860    }
861
862    /// @happy — màj financière nominale : montants Decimal stockés exacts.
863    #[test]
864    fn happy_update_financial_data_decimal_exact() {
865        let mut ed = sample();
866        ed.update_financial_data(
867            dec!(-1234.56),
868            dec!(789.01),
869            dec!(150.00),
870            dec!(-445.55),
871            dec!(2000.00),
872        )
873        .unwrap();
874        assert_eq!(ed.owner_balance, dec!(-1234.56));
875        assert_eq!(ed.total_balance, dec!(-445.55));
876    }
877
878    /// @edge — exactitude Decimal sur cumul (0.1+0.2=0.3 ; f64 échoue) +
879    /// borne quota exactement 100% acceptée.
880    #[test]
881    fn edge_decimal_exactness_and_quota_boundary() {
882        let mut ed = sample();
883        ed.update_financial_data(
884            dec!(0.1) + dec!(0.2),
885            Decimal::ZERO,
886            Decimal::ZERO,
887            dec!(0.3),
888            Decimal::ZERO,
889        )
890        .unwrap();
891        assert_eq!(ed.owner_balance, dec!(0.3));
892        assert_eq!(ed.owner_balance, ed.total_balance);
893
894        // Quota exactement 100% (borne incluse) accepté.
895        let ok = EtatDate::new(
896            Uuid::new_v4(), // acp_id
897            Uuid::new_v4(),
898            Uuid::new_v4(),
899            Uuid::new_v4(),
900            Utc::now(),
901            EtatDateLanguage::Nl,
902            "N".to_string(),
903            "n@x.be".to_string(),
904            None,
905            "B".to_string(),
906            "A".to_string(),
907            "1".to_string(),
908            None,
909            None,
910            dec!(100),
911            dec!(0),
912        );
913        assert!(ok.is_ok());
914    }
915
916    /// @negative — montant interdit négatif & transition invalide rejetés
917    /// (erreur typée, pas de panic).
918    #[test]
919    fn negative_amount_and_transition_rejected() {
920        let mut ed = sample();
921        assert!(matches!(
922            ed.update_financial_data(
923                Decimal::ZERO,
924                dec!(-1), // arriérés négatifs interdits
925                Decimal::ZERO,
926                Decimal::ZERO,
927                Decimal::ZERO,
928            )
929            .unwrap_err(),
930            EtatDateError::NegativeAmount(_)
931        ));
932
933        // Requested -> Delivered direct interdit.
934        assert!(matches!(
935            ed.mark_delivered().unwrap_err(),
936            EtatDateError::InvalidTransition { .. }
937        ));
938    }
939
940    /// @security — un état daté (acte légal Art. 577-2 CC) ne peut être créé
941    /// avec une quote-part falsifiée hors [0,100] % : intégrité du document
942    /// opposable au notaire/acquéreur.
943    #[test]
944    fn security_tampered_quota_rejected() {
945        let result = EtatDate::new(
946            Uuid::new_v4(), // acp_id
947            Uuid::new_v4(),
948            Uuid::new_v4(),
949            Uuid::new_v4(),
950            Utc::now(),
951            EtatDateLanguage::Fr,
952            "Maître Dupont".to_string(),
953            "dupont@notaire.be".to_string(),
954            None,
955            "Résidence".to_string(),
956            "Rue".to_string(),
957            "101".to_string(),
958            None,
959            None,
960            dec!(250), // falsifié > 100%
961            dec!(50),
962        );
963        assert!(matches!(
964            result.unwrap_err(),
965            EtatDateError::QuotaOutOfRange(_)
966        ));
967    }
968}