1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[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, Submitted, Approved, Rejected, Archived, }
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct Budget {
24 pub id: Uuid,
25
26 pub acp_id: Uuid,
33
34 pub organization_id: Uuid,
39 pub building_id: Uuid,
40
41 pub fiscal_year: i32,
43
44 pub ordinary_budget: Decimal,
46
47 pub extraordinary_budget: Decimal,
49
50 pub total_budget: Decimal,
52
53 pub status: BudgetStatus,
55
56 pub submitted_date: Option<DateTime<Utc>>,
58
59 pub approved_date: Option<DateTime<Utc>>,
61
62 pub approved_by_meeting_id: Option<Uuid>,
64
65 pub monthly_provision_amount: Decimal,
68
69 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 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 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 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 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 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 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 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 pub fn update_notes(&mut self, notes: String) {
226 self.notes = Some(notes);
227 self.updated_at = Utc::now();
228 }
229
230 pub fn is_active(&self) -> bool {
232 self.status == BudgetStatus::Approved
233 }
234
235 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 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 #[test]
275 fn happy_monthly_provision_is_exact_decimal() {
276 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 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 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)); 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 assert_eq!(budget.status, BudgetStatus::Draft);
467 budget.submit_for_approval().unwrap();
468 assert_eq!(budget.status, BudgetStatus::Submitted);
469
470 budget.approve(meeting_id).unwrap();
472 assert_eq!(budget.status, BudgetStatus::Approved);
473 assert!(budget.is_active());
474 assert!(!budget.is_editable());
475
476 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 budget.submit_for_approval().unwrap();
493 budget.reject().unwrap();
494 assert_eq!(budget.status, BudgetStatus::Rejected);
495 assert!(budget.is_editable());
496
497 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}