koprogo_api/domain/entities/
budget.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Statut du budget annuel
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)]
7#[sqlx(type_name = "budget_status", rename_all = "snake_case")]
8pub enum BudgetStatus {
9    Draft,     // Brouillon (en préparation)
10    Submitted, // Soumis pour vote en AG
11    Approved,  // Approuvé par l'AG (actif)
12    Rejected,  // Rejeté par l'AG
13    Archived,  // Archivé (exercice terminé)
14}
15
16/// Représente un budget annuel de copropriété (ordinaire + extraordinaire)
17///
18/// Obligation légale belge: Le budget doit être voté en AG avant le début
19/// de l'exercice fiscal. Il détermine les provisions mensuelles à appeler
20/// auprès des copropriétaires.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct Budget {
23    pub id: Uuid,
24    pub organization_id: Uuid,
25    pub building_id: Uuid,
26
27    /// Année fiscale (ex: 2025)
28    pub fiscal_year: i32,
29
30    /// Budget charges ordinaires (€) - Charges courantes récurrentes
31    pub ordinary_budget: f64,
32
33    /// Budget charges extraordinaires (€) - Travaux et dépenses exceptionnelles
34    pub extraordinary_budget: f64,
35
36    /// Budget total (€) = ordinaire + extraordinaire
37    pub total_budget: f64,
38
39    /// Statut du budget
40    pub status: BudgetStatus,
41
42    /// Date de soumission pour vote AG
43    pub submitted_date: Option<DateTime<Utc>>,
44
45    /// Date d'approbation par l'AG
46    pub approved_date: Option<DateTime<Utc>>,
47
48    /// ID de l'AG qui a approuvé le budget
49    pub approved_by_meeting_id: Option<Uuid>,
50
51    /// Montant mensuel des provisions à appeler (€)
52    /// = total_budget / 12 mois
53    pub monthly_provision_amount: f64,
54
55    /// Notes / Commentaires
56    pub notes: Option<String>,
57
58    pub created_at: DateTime<Utc>,
59    pub updated_at: DateTime<Utc>,
60}
61
62impl Budget {
63    pub fn new(
64        organization_id: Uuid,
65        building_id: Uuid,
66        fiscal_year: i32,
67        ordinary_budget: f64,
68        extraordinary_budget: f64,
69    ) -> Result<Self, String> {
70        // Validations
71        if fiscal_year < 2000 || fiscal_year > 2100 {
72            return Err("Fiscal year must be between 2000 and 2100".to_string());
73        }
74
75        if ordinary_budget < 0.0 {
76            return Err("Ordinary budget cannot be negative".to_string());
77        }
78
79        if extraordinary_budget < 0.0 {
80            return Err("Extraordinary budget cannot be negative".to_string());
81        }
82
83        let total_budget = ordinary_budget + extraordinary_budget;
84
85        if total_budget == 0.0 {
86            return Err("Total budget cannot be zero".to_string());
87        }
88
89        // Calcul provisions mensuelles
90        let monthly_provision_amount = total_budget / 12.0;
91
92        let now = Utc::now();
93        Ok(Self {
94            id: Uuid::new_v4(),
95            organization_id,
96            building_id,
97            fiscal_year,
98            ordinary_budget,
99            extraordinary_budget,
100            total_budget,
101            status: BudgetStatus::Draft,
102            submitted_date: None,
103            approved_date: None,
104            approved_by_meeting_id: None,
105            monthly_provision_amount,
106            notes: None,
107            created_at: now,
108            updated_at: now,
109        })
110    }
111
112    /// Soumet le budget pour vote en AG
113    pub fn submit_for_approval(&mut self) -> Result<(), String> {
114        match self.status {
115            BudgetStatus::Draft | BudgetStatus::Rejected => {
116                self.status = BudgetStatus::Submitted;
117                self.submitted_date = Some(Utc::now());
118                self.updated_at = Utc::now();
119                Ok(())
120            }
121            _ => Err(format!(
122                "Cannot submit budget with status {:?}",
123                self.status
124            )),
125        }
126    }
127
128    /// Approuve le budget (vote AG positif)
129    pub fn approve(&mut self, meeting_id: Uuid) -> Result<(), String> {
130        match self.status {
131            BudgetStatus::Submitted => {
132                self.status = BudgetStatus::Approved;
133                self.approved_date = Some(Utc::now());
134                self.approved_by_meeting_id = Some(meeting_id);
135                self.updated_at = Utc::now();
136                Ok(())
137            }
138            _ => Err(format!(
139                "Cannot approve budget with status {:?}",
140                self.status
141            )),
142        }
143    }
144
145    /// Rejette le budget (vote AG négatif)
146    pub fn reject(&mut self) -> Result<(), String> {
147        match self.status {
148            BudgetStatus::Submitted => {
149                self.status = BudgetStatus::Rejected;
150                self.updated_at = Utc::now();
151                Ok(())
152            }
153            _ => Err(format!(
154                "Cannot reject budget with status {:?}",
155                self.status
156            )),
157        }
158    }
159
160    /// Archive le budget (fin d'exercice)
161    pub fn archive(&mut self) -> Result<(), String> {
162        match self.status {
163            BudgetStatus::Approved => {
164                self.status = BudgetStatus::Archived;
165                self.updated_at = Utc::now();
166                Ok(())
167            }
168            _ => Err(format!(
169                "Cannot archive budget with status {:?}",
170                self.status
171            )),
172        }
173    }
174
175    /// Met à jour les montants du budget (uniquement en Draft)
176    pub fn update_amounts(
177        &mut self,
178        ordinary_budget: f64,
179        extraordinary_budget: f64,
180    ) -> Result<(), String> {
181        if self.status != BudgetStatus::Draft {
182            return Err("Can only update amounts in Draft status".to_string());
183        }
184
185        if ordinary_budget < 0.0 {
186            return Err("Ordinary budget cannot be negative".to_string());
187        }
188
189        if extraordinary_budget < 0.0 {
190            return Err("Extraordinary budget cannot be negative".to_string());
191        }
192
193        let total_budget = ordinary_budget + extraordinary_budget;
194
195        if total_budget == 0.0 {
196            return Err("Total budget cannot be zero".to_string());
197        }
198
199        self.ordinary_budget = ordinary_budget;
200        self.extraordinary_budget = extraordinary_budget;
201        self.total_budget = total_budget;
202        self.monthly_provision_amount = total_budget / 12.0;
203        self.updated_at = Utc::now();
204
205        Ok(())
206    }
207
208    /// Ajoute/met à jour les notes
209    pub fn update_notes(&mut self, notes: String) {
210        self.notes = Some(notes);
211        self.updated_at = Utc::now();
212    }
213
214    /// Vérifie si le budget est actif (approuvé et pas encore archivé)
215    pub fn is_active(&self) -> bool {
216        self.status == BudgetStatus::Approved
217    }
218
219    /// Vérifie si le budget peut être modifié
220    pub fn is_editable(&self) -> bool {
221        matches!(self.status, BudgetStatus::Draft | BudgetStatus::Rejected)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_create_budget_success() {
231        let org_id = Uuid::new_v4();
232        let building_id = Uuid::new_v4();
233
234        let budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0);
235
236        assert!(budget.is_ok());
237        let b = budget.unwrap();
238        assert_eq!(b.fiscal_year, 2025);
239        assert_eq!(b.ordinary_budget, 50000.0);
240        assert_eq!(b.extraordinary_budget, 25000.0);
241        assert_eq!(b.total_budget, 75000.0);
242        assert_eq!(b.monthly_provision_amount, 6250.0); // 75000 / 12
243        assert_eq!(b.status, BudgetStatus::Draft);
244    }
245
246    #[test]
247    fn test_create_budget_invalid_year() {
248        let org_id = Uuid::new_v4();
249        let building_id = Uuid::new_v4();
250
251        let result = Budget::new(org_id, building_id, 1999, 50000.0, 25000.0);
252
253        assert!(result.is_err());
254        assert!(result.unwrap_err().contains("between 2000 and 2100"));
255    }
256
257    #[test]
258    fn test_create_budget_negative_amounts() {
259        let org_id = Uuid::new_v4();
260        let building_id = Uuid::new_v4();
261
262        let result1 = Budget::new(org_id, building_id, 2025, -1000.0, 25000.0);
263        assert!(result1.is_err());
264
265        let result2 = Budget::new(org_id, building_id, 2025, 50000.0, -1000.0);
266        assert!(result2.is_err());
267    }
268
269    #[test]
270    fn test_create_budget_zero_total() {
271        let org_id = Uuid::new_v4();
272        let building_id = Uuid::new_v4();
273
274        let result = Budget::new(org_id, building_id, 2025, 0.0, 0.0);
275
276        assert!(result.is_err());
277        assert_eq!(result.unwrap_err(), "Total budget cannot be zero");
278    }
279
280    #[test]
281    fn test_submit_for_approval() {
282        let org_id = Uuid::new_v4();
283        let building_id = Uuid::new_v4();
284
285        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
286
287        assert!(budget.submit_for_approval().is_ok());
288        assert_eq!(budget.status, BudgetStatus::Submitted);
289        assert!(budget.submitted_date.is_some());
290    }
291
292    #[test]
293    fn test_approve_budget() {
294        let org_id = Uuid::new_v4();
295        let building_id = Uuid::new_v4();
296        let meeting_id = Uuid::new_v4();
297
298        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
299        budget.submit_for_approval().unwrap();
300
301        assert!(budget.approve(meeting_id).is_ok());
302        assert_eq!(budget.status, BudgetStatus::Approved);
303        assert!(budget.approved_date.is_some());
304        assert_eq!(budget.approved_by_meeting_id, Some(meeting_id));
305        assert!(budget.is_active());
306    }
307
308    #[test]
309    fn test_reject_budget() {
310        let org_id = Uuid::new_v4();
311        let building_id = Uuid::new_v4();
312
313        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
314        budget.submit_for_approval().unwrap();
315
316        assert!(budget.reject().is_ok());
317        assert_eq!(budget.status, BudgetStatus::Rejected);
318    }
319
320    #[test]
321    fn test_archive_budget() {
322        let org_id = Uuid::new_v4();
323        let building_id = Uuid::new_v4();
324        let meeting_id = Uuid::new_v4();
325
326        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
327        budget.submit_for_approval().unwrap();
328        budget.approve(meeting_id).unwrap();
329
330        assert!(budget.archive().is_ok());
331        assert_eq!(budget.status, BudgetStatus::Archived);
332        assert!(!budget.is_active());
333    }
334
335    #[test]
336    fn test_update_amounts_draft() {
337        let org_id = Uuid::new_v4();
338        let building_id = Uuid::new_v4();
339
340        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
341
342        assert!(budget.update_amounts(60000.0, 30000.0).is_ok());
343        assert_eq!(budget.ordinary_budget, 60000.0);
344        assert_eq!(budget.extraordinary_budget, 30000.0);
345        assert_eq!(budget.total_budget, 90000.0);
346        assert_eq!(budget.monthly_provision_amount, 7500.0);
347    }
348
349    #[test]
350    fn test_update_amounts_submitted_fails() {
351        let org_id = Uuid::new_v4();
352        let building_id = Uuid::new_v4();
353
354        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
355        budget.submit_for_approval().unwrap();
356
357        let result = budget.update_amounts(60000.0, 30000.0);
358        assert!(result.is_err());
359        assert!(result.unwrap_err().contains("only update amounts in Draft"));
360    }
361
362    #[test]
363    fn test_workflow_draft_to_approved() {
364        let org_id = Uuid::new_v4();
365        let building_id = Uuid::new_v4();
366        let meeting_id = Uuid::new_v4();
367
368        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
369
370        // Draft → Submitted
371        assert_eq!(budget.status, BudgetStatus::Draft);
372        budget.submit_for_approval().unwrap();
373        assert_eq!(budget.status, BudgetStatus::Submitted);
374
375        // Submitted → Approved
376        budget.approve(meeting_id).unwrap();
377        assert_eq!(budget.status, BudgetStatus::Approved);
378        assert!(budget.is_active());
379        assert!(!budget.is_editable());
380
381        // Approved → Archived
382        budget.archive().unwrap();
383        assert_eq!(budget.status, BudgetStatus::Archived);
384        assert!(!budget.is_active());
385    }
386
387    #[test]
388    fn test_workflow_draft_to_rejected_to_resubmit() {
389        let org_id = Uuid::new_v4();
390        let building_id = Uuid::new_v4();
391
392        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
393
394        // Draft → Submitted → Rejected
395        budget.submit_for_approval().unwrap();
396        budget.reject().unwrap();
397        assert_eq!(budget.status, BudgetStatus::Rejected);
398        assert!(budget.is_editable());
399
400        // Rejected → can be resubmitted
401        assert!(budget.submit_for_approval().is_ok());
402        assert_eq!(budget.status, BudgetStatus::Submitted);
403    }
404
405    #[test]
406    fn test_update_notes() {
407        let org_id = Uuid::new_v4();
408        let building_id = Uuid::new_v4();
409
410        let mut budget = Budget::new(org_id, building_id, 2025, 50000.0, 25000.0).unwrap();
411
412        budget.update_notes("Budget prévisionnel incluant réfection toiture".to_string());
413        assert_eq!(
414            budget.notes,
415            Some("Budget prévisionnel incluant réfection toiture".to_string())
416        );
417    }
418}