Skip to main content

koprogo_api/domain/copropriete/
fonds_de_reserve.rs

1//! L'obligation de constituer un fonds de réserve.
2//!
3//! Art. 3.86 § 3, alinéa 4 :
4//!
5//! > « L'association des copropriétaires doit constituer **au plus tard à
6//! > l'issue d'une période de cinq ans suivant la date de la réception
7//! > provisoire des parties communes** de l'immeuble, un fonds de réserve dont
8//! > la **contribution annuelle ne peut être inférieure à cinq pour cent de la
9//! > totalité des charges communes ordinaires de l'exercice précédent** ;
10//! > l'association des copropriétaires peut décider **à une majorité de quatre
11//! > cinquième des voix** de ne pas constituer ce fonds de réserve
12//! > obligatoire. »
13//!
14//! Trois conditions, et il faut les tenir ensemble :
15//!
16//! 1. une **échéance** — cinq ans après la réception provisoire, pas après la
17//!    constitution de l'ACP ni après la première AG ;
18//! 2. un **plancher** — 5 % des charges communes **ordinaires** de l'exercice
19//!    précédent. Les charges extraordinaires en sont exclues, et les y inclure
20//!    gonflerait artificiellement l'obligation ;
21//! 3. une **échappatoire** — la renonciation, mais seulement aux quatre
22//!    cinquièmes des voix, ce qui n'est pas une majorité qu'on obtient
23//!    distraitement.
24//!
25//! Ce que le domaine portait déjà : le taux (`RESERVE_FUND_RATE`), le solde et
26//! le drapeau de renonciation. Ce qui manquait : le calcul, l'échéance, et le
27//! fait de savoir dans quel état on se trouve.
28//!
29//! Voir issue #738 et ADR-0012.
30
31use super::acp::RESERVE_FUND_RATE;
32use chrono::{Datelike, NaiveDate};
33use rust_decimal::Decimal;
34
35/// Où en est une ACP vis-à-vis de son obligation.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum StatutFondsReserve {
38    /// La réception provisoire n'est pas encodée : on ne peut pas dater
39    /// l'échéance, donc ni exiger ni dispenser.
40    ///
41    /// Ce troisième état est délibéré. Répondre « pas encore exigible »
42    /// dispenserait une ACP qui l'est peut-être depuis des années.
43    ReceptionInconnue,
44    /// Le délai de cinq ans court encore.
45    PasEncoreExigible { exigible_le: NaiveDate },
46    /// L'assemblée a renoncé aux quatre cinquièmes des voix.
47    EcarteParLassemblee,
48    /// L'obligation est due, avec son plancher annuel.
49    Exigible { plancher_annuel: Decimal },
50}
51
52/// Ce que refuse un budget qui sous-dote le fonds.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DotationInsuffisante {
55    pub prevue: Decimal,
56    pub plancher: Decimal,
57    pub charges_ordinaires_n_moins_1: Decimal,
58}
59
60impl std::fmt::Display for DotationInsuffisante {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(
63            f,
64            "Art. 3.86 § 3 : la dotation au fonds de réserve prévue ({}) est inférieure au \
65             plancher légal de {} — 5 % des {} de charges ordinaires de l'exercice précédent. \
66             Seule une renonciation votée aux 4/5 en dispense.",
67            self.prevue, self.plancher, self.charges_ordinaires_n_moins_1
68        )
69    }
70}
71
72/// Le plancher annuel : 5 % des charges communes **ordinaires** de l'exercice
73/// précédent.
74///
75/// Les charges extraordinaires sont hors du calcul. Les y ajouter gonflerait
76/// l'obligation d'une ACP qui a fait de gros travaux, alors que l'article vise
77/// précisément le train de vie courant.
78pub fn plancher_annuel(charges_ordinaires_n_moins_1: Decimal) -> Decimal {
79    (charges_ordinaires_n_moins_1 * RESERVE_FUND_RATE).round_dp(2)
80}
81
82/// La date à laquelle l'obligation devient exigible.
83///
84/// « À l'issue d'une période de cinq ans suivant la réception provisoire ».
85pub fn exigible_le(reception_provisoire: NaiveDate) -> Option<NaiveDate> {
86    reception_provisoire.with_year(reception_provisoire.year() + 5)
87}
88
89/// L'état de l'obligation à une date donnée.
90pub fn statut(
91    reception_provisoire: Option<NaiveDate>,
92    aujourdhui: NaiveDate,
93    renonciation_votee: bool,
94    charges_ordinaires_n_moins_1: Decimal,
95) -> StatutFondsReserve {
96    if renonciation_votee {
97        return StatutFondsReserve::EcarteParLassemblee;
98    }
99    let Some(reception) = reception_provisoire else {
100        return StatutFondsReserve::ReceptionInconnue;
101    };
102    let Some(echeance) = exigible_le(reception) else {
103        return StatutFondsReserve::ReceptionInconnue;
104    };
105    if aujourdhui < echeance {
106        return StatutFondsReserve::PasEncoreExigible {
107            exigible_le: echeance,
108        };
109    }
110    StatutFondsReserve::Exigible {
111        plancher_annuel: plancher_annuel(charges_ordinaires_n_moins_1),
112    }
113}
114
115/// Un budget dote-t-il suffisamment le fonds de réserve ?
116///
117/// Ne refuse que dans l'état `Exigible` : une ACP encore dans ses cinq ans, ou
118/// qui a voté la renonciation, peut doter ce qu'elle veut — y compris rien.
119pub fn verifier_dotation(
120    statut: &StatutFondsReserve,
121    dotation_prevue: Decimal,
122    charges_ordinaires_n_moins_1: Decimal,
123) -> Result<(), DotationInsuffisante> {
124    let StatutFondsReserve::Exigible { plancher_annuel } = statut else {
125        return Ok(());
126    };
127    if dotation_prevue < *plancher_annuel {
128        return Err(DotationInsuffisante {
129            prevue: dotation_prevue,
130            plancher: *plancher_annuel,
131            charges_ordinaires_n_moins_1,
132        });
133    }
134    Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use rust_decimal_macros::dec;
141
142    fn le(annee: i32, mois: u32, jour: u32) -> NaiveDate {
143        NaiveDate::from_ymd_opt(annee, mois, jour).expect("date valide")
144    }
145
146    #[test]
147    fn happy_le_plancher_vaut_cinq_pourcents_des_charges_ordinaires() {
148        assert_eq!(plancher_annuel(dec!(48000)), dec!(2400));
149    }
150
151    #[test]
152    fn edge_le_plancher_sarrondit_au_centime() {
153        // 5 % de 12 345,67 = 617,2835 → 617,28. Pas de dérive flottante
154        // (ADR-0007) : c'est une somme opposable à des copropriétaires.
155        assert_eq!(plancher_annuel(dec!(12345.67)), dec!(617.28));
156    }
157
158    #[test]
159    fn happy_lecheance_court_cinq_ans_apres_la_reception_provisoire() {
160        assert_eq!(exigible_le(le(2021, 3, 15)), Some(le(2026, 3, 15)));
161    }
162
163    #[test]
164    fn happy_avant_cinq_ans_lobligation_nest_pas_due() {
165        let statut = statut(Some(le(2023, 6, 1)), le(2026, 9, 3), false, dec!(48000));
166        assert_eq!(
167            statut,
168            StatutFondsReserve::PasEncoreExigible {
169                exigible_le: le(2028, 6, 1)
170            }
171        );
172    }
173
174    #[test]
175    fn happy_passe_cinq_ans_le_plancher_sapplique() {
176        let statut = statut(Some(le(2019, 6, 1)), le(2026, 9, 3), false, dec!(48000));
177        assert_eq!(
178            statut,
179            StatutFondsReserve::Exigible {
180                plancher_annuel: dec!(2400)
181            }
182        );
183    }
184
185    /// @edge — le jour même de l'échéance, l'obligation est due.
186    ///
187    /// « À l'issue d'une période de cinq ans » : la période est écoulée ce
188    /// jour-là.
189    #[test]
190    fn edge_le_jour_de_lecheance_lobligation_est_due() {
191        let statut = statut(Some(le(2021, 6, 1)), le(2026, 6, 1), false, dec!(48000));
192        assert!(matches!(statut, StatutFondsReserve::Exigible { .. }));
193    }
194
195    #[test]
196    fn happy_la_renonciation_aux_quatre_cinquiemes_dispense() {
197        let statut = statut(Some(le(2015, 6, 1)), le(2026, 9, 3), true, dec!(48000));
198        assert_eq!(statut, StatutFondsReserve::EcarteParLassemblee);
199    }
200
201    /// @security — sans réception provisoire encodée, on ne dispense pas.
202    ///
203    /// Répondre « pas encore exigible » dispenserait une ACP qui l'est
204    /// peut-être depuis des années. Le troisième état est le seul honnête.
205    #[test]
206    fn security_sans_reception_provisoire_on_ne_dispense_pas() {
207        let statut = statut(None, le(2026, 9, 3), false, dec!(48000));
208        assert_eq!(statut, StatutFondsReserve::ReceptionInconnue);
209        assert!(
210            !matches!(statut, StatutFondsReserve::PasEncoreExigible { .. }),
211            "l'inconnu ne vaut pas dispense"
212        );
213    }
214
215    // ── La vérification d'un budget ────────────────────────────────────
216
217    #[test]
218    fn negative_un_budget_qui_sous_dote_est_refuse() {
219        let statut = StatutFondsReserve::Exigible {
220            plancher_annuel: dec!(2400),
221        };
222        let refus = verifier_dotation(&statut, dec!(1000), dec!(48000)).expect_err("doit refuser");
223        assert_eq!(refus.plancher, dec!(2400));
224        assert_eq!(refus.prevue, dec!(1000));
225        assert!(
226            refus.to_string().contains("4/5"),
227            "le refus doit dire l'issue"
228        );
229    }
230
231    #[test]
232    fn edge_une_dotation_exactement_au_plancher_passe() {
233        let statut = StatutFondsReserve::Exigible {
234            plancher_annuel: dec!(2400),
235        };
236        assert!(verifier_dotation(&statut, dec!(2400), dec!(48000)).is_ok());
237    }
238
239    #[test]
240    fn happy_avant_lecheance_une_dotation_nulle_est_licite() {
241        let statut = StatutFondsReserve::PasEncoreExigible {
242            exigible_le: le(2028, 6, 1),
243        };
244        assert!(verifier_dotation(&statut, Decimal::ZERO, dec!(48000)).is_ok());
245    }
246
247    #[test]
248    fn happy_apres_renonciation_une_dotation_nulle_est_licite() {
249        assert!(verifier_dotation(
250            &StatutFondsReserve::EcarteParLassemblee,
251            Decimal::ZERO,
252            dec!(48000)
253        )
254        .is_ok());
255    }
256
257    /// Les charges extraordinaires ne gonflent pas le plancher.
258    ///
259    /// Une ACP qui a refait sa toiture ne doit pas voir son obligation de
260    /// réserve exploser l'année suivante : l'article vise le train de vie
261    /// courant, et c'est à l'appelant de ne passer que l'ordinaire.
262    #[test]
263    fn happy_le_plancher_ignore_ce_quon_ne_lui_donne_pas() {
264        let ordinaire_seul = plancher_annuel(dec!(48000));
265        let avec_extraordinaire = plancher_annuel(dec!(48000) + dec!(120000));
266        assert_ne!(ordinaire_seul, avec_extraordinaire);
267        assert_eq!(ordinaire_seul, dec!(2400));
268    }
269}