Skip to main content

koprogo_api/domain/comptabilite/
budget.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Statut du budget annuel
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type, utoipa::ToSchema)]
8#[sqlx(type_name = "budget_status", rename_all = "snake_case")]
9pub enum BudgetStatus {
10    Draft,     // Brouillon (en préparation)
11    Submitted, // Soumis pour vote en AG
12    Approved,  // Approuvé par l'AG (actif)
13    Rejected,  // Rejeté par l'AG
14    Archived,  // Archivé (exercice terminé)
15}
16
17/// Représente un budget annuel de copropriété (ordinaire + extraordinaire)
18///
19/// Obligation légale belge: Le budget doit être voté en AG avant le début
20/// de l'exercice fiscal. Il détermine les provisions mensuelles à appeler
21/// auprès des copropriétaires.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct Budget {
24    pub id: Uuid,
25
26    /// L'ACP dont ce budget est le budget.
27    ///
28    /// Deux faits distincts, et c'est leur confusion qui a fait appartenir le
29    /// dossier de gestion au syndic (ADR-0045) : le budget prévisionnel est
30    /// **préparé par** le syndic (Art. 3.89 § 5, 16°) mais **appartient à**
31    /// l'ACP, qui le vote et le supporte. Le mandat change, le budget reste.
32    pub acp_id: Uuid,
33
34    /// Le syndic qui a préparé ce budget, conservé comme trace d'auteur.
35    ///
36    /// N'entre dans aucun prédicat d'autorisation : le périmètre d'un syndic
37    /// se dérive de son mandat, cf. `perimetre_du_mandataire`.
38    pub organization_id: Uuid,
39    pub building_id: Uuid,
40
41    /// Année fiscale (ex: 2025)
42    pub fiscal_year: i32,
43
44    /// Budget charges ordinaires (€) - Charges courantes récurrentes
45    pub ordinary_budget: Decimal,
46
47    /// Budget charges extraordinaires (€) - Travaux et dépenses exceptionnelles
48    pub extraordinary_budget: Decimal,
49
50    /// Budget total (€) = ordinaire + extraordinaire
51    pub total_budget: Decimal,
52
53    /// Statut du budget
54    pub status: BudgetStatus,
55
56    /// Date de soumission pour vote AG
57    pub submitted_date: Option<DateTime<Utc>>,
58
59    /// Date d'approbation par l'AG
60    pub approved_date: Option<DateTime<Utc>>,
61
62    /// ID de l'AG qui a approuvé le budget
63    pub approved_by_meeting_id: Option<Uuid>,
64
65    /// Montant mensuel des provisions à appeler (€)
66    /// = total_budget / 12 mois
67    pub monthly_provision_amount: Decimal,
68
69    /// Notes / Commentaires
70    pub notes: Option<String>,
71
72    pub created_at: DateTime<Utc>,
73    pub updated_at: DateTime<Utc>,
74}
75
76impl Budget {
77    pub fn new(
78        acp_id: Uuid,
79        organization_id: Uuid,
80        building_id: Uuid,
81        fiscal_year: i32,
82        ordinary_budget: Decimal,
83        extraordinary_budget: Decimal,
84    ) -> Result<Self, String> {
85        // Validations
86        if fiscal_year < 2000 || fiscal_year > 2100 {
87            return Err("Fiscal year must be between 2000 and 2100".to_string());
88        }
89
90        if ordinary_budget < Decimal::ZERO {
91            return Err("Ordinary budget cannot be negative".to_string());
92        }
93
94        if extraordinary_budget < Decimal::ZERO {
95            return Err("Extraordinary budget cannot be negative".to_string());
96        }
97
98        let total_budget = ordinary_budget + extraordinary_budget;
99
100        if total_budget == Decimal::ZERO {
101            return Err("Total budget cannot be zero".to_string());
102        }
103
104        // Calcul provisions mensuelles (division Decimal exacte — cf. ADR-0007)
105        let monthly_provision_amount = total_budget / Decimal::from(12);
106
107        let now = Utc::now();
108        Ok(Self {
109            id: Uuid::new_v4(),
110            acp_id,
111            organization_id,
112            building_id,
113            fiscal_year,
114            ordinary_budget,
115            extraordinary_budget,
116            total_budget,
117            status: BudgetStatus::Draft,
118            submitted_date: None,
119            approved_date: None,
120            approved_by_meeting_id: None,
121            monthly_provision_amount,
122            notes: None,
123            created_at: now,
124            updated_at: now,
125        })
126    }
127
128    /// Soumet le budget pour vote en AG
129    pub fn submit_for_approval(&mut self) -> Result<(), String> {
130        match self.status {
131            BudgetStatus::Draft | BudgetStatus::Rejected => {
132                self.status = BudgetStatus::Submitted;
133                self.submitted_date = Some(Utc::now());
134                self.updated_at = Utc::now();
135                Ok(())
136            }
137            _ => Err(format!(
138                "Cannot submit budget with status {:?}",
139                self.status
140            )),
141        }
142    }
143
144    /// Approuve le budget (vote AG positif)
145    pub fn approve(&mut self, meeting_id: Uuid) -> Result<(), String> {
146        match self.status {
147            BudgetStatus::Submitted => {
148                self.status = BudgetStatus::Approved;
149                self.approved_date = Some(Utc::now());
150                self.approved_by_meeting_id = Some(meeting_id);
151                self.updated_at = Utc::now();
152                Ok(())
153            }
154            _ => Err(format!(
155                "Cannot approve budget with status {:?}",
156                self.status
157            )),
158        }
159    }
160
161    /// Rejette le budget (vote AG négatif)
162    pub fn reject(&mut self) -> Result<(), String> {
163        match self.status {
164            BudgetStatus::Submitted => {
165                self.status = BudgetStatus::Rejected;
166                self.updated_at = Utc::now();
167                Ok(())
168            }
169            _ => Err(format!(
170                "Cannot reject budget with status {:?}",
171                self.status
172            )),
173        }
174    }
175
176    /// Archive le budget (fin d'exercice)
177    pub fn archive(&mut self) -> Result<(), String> {
178        match self.status {
179            BudgetStatus::Approved => {
180                self.status = BudgetStatus::Archived;
181                self.updated_at = Utc::now();
182                Ok(())
183            }
184            _ => Err(format!(
185                "Cannot archive budget with status {:?}",
186                self.status
187            )),
188        }
189    }
190
191    /// Met à jour les montants du budget (uniquement en Draft)
192    pub fn update_amounts(
193        &mut self,
194        ordinary_budget: Decimal,
195        extraordinary_budget: Decimal,
196    ) -> Result<(), String> {
197        if !self.is_editable() {
198            return Err("Can only update amounts in Draft or Rejected status".to_string());
199        }
200
201        if ordinary_budget < Decimal::ZERO {
202            return Err("Ordinary budget cannot be negative".to_string());
203        }
204
205        if extraordinary_budget < Decimal::ZERO {
206            return Err("Extraordinary budget cannot be negative".to_string());
207        }
208
209        let total_budget = ordinary_budget + extraordinary_budget;
210
211        if total_budget == Decimal::ZERO {
212            return Err("Total budget cannot be zero".to_string());
213        }
214
215        self.ordinary_budget = ordinary_budget;
216        self.extraordinary_budget = extraordinary_budget;
217        self.total_budget = total_budget;
218        self.monthly_provision_amount = total_budget / Decimal::from(12);
219        self.updated_at = Utc::now();
220
221        Ok(())
222    }
223
224    /// Ajoute/met à jour les notes
225    pub fn update_notes(&mut self, notes: String) {
226        self.notes = Some(notes);
227        self.updated_at = Utc::now();
228    }
229
230    /// Vérifie si le budget est actif (approuvé et pas encore archivé)
231    pub fn is_active(&self) -> bool {
232        self.status == BudgetStatus::Approved
233    }
234
235    /// Vérifie si le budget peut être modifié
236    pub fn is_editable(&self) -> bool {
237        matches!(self.status, BudgetStatus::Draft | BudgetStatus::Rejected)
238    }
239}
240
241impl crate::domain::services::PieceDeGestion for Budget {
242    fn acp_id(&self) -> Uuid {
243        self.acp_id
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use rust_decimal_macros::dec;
251
252    /// Un budget de test, pour une ACP anonyme préparé par un syndic anonyme.
253    ///
254    /// Le premier identifiant est celui de l'ACP, pas du syndic : c'est elle
255    /// qui vote le budget et le supporte (Art. 3.89 § 5, 16°). Les tests qui
256    /// ont besoin de nommer l'ACP appellent `Budget::new` directement.
257    fn budget_quelconque(
258        fiscal_year: i32,
259        ordinaire: Decimal,
260        extraordinaire: Decimal,
261    ) -> Result<Budget, String> {
262        Budget::new(
263            Uuid::new_v4(),
264            Uuid::new_v4(),
265            Uuid::new_v4(),
266            fiscal_year,
267            ordinaire,
268            extraordinaire,
269        )
270    }
271
272    // ----- Story H11 (CL4) — montants Budget en Decimal exact (4-cat) --------
273
274    #[test]
275    fn happy_monthly_provision_is_exact_decimal() {
276        // 75000 / 12 = 6250 exact (Decimal, pas de dérive en virgule flottante).
277        let b = budget_quelconque(2025, dec!(50000), dec!(25000)).unwrap();
278        assert_eq!(b.total_budget, dec!(75000));
279        assert_eq!(b.monthly_provision_amount, dec!(6250));
280    }
281
282    #[test]
283    fn edge_no_floating_point_drift_on_sum() {
284        // En virgule flottante binaire, 0.10 + 0.20 != 0.30 (dérive IEEE 754).
285        // En Decimal c'est 0.30 exact.
286        let b = budget_quelconque(2025, dec!(0.10), dec!(0.20)).unwrap();
287        assert_eq!(b.total_budget, dec!(0.30));
288    }
289
290    #[test]
291    fn security_large_budget_no_overflow() {
292        // Decimal supporte ~7.9e28 : un budget géant ne panique pas.
293        let b = budget_quelconque(2025, dec!(900000000000), dec!(100000000000)).unwrap();
294        assert_eq!(b.total_budget, dec!(1000000000000));
295    }
296
297    #[test]
298    fn negative_budget_rejected_typed() {
299        let r = budget_quelconque(2025, dec!(-1), dec!(0));
300        assert!(r.is_err());
301        assert!(r.unwrap_err().contains("negative"));
302    }
303
304    #[test]
305    fn test_create_budget_success() {
306        let acp_id = Uuid::new_v4();
307        let org_id = Uuid::new_v4();
308        let building_id = Uuid::new_v4();
309
310        let budget = Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000));
311
312        assert!(budget.is_ok());
313        let b = budget.unwrap();
314        assert_eq!(b.fiscal_year, 2025);
315        assert_eq!(b.ordinary_budget, dec!(50000));
316        assert_eq!(b.extraordinary_budget, dec!(25000));
317        assert_eq!(b.total_budget, dec!(75000));
318        assert_eq!(b.monthly_provision_amount, dec!(6250)); // 75000 / 12
319        assert_eq!(b.status, BudgetStatus::Draft);
320    }
321
322    #[test]
323    fn test_create_budget_invalid_year() {
324        let acp_id = Uuid::new_v4();
325        let org_id = Uuid::new_v4();
326        let building_id = Uuid::new_v4();
327
328        let result = Budget::new(acp_id, org_id, building_id, 1999, dec!(50000), dec!(25000));
329
330        assert!(result.is_err());
331        assert!(result.unwrap_err().contains("between 2000 and 2100"));
332    }
333
334    #[test]
335    fn test_create_budget_negative_amounts() {
336        let acp_id = Uuid::new_v4();
337        let org_id = Uuid::new_v4();
338        let building_id = Uuid::new_v4();
339
340        let result1 = Budget::new(acp_id, org_id, building_id, 2025, dec!(-1000), dec!(25000));
341        assert!(result1.is_err());
342
343        let result2 = Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(-1000));
344        assert!(result2.is_err());
345    }
346
347    #[test]
348    fn test_create_budget_zero_total() {
349        let acp_id = Uuid::new_v4();
350        let org_id = Uuid::new_v4();
351        let building_id = Uuid::new_v4();
352
353        let result = Budget::new(acp_id, org_id, building_id, 2025, dec!(0), dec!(0));
354
355        assert!(result.is_err());
356        assert_eq!(result.unwrap_err(), "Total budget cannot be zero");
357    }
358
359    #[test]
360    fn test_submit_for_approval() {
361        let acp_id = Uuid::new_v4();
362        let org_id = Uuid::new_v4();
363        let building_id = Uuid::new_v4();
364
365        let mut budget =
366            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
367
368        assert!(budget.submit_for_approval().is_ok());
369        assert_eq!(budget.status, BudgetStatus::Submitted);
370        assert!(budget.submitted_date.is_some());
371    }
372
373    #[test]
374    fn test_approve_budget() {
375        let acp_id = Uuid::new_v4();
376        let org_id = Uuid::new_v4();
377        let building_id = Uuid::new_v4();
378        let meeting_id = Uuid::new_v4();
379
380        let mut budget =
381            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
382        budget.submit_for_approval().unwrap();
383
384        assert!(budget.approve(meeting_id).is_ok());
385        assert_eq!(budget.status, BudgetStatus::Approved);
386        assert!(budget.approved_date.is_some());
387        assert_eq!(budget.approved_by_meeting_id, Some(meeting_id));
388        assert!(budget.is_active());
389    }
390
391    #[test]
392    fn test_reject_budget() {
393        let acp_id = Uuid::new_v4();
394        let org_id = Uuid::new_v4();
395        let building_id = Uuid::new_v4();
396
397        let mut budget =
398            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
399        budget.submit_for_approval().unwrap();
400
401        assert!(budget.reject().is_ok());
402        assert_eq!(budget.status, BudgetStatus::Rejected);
403    }
404
405    #[test]
406    fn test_archive_budget() {
407        let acp_id = Uuid::new_v4();
408        let org_id = Uuid::new_v4();
409        let building_id = Uuid::new_v4();
410        let meeting_id = Uuid::new_v4();
411
412        let mut budget =
413            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
414        budget.submit_for_approval().unwrap();
415        budget.approve(meeting_id).unwrap();
416
417        assert!(budget.archive().is_ok());
418        assert_eq!(budget.status, BudgetStatus::Archived);
419        assert!(!budget.is_active());
420    }
421
422    #[test]
423    fn test_update_amounts_draft() {
424        let acp_id = Uuid::new_v4();
425        let org_id = Uuid::new_v4();
426        let building_id = Uuid::new_v4();
427
428        let mut budget =
429            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
430
431        assert!(budget.update_amounts(dec!(60000), dec!(30000)).is_ok());
432        assert_eq!(budget.ordinary_budget, dec!(60000));
433        assert_eq!(budget.extraordinary_budget, dec!(30000));
434        assert_eq!(budget.total_budget, dec!(90000));
435        assert_eq!(budget.monthly_provision_amount, dec!(7500));
436    }
437
438    #[test]
439    fn test_update_amounts_submitted_fails() {
440        let acp_id = Uuid::new_v4();
441        let org_id = Uuid::new_v4();
442        let building_id = Uuid::new_v4();
443
444        let mut budget =
445            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
446        budget.submit_for_approval().unwrap();
447
448        let result = budget.update_amounts(dec!(60000), dec!(30000));
449        assert!(result.is_err());
450        assert!(result
451            .unwrap_err()
452            .contains("only update amounts in Draft or Rejected"));
453    }
454
455    #[test]
456    fn test_workflow_draft_to_approved() {
457        let acp_id = Uuid::new_v4();
458        let org_id = Uuid::new_v4();
459        let building_id = Uuid::new_v4();
460        let meeting_id = Uuid::new_v4();
461
462        let mut budget =
463            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
464
465        // Draft → Submitted
466        assert_eq!(budget.status, BudgetStatus::Draft);
467        budget.submit_for_approval().unwrap();
468        assert_eq!(budget.status, BudgetStatus::Submitted);
469
470        // Submitted → Approved
471        budget.approve(meeting_id).unwrap();
472        assert_eq!(budget.status, BudgetStatus::Approved);
473        assert!(budget.is_active());
474        assert!(!budget.is_editable());
475
476        // Approved → Archived
477        budget.archive().unwrap();
478        assert_eq!(budget.status, BudgetStatus::Archived);
479        assert!(!budget.is_active());
480    }
481
482    #[test]
483    fn test_workflow_draft_to_rejected_to_resubmit() {
484        let acp_id = Uuid::new_v4();
485        let org_id = Uuid::new_v4();
486        let building_id = Uuid::new_v4();
487
488        let mut budget =
489            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
490
491        // Draft → Submitted → Rejected
492        budget.submit_for_approval().unwrap();
493        budget.reject().unwrap();
494        assert_eq!(budget.status, BudgetStatus::Rejected);
495        assert!(budget.is_editable());
496
497        // Rejected → can be resubmitted
498        assert!(budget.submit_for_approval().is_ok());
499        assert_eq!(budget.status, BudgetStatus::Submitted);
500    }
501
502    #[test]
503    fn test_update_notes() {
504        let acp_id = Uuid::new_v4();
505        let org_id = Uuid::new_v4();
506        let building_id = Uuid::new_v4();
507
508        let mut budget =
509            Budget::new(acp_id, org_id, building_id, 2025, dec!(50000), dec!(25000)).unwrap();
510
511        budget.update_notes("Budget prévisionnel incluant réfection toiture".to_string());
512        assert_eq!(
513            budget.notes,
514            Some("Budget prévisionnel incluant réfection toiture".to_string())
515        );
516    }
517}