koprogo_api/domain/copropriete/
fonds_de_reserve.rs1use super::acp::RESERVE_FUND_RATE;
32use chrono::{Datelike, NaiveDate};
33use rust_decimal::Decimal;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum StatutFondsReserve {
38 ReceptionInconnue,
44 PasEncoreExigible { exigible_le: NaiveDate },
46 EcarteParLassemblee,
48 Exigible { plancher_annuel: Decimal },
50}
51
52#[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
72pub fn plancher_annuel(charges_ordinaires_n_moins_1: Decimal) -> Decimal {
79 (charges_ordinaires_n_moins_1 * RESERVE_FUND_RATE).round_dp(2)
80}
81
82pub fn exigible_le(reception_provisoire: NaiveDate) -> Option<NaiveDate> {
86 reception_provisoire.with_year(reception_provisoire.year() + 5)
87}
88
89pub 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
115pub 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 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 #[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 #[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 #[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 #[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}