koprogo_api/domain/entities/
budget.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)]
7#[sqlx(type_name = "budget_status", rename_all = "snake_case")]
8pub enum BudgetStatus {
9 Draft, Submitted, Approved, Rejected, Archived, }
15
16#[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 pub fiscal_year: i32,
29
30 pub ordinary_budget: f64,
32
33 pub extraordinary_budget: f64,
35
36 pub total_budget: f64,
38
39 pub status: BudgetStatus,
41
42 pub submitted_date: Option<DateTime<Utc>>,
44
45 pub approved_date: Option<DateTime<Utc>>,
47
48 pub approved_by_meeting_id: Option<Uuid>,
50
51 pub monthly_provision_amount: f64,
54
55 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 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 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 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 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 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 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 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 pub fn update_notes(&mut self, notes: String) {
210 self.notes = Some(notes);
211 self.updated_at = Utc::now();
212 }
213
214 pub fn is_active(&self) -> bool {
216 self.status == BudgetStatus::Approved
217 }
218
219 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); 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 assert_eq!(budget.status, BudgetStatus::Draft);
372 budget.submit_for_approval().unwrap();
373 assert_eq!(budget.status, BudgetStatus::Submitted);
374
375 budget.approve(meeting_id).unwrap();
377 assert_eq!(budget.status, BudgetStatus::Approved);
378 assert!(budget.is_active());
379 assert!(!budget.is_editable());
380
381 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 budget.submit_for_approval().unwrap();
396 budget.reject().unwrap();
397 assert_eq!(budget.status, BudgetStatus::Rejected);
398 assert!(budget.is_editable());
399
400 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}