Skip to main content

koprogo_api/domain/copropriete/
mention_numero_entreprise.rs

1//! La mention du numéro d'entreprise sur les documents de l'ACP.
2//!
3//! Art. 3.86 § 1er, alinéa 4 :
4//!
5//! > « **Tous les documents** émanant de l'association des copropriétaires
6//! > mentionnent le **numéro d'entreprise** de ladite association. »
7//!
8//! « Tous » est sans exception : convocations, procès-verbaux, appels de
9//! fonds, états datés, décomptes, courriers de relance. Le champ existait sur
10//! l'ACP depuis longtemps ; ce qui manquait, c'est que les documents le
11//! portent effectivement.
12//!
13//! **Le contrôle doit être exhaustif par construction, pas exporteur par
14//! exporteur.** Vérifier chaque exporteur à la main garantit qu'on oubliera le
15//! prochain — celui qu'un développeur ajoutera dans six mois sans avoir lu cet
16//! article. D'où [`verifier_les_documents`], qui prend la liste des documents
17//! produits et refuse celui qui omet la mention, et le test d'exhaustivité qui
18//! l'accompagne.
19//!
20//! Un lien avec la personnalité juridique, qui n'est pas anodin : le numéro
21//! d'entreprise est attribué à l'ACP quand elle acquiert la personnalité
22//! (Art. 3.86 § 1er). Une ACP qui n'en a pas encore n'est pas fautive de ne
23//! pas le mentionner — elle est incomplète, et le troisième état le dit,
24//! comme ailleurs.
25//!
26//! Voir issue #748 et [`super::personnalite_juridique`].
27
28/// Un document produit par l'ACP, du point de vue de cette obligation.
29#[derive(Debug, Clone, PartialEq)]
30pub struct DocumentProduit {
31    /// Ce qu'il est, pour que le manquement soit nommable.
32    pub nature: &'static str,
33    /// Le texte rendu, dans lequel la mention doit apparaître.
34    pub contenu: String,
35}
36
37/// Le résultat de la vérification.
38#[derive(Debug, Clone, PartialEq)]
39pub enum MentionNumeroEntreprise {
40    /// La mention est présente sur tous les documents.
41    Presente,
42    /// L'ACP n'a pas encore de numéro d'entreprise.
43    ///
44    /// Elle n'est pas fautive : le numéro est attribué avec la personnalité
45    /// juridique. Le distinguer d'un manquement évite d'accuser une ACP en
46    /// cours de constitution.
47    NumeroNonAttribue,
48    /// Des documents omettent la mention.
49    Manquante { documents: Vec<&'static str> },
50}
51
52impl std::fmt::Display for MentionNumeroEntreprise {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Presente => write!(f, "Numéro d'entreprise mentionné sur tous les documents"),
56            Self::NumeroNonAttribue => write!(
57                f,
58                "Numéro d'entreprise non attribué : l'ACP n'a pas encore acquis la \
59                 personnalité juridique (Art. 3.86 § 1er)"
60            ),
61            Self::Manquante { documents } => write!(
62                f,
63                "Art. 3.86 § 1er : le numéro d'entreprise manque sur {} — « tous les \
64                 documents émanant de l'association des copropriétaires mentionnent le \
65                 numéro d'entreprise ».",
66                documents.join(", ")
67            ),
68        }
69    }
70}
71
72/// Vérifie que tous les documents portent le numéro d'entreprise.
73///
74/// La comparaison ignore les espaces : un numéro s'écrit « BE 0123.456.789 »
75/// ou « BE0123456789 » selon les habitudes, et refuser la seconde forme serait
76/// signaler un manquement là où il n'y en a pas.
77pub fn verifier_les_documents(
78    numero_entreprise: Option<&str>,
79    documents: &[DocumentProduit],
80) -> MentionNumeroEntreprise {
81    let Some(numero) = numero_entreprise else {
82        return MentionNumeroEntreprise::NumeroNonAttribue;
83    };
84
85    let normalise = |s: &str| {
86        s.chars()
87            .filter(|c| c.is_alphanumeric())
88            .collect::<String>()
89    };
90    let attendu = normalise(numero);
91
92    let manquants: Vec<&'static str> = documents
93        .iter()
94        .filter(|d| !normalise(&d.contenu).contains(&attendu))
95        .map(|d| d.nature)
96        .collect();
97
98    if manquants.is_empty() {
99        MentionNumeroEntreprise::Presente
100    } else {
101        MentionNumeroEntreprise::Manquante {
102            documents: manquants,
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn document(nature: &'static str, contenu: &str) -> DocumentProduit {
112        DocumentProduit {
113            nature,
114            contenu: contenu.to_string(),
115        }
116    }
117
118    const NUMERO: &str = "BE 0123.456.789";
119
120    #[test]
121    fn happy_un_document_portant_la_mention_passe() {
122        let docs = vec![document(
123            "convocation",
124            "ACP Résidence du Parc — BE 0123.456.789\nConvocation à l'AGO...",
125        )];
126        assert_eq!(
127            verifier_les_documents(Some(NUMERO), &docs),
128            MentionNumeroEntreprise::Presente
129        );
130    }
131
132    /// Un numéro s'écrit avec ou sans séparateurs selon les habitudes.
133    ///
134    /// Refuser la forme compacte signalerait un manquement là où il n'y en a
135    /// pas.
136    #[test]
137    fn happy_la_forme_compacte_est_acceptee() {
138        let docs = vec![document("état daté", "ACP — BE0123456789\nÉtat daté...")];
139        assert_eq!(
140            verifier_les_documents(Some(NUMERO), &docs),
141            MentionNumeroEntreprise::Presente
142        );
143    }
144
145    #[test]
146    fn negative_un_document_sans_mention_est_signale() {
147        let docs = vec![document(
148            "appel de fonds",
149            "Appel de fonds T1 2026\nMontant : 1200 €",
150        )];
151        assert_eq!(
152            verifier_les_documents(Some(NUMERO), &docs),
153            MentionNumeroEntreprise::Manquante {
154                documents: vec!["appel de fonds"]
155            }
156        );
157    }
158
159    /// « Tous les documents » : un seul oubli parmi cinq suffit.
160    #[test]
161    fn security_un_seul_oubli_parmi_cinq_est_releve() {
162        let avec = format!("En-tête — {NUMERO}\nCorps du document");
163        let docs = vec![
164            document("convocation", &avec),
165            document("procès-verbal", &avec),
166            document("appel de fonds", "Appel de fonds T1 2026"),
167            document("état daté", &avec),
168            document("décompte", &avec),
169        ];
170        assert_eq!(
171            verifier_les_documents(Some(NUMERO), &docs),
172            MentionNumeroEntreprise::Manquante {
173                documents: vec!["appel de fonds"]
174            }
175        );
176    }
177
178    #[test]
179    fn negative_plusieurs_oublis_remontent_ensemble() {
180        let docs = vec![
181            document("convocation", "Convocation"),
182            document("relance", "Relance de paiement"),
183        ];
184        match verifier_les_documents(Some(NUMERO), &docs) {
185            MentionNumeroEntreprise::Manquante { documents } => {
186                assert_eq!(documents.len(), 2);
187            }
188            autre => panic!("attendu un manquement : {autre}"),
189        }
190    }
191
192    /// @edge — une ACP sans numéro n'est pas fautive.
193    ///
194    /// Le numéro est attribué avec la personnalité juridique
195    /// (Art. 3.86 § 1er). Signaler un manquement accuserait une ACP en cours
196    /// de constitution de ne pas mentionner ce qu'elle n'a pas.
197    #[test]
198    fn edge_une_acp_sans_numero_nest_pas_en_manquement() {
199        let docs = vec![document("convocation", "Convocation à l'AGO")];
200        assert_eq!(
201            verifier_les_documents(None, &docs),
202            MentionNumeroEntreprise::NumeroNonAttribue
203        );
204    }
205
206    #[test]
207    fn happy_aucun_document_produit_ne_pose_pas_de_probleme() {
208        assert_eq!(
209            verifier_les_documents(Some(NUMERO), &[]),
210            MentionNumeroEntreprise::Presente
211        );
212    }
213
214    /// @security — un numéro voisin ne passe pas pour le bon.
215    ///
216    /// Le cas se produit après une reprise de dossier : l'en-tête garde le
217    /// numéro de l'ACP précédente.
218    #[test]
219    fn security_le_numero_dune_autre_acp_ne_satisfait_pas_lobligation() {
220        let docs = vec![document(
221            "convocation",
222            "ACP voisine — BE 0987.654.321\nConvocation",
223        )];
224        assert!(matches!(
225            verifier_les_documents(Some(NUMERO), &docs),
226            MentionNumeroEntreprise::Manquante { .. }
227        ));
228    }
229}