Skip to main content

koprogo_api/domain/services/
dossier_de_gestion.rs

1//! Le dossier de gestion d'une ACP, au sens de l'Art. 3.89 § 5, 7° du Code civil.
2//!
3//! Le syndic sortant doit remettre à son successeur, dans les trente jours,
4//! *« l'ensemble du dossier de la gestion de l'immeuble, y compris la
5//! comptabilité et les archives »*. La loi désigne donc un ensemble hétérogène
6//! de pièces (budgets, écritures, appels de fonds, convocations…) qui ont un
7//! point commun : elles appartiennent à l'**ACP**, personne morale (Art. 3.86),
8//! et non au mandataire qui les a produites.
9//!
10//! Ce module rend ce fait explicite dans le code plutôt que de le laisser
11//! deviner : une pièce sait de quelle ACP elle relève, et le périmètre d'un
12//! syndic se **dérive** de son mandat au lieu d'être gravé dans la pièce.
13//!
14//! Voir ADR-0045.
15
16use crate::domain::entities::SyndicMandate;
17use chrono::{DateTime, Utc};
18use uuid::Uuid;
19
20/// Une pièce du dossier de gestion.
21///
22/// Implémenter ce trait, c'est déclarer qu'une entité est un acte de gestion
23/// posé pour le compte d'une ACP, et donc qu'elle se transmet avec elle.
24pub trait PieceDeGestion {
25    /// L'ACP dont relève la pièce. C'est elle qui en est propriétaire.
26    fn acp_id(&self) -> Uuid;
27}
28
29/// Les pièces qu'un syndic peut légitimement consulter à un moment donné.
30///
31/// Le filtre n'interroge jamais l'auteur de la pièce : il interroge le mandat.
32/// Un syndic voit ce que son mandat lui confie, ni plus, ni après.
33pub fn perimetre_du_mandataire<'a>(
34    pieces: &'a [&'a dyn PieceDeGestion],
35    mandats: &[SyndicMandate],
36    syndic: Uuid,
37    moment: DateTime<Utc>,
38) -> Vec<&'a dyn PieceDeGestion> {
39    pieces
40        .iter()
41        .filter(|piece| {
42            let acp = piece.acp_id();
43            mandats
44                .iter()
45                .filter(|m| m.acp_id == acp)
46                .any(|m| m.covers(moment) && m.organization_id == syndic)
47        })
48        .copied()
49        .collect()
50}
51
52/// Le délai de l'Art. 3.89 § 5, 7°, en jours calendaires.
53///
54/// Calendaires, comme les autres délais de ce chapitre : l'Art. 3.31 § 2 dit
55/// « jour ouvrable » quand il le veut, et son silence ailleurs est délibéré.
56/// Même choix que `releve_notaire::DELAI_JOURS`, pour la même raison.
57pub const DELAI_PASSATION_JOURS: i64 = 30;
58
59/// L'état d'une passation, du point de vue du syndic sortant.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum EtatPassation {
62    /// Dossier remis dans les trente jours.
63    RemisATemps,
64    /// Remis, mais après l'échéance.
65    RemisEnRetard,
66    /// Pas encore remis, délai non écoulé.
67    EnCours,
68    /// Pas remis, délai écoulé. Le successeur gère sans les archives.
69    EnDefaut,
70}
71
72/// La remise du dossier au syndic successeur, et son échéance.
73///
74/// ── Pourquoi cet objet existe ────────────────────────────────────────────
75///
76/// Le module documentait le délai de trente jours depuis toujours, en tête de
77/// fichier, et ne le tenait **nulle part** : aucune échéance, aucun état, rien
78/// qui puisse être dépassé. Le registre légal déclarait pourtant l'obligation
79/// attestée par un test qui vérifie que le successeur voit l'ensemble des
80/// pièces — ce qui est vrai, et ne dit rien du délai (#847).
81///
82/// Un dossier remis en retard n'est pas une formalité manquée : le successeur
83/// gère une copropriété dont il ignore les dettes, les procédures en cours et
84/// les décisions d'assemblée. Il engage sa responsabilité sur des faits qu'il
85/// n'a pas pu connaître.
86#[derive(Debug, Clone, PartialEq)]
87pub struct PassationDeDossier {
88    pub acp_id: Uuid,
89    pub syndic_sortant: Uuid,
90    pub syndic_entrant: Uuid,
91    /// Fin du mandat sortant — c'est elle qui fait courir le délai.
92    pub fin_de_mandat: DateTime<Utc>,
93    pub echeance: DateTime<Utc>,
94    /// Date de remise effective, si elle a eu lieu.
95    pub remis_le: Option<DateTime<Utc>>,
96}
97
98impl PassationDeDossier {
99    pub fn nouvelle(
100        acp_id: Uuid,
101        syndic_sortant: Uuid,
102        syndic_entrant: Uuid,
103        fin_de_mandat: DateTime<Utc>,
104    ) -> Self {
105        Self {
106            acp_id,
107            syndic_sortant,
108            syndic_entrant,
109            fin_de_mandat,
110            echeance: fin_de_mandat + chrono::Duration::days(DELAI_PASSATION_JOURS),
111            remis_le: None,
112        }
113    }
114
115    pub fn remettre(&mut self, le: DateTime<Utc>) {
116        self.remis_le = Some(le);
117    }
118
119    pub fn etat(&self, moment: DateTime<Utc>) -> EtatPassation {
120        match self.remis_le {
121            Some(remis) if remis <= self.echeance => EtatPassation::RemisATemps,
122            Some(_) => EtatPassation::RemisEnRetard,
123            None if moment <= self.echeance => EtatPassation::EnCours,
124            None => EtatPassation::EnDefaut,
125        }
126    }
127
128    /// Combien de jours restent avant l'échéance ? Négatif une fois dépassée.
129    ///
130    /// Sert à prévenir **avant** plutôt qu'à constater après : c'est la seule
131    /// forme utile d'un délai légal dans un logiciel de gestion.
132    pub fn jours_restants(&self, moment: DateTime<Utc>) -> i64 {
133        (self.echeance - moment).num_days()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::domain::entities::{
141        Budget, CallForFunds, ContributionType, Convocation, ConvocationType, EtatDate,
142        EtatDateLanguage, Expense, ExpenseCategory, JournalEntry, JournalEntryLine, Meeting,
143        MeetingType, OwnerContribution, PaymentReminder, ReminderLevel,
144    };
145    use chrono::Duration;
146    use rust_decimal_macros::dec;
147
148    /// Le dossier de gestion d'une ACP conforme, tel qu'il se présente le jour
149    /// d'une passation. Chaque champ est une famille de pièces que
150    /// l'Art. 3.89 § 5, 7° oblige à transmettre.
151    struct DossierComplet {
152        charge: Expense,
153        budget: Budget,
154        appel_de_fonds: CallForFunds,
155        quote_part: OwnerContribution,
156        ecriture: JournalEntry,
157        assemblee: Meeting,
158        convocation: Convocation,
159        etat_date: EtatDate,
160        relance: PaymentReminder,
161    }
162
163    impl DossierComplet {
164        fn pour(acp: Uuid, syndic: Uuid, immeuble: Uuid) -> Self {
165            Self {
166                charge: Expense::new(
167                    acp,
168                    syndic,
169                    immeuble,
170                    ExpenseCategory::Maintenance,
171                    "Entretien de la chaudière".to_string(),
172                    dec!(1200.00),
173                    Utc::now(),
174                    None,
175                    None,
176                    None,
177                )
178                .expect("charge valide"),
179                budget: Budget::new(acp, syndic, immeuble, 2026, dec!(48000.00), dec!(12000.00))
180                    .expect("budget valide"),
181                appel_de_fonds: CallForFunds::new(
182                    acp,
183                    syndic,
184                    immeuble,
185                    "Provision T1 2026".to_string(),
186                    "Charges ordinaires du premier trimestre".to_string(),
187                    dec!(12000.00),
188                    ContributionType::Regular,
189                    Utc::now(),
190                    Utc::now() + Duration::days(30),
191                    None,
192                    rust_decimal::Decimal::ZERO, // part fonds de réserve
193                )
194                .expect("appel de fonds valide"),
195                quote_part: OwnerContribution::new(
196                    acp,
197                    syndic,
198                    Uuid::new_v4(),
199                    Some(Uuid::new_v4()),
200                    "Quote-part provision T1 2026".to_string(),
201                    dec!(1200.00),
202                    ContributionType::Regular,
203                    Utc::now(),
204                    Some("7000".to_string()),
205                )
206                .expect("quote-part valide"),
207                ecriture: {
208                    let id = Uuid::new_v4();
209                    JournalEntry::new(
210                        acp,
211                        syndic,
212                        Some(immeuble),
213                        Utc::now(),
214                        Some("Entretien de la chaudière".to_string()),
215                        None,
216                        Some("ACH".to_string()),
217                        None,
218                        None,
219                        vec![
220                            JournalEntryLine::new_debit(
221                                id,
222                                syndic,
223                                "610".to_string(),
224                                dec!(1200.00),
225                                None,
226                            )
227                            .expect("débit valide"),
228                            JournalEntryLine::new_credit(
229                                id,
230                                syndic,
231                                "440".to_string(),
232                                dec!(1200.00),
233                                None,
234                            )
235                            .expect("crédit valide"),
236                        ],
237                        None,
238                    )
239                    .expect("écriture valide")
240                },
241                assemblee: Meeting::new(
242                    acp,
243                    syndic,
244                    immeuble,
245                    MeetingType::Ordinary,
246                    "AGO 2026".to_string(),
247                    None,
248                    Utc::now() + Duration::days(30),
249                    "Salle communale".to_string(),
250                )
251                .expect("assemblée valide"),
252                convocation: Convocation::new(
253                    acp,
254                    syndic,
255                    immeuble,
256                    Uuid::new_v4(),
257                    ConvocationType::Ordinary,
258                    Utc::now() + Duration::days(30),
259                    "FR".to_string(),
260                    Uuid::new_v4(),
261                )
262                .expect("convocation valide"),
263                etat_date: EtatDate::new(
264                    acp,
265                    syndic,
266                    immeuble,
267                    Uuid::new_v4(),
268                    Utc::now(),
269                    EtatDateLanguage::Fr,
270                    "Me Dupont".to_string(),
271                    "dupont@notaire.be".to_string(),
272                    None,
273                    "Résidence du Parc".to_string(),
274                    "12 Rue de la Loi".to_string(),
275                    "A101".to_string(),
276                    Some("1".to_string()),
277                    Some(85.0),
278                    dec!(100),
279                    dec!(100),
280                )
281                .expect("état daté valide"),
282                relance: PaymentReminder::new(
283                    acp,
284                    syndic,
285                    Uuid::new_v4(),
286                    Uuid::new_v4(),
287                    ReminderLevel::FirstReminder,
288                    dec!(450.00),
289                    Utc::now() - Duration::days(45),
290                    45,
291                )
292                .expect("relance valide"),
293            }
294        }
295
296        fn pieces(&self) -> Vec<&dyn PieceDeGestion> {
297            vec![
298                &self.charge,
299                &self.budget,
300                &self.appel_de_fonds,
301                &self.quote_part,
302                &self.ecriture,
303                &self.assemblee,
304                &self.convocation,
305                &self.etat_date,
306                &self.relance,
307            ]
308        }
309    }
310
311    /// Art. 3.89 § 5, 7° : à la passation, le dossier passe en entier au
312    /// successeur, et cesse d'être accessible au sortant.
313    /// Art. 3.89 § 5, 7° : le dossier est transmis **dans les trente jours**.
314    ///
315    /// Le module portait ce délai en commentaire de tête depuis toujours et ne
316    /// le tenait nulle part. Le registre légal le déclarait pourtant attesté,
317    /// par un test qui vérifie que le successeur voit l'ensemble des pièces —
318    /// vrai, et muet sur le délai (#847). C'est la variante la plus sournoise
319    /// du motif : une preuve qui existe, qui passe, et qui prouve autre chose.
320    #[test]
321    fn negative_passe_trente_jours_sans_remise_le_syndic_sortant_est_en_defaut() {
322        let acp = Uuid::new_v4();
323        let sortant = Uuid::new_v4();
324        let entrant = Uuid::new_v4();
325        let fin = Utc::now() - Duration::days(40);
326
327        let passation = PassationDeDossier::nouvelle(acp, sortant, entrant, fin);
328
329        // L'échéance tombe trente jours après la fin du mandat, pas un de plus.
330        assert_eq!(
331            passation.echeance,
332            fin + Duration::days(30),
333            "le délai court depuis la fin du mandat"
334        );
335
336        // Le vingt-neuvième jour, le sortant est encore dans les temps.
337        assert_eq!(
338            passation.etat(fin + Duration::days(29)),
339            EtatPassation::EnCours
340        );
341        assert_eq!(passation.jours_restants(fin + Duration::days(29)), 1);
342
343        // Le trente et unième, il est en défaut : le successeur gère une
344        // copropriété dont il ignore les dettes et les procédures en cours.
345        assert_eq!(
346            passation.etat(fin + Duration::days(31)),
347            EtatPassation::EnDefaut
348        );
349
350        // Une remise tardive ne rétroagit pas : elle est constatée en retard.
351        let mut tardive = passation.clone();
352        tardive.remettre(fin + Duration::days(45));
353        assert_eq!(
354            tardive.etat(Utc::now()),
355            EtatPassation::RemisEnRetard,
356            "remettre après l'échéance ne régularise rien"
357        );
358
359        // Remise dans les temps : honorée, quel que soit le moment où on juge.
360        let mut a_temps = passation.clone();
361        a_temps.remettre(fin + Duration::days(10));
362        assert_eq!(a_temps.etat(Utc::now()), EtatPassation::RemisATemps);
363    }
364
365    #[test]
366    fn le_dossier_de_gestion_suit_lacp_lors_dune_passation() {
367        let acp = Uuid::new_v4();
368        let immeuble = Uuid::new_v4();
369        let cabinet_sortant = Uuid::new_v4();
370        let cabinet_entrant = Uuid::new_v4();
371
372        let passation = Utc::now();
373        let avant = passation - Duration::days(30);
374        let apres = passation + Duration::days(1);
375
376        // Le dossier a été constitué par le cabinet sortant, pour l'ACP.
377        let dossier = DossierComplet::pour(acp, cabinet_sortant, immeuble);
378        let pieces = dossier.pieces();
379
380        let mut mandat_sortant = SyndicMandate::new(acp, cabinet_sortant, avant, None);
381        mandat_sortant
382            .revoke(
383                passation,
384                None,
385                Some("Fin de mandat votée en AG".to_string()),
386            )
387            .expect("révocation valide");
388        let mandat_entrant = SyndicMandate::new(acp, cabinet_entrant, passation, None);
389        let mandats = vec![mandat_sortant, mandat_entrant];
390
391        // Avant la passation : le sortant tient le dossier, l'entrant n'existe pas encore.
392        assert_eq!(
393            perimetre_du_mandataire(&pieces, &mandats, cabinet_sortant, avant).len(),
394            pieces.len(),
395            "le mandataire en fonction doit voir tout le dossier"
396        );
397        assert!(
398            perimetre_du_mandataire(&pieces, &mandats, cabinet_entrant, avant).is_empty(),
399            "un cabinet sans mandat ne voit rien, même une pièce future"
400        );
401
402        // Après la passation : le dossier a suivi l'ACP, sans qu'une seule pièce bouge.
403        assert_eq!(
404            perimetre_du_mandataire(&pieces, &mandats, cabinet_entrant, apres).len(),
405            pieces.len(),
406            "Art. 3.89 § 5, 7° : le successeur reçoit l'ensemble du dossier"
407        );
408        assert!(
409            perimetre_du_mandataire(&pieces, &mandats, cabinet_sortant, apres).is_empty(),
410            "le mandat éteint, le sortant n'a plus de base pour consulter le dossier"
411        );
412    }
413
414    /// Le dossier reste rattaché même quand personne ne le gère : une ACP
415    /// entre deux mandats n'est pas une ACP sans comptabilité.
416    #[test]
417    fn une_acp_sans_mandataire_conserve_son_dossier() {
418        let acp = Uuid::new_v4();
419        let ancien_syndic = Uuid::new_v4();
420        let passation = Utc::now();
421
422        let dossier = DossierComplet::pour(acp, ancien_syndic, Uuid::new_v4());
423        let pieces = dossier.pieces();
424
425        let mut mandat =
426            SyndicMandate::new(acp, ancien_syndic, passation - Duration::days(90), None);
427        mandat
428            .revoke(passation, None, None)
429            .expect("révocation valide");
430
431        assert!(
432            perimetre_du_mandataire(&pieces, &[mandat], ancien_syndic, passation).is_empty(),
433            "plus personne ne voit le dossier"
434        );
435        for piece in &pieces {
436            assert_eq!(
437                piece.acp_id(),
438                acp,
439                "mais chaque pièce sait encore à qui elle est"
440            );
441        }
442    }
443
444    /// Deux ACP confiées au même cabinet ne se mélangent pas.
445    #[test]
446    fn un_syndic_ne_voit_pas_le_dossier_dune_acp_quil_ne_gere_pas() {
447        let acp_geree = Uuid::new_v4();
448        let acp_voisine = Uuid::new_v4();
449        let cabinet = Uuid::new_v4();
450        let maintenant = Utc::now();
451
452        let dossier_voisin = DossierComplet::pour(acp_voisine, cabinet, Uuid::new_v4());
453        let pieces = dossier_voisin.pieces();
454
455        let mandat = SyndicMandate::new(acp_geree, cabinet, maintenant - Duration::days(10), None);
456
457        assert!(
458            perimetre_du_mandataire(&pieces, &[mandat], cabinet, maintenant).is_empty(),
459            "un mandat sur une ACP n'ouvre rien sur une autre, même chez le même cabinet"
460        );
461    }
462}