1use crate::application::dto::{
2 BudgetResponse, CreateBudgetRequest, PageRequest, UpdateBudgetRequest,
3};
4use crate::application::ports::{
5 BudgetRepository, BudgetStatsResponse, BudgetVarianceResponse, BuildingRepository,
6 ExpenseRepository,
7};
8use crate::domain::entities::{Budget, BudgetStatus};
9use std::sync::Arc;
10use uuid::Uuid;
11
12pub struct BudgetUseCases {
13 repository: Arc<dyn BudgetRepository>,
14 building_repository: Arc<dyn BuildingRepository>,
15 #[allow(dead_code)]
16 expense_repository: Arc<dyn ExpenseRepository>,
17}
18
19impl BudgetUseCases {
20 pub fn new(
21 repository: Arc<dyn BudgetRepository>,
22 building_repository: Arc<dyn BuildingRepository>,
23 expense_repository: Arc<dyn ExpenseRepository>,
24 ) -> Self {
25 Self {
26 repository,
27 building_repository,
28 expense_repository,
29 }
30 }
31
32 pub async fn create_budget(
34 &self,
35 request: CreateBudgetRequest,
36 ) -> Result<BudgetResponse, String> {
37 let _building = self
39 .building_repository
40 .find_by_id(request.building_id)
41 .await?
42 .ok_or_else(|| "Building not found".to_string())?;
43
44 if let Some(_existing) = self
46 .repository
47 .find_by_building_and_fiscal_year(request.building_id, request.fiscal_year)
48 .await?
49 {
50 return Err(format!(
51 "Budget already exists for building {} and fiscal year {}",
52 request.building_id, request.fiscal_year
53 ));
54 }
55
56 let mut budget = Budget::new(
58 request.organization_id,
59 request.building_id,
60 request.fiscal_year,
61 request.ordinary_budget,
62 request.extraordinary_budget,
63 )?;
64
65 if let Some(notes) = request.notes {
67 budget.update_notes(notes);
68 }
69
70 let created = self.repository.create(&budget).await?;
71 Ok(BudgetResponse::from(created))
72 }
73
74 pub async fn get_budget(&self, id: Uuid) -> Result<Option<BudgetResponse>, String> {
76 let budget = self.repository.find_by_id(id).await?;
77 Ok(budget.map(BudgetResponse::from))
78 }
79
80 pub async fn get_by_building_and_fiscal_year(
82 &self,
83 building_id: Uuid,
84 fiscal_year: i32,
85 ) -> Result<Option<BudgetResponse>, String> {
86 let budget = self
87 .repository
88 .find_by_building_and_fiscal_year(building_id, fiscal_year)
89 .await?;
90 Ok(budget.map(BudgetResponse::from))
91 }
92
93 pub async fn get_active_budget(
95 &self,
96 building_id: Uuid,
97 ) -> Result<Option<BudgetResponse>, String> {
98 let budget = self.repository.find_active_by_building(building_id).await?;
99 Ok(budget.map(BudgetResponse::from))
100 }
101
102 pub async fn list_by_building(&self, building_id: Uuid) -> Result<Vec<BudgetResponse>, String> {
104 let budgets = self.repository.find_by_building(building_id).await?;
105 Ok(budgets.into_iter().map(BudgetResponse::from).collect())
106 }
107
108 pub async fn list_by_fiscal_year(
110 &self,
111 organization_id: Uuid,
112 fiscal_year: i32,
113 ) -> Result<Vec<BudgetResponse>, String> {
114 let budgets = self
115 .repository
116 .find_by_fiscal_year(organization_id, fiscal_year)
117 .await?;
118 Ok(budgets.into_iter().map(BudgetResponse::from).collect())
119 }
120
121 pub async fn list_by_status(
123 &self,
124 organization_id: Uuid,
125 status: BudgetStatus,
126 ) -> Result<Vec<BudgetResponse>, String> {
127 let budgets = self
128 .repository
129 .find_by_status(organization_id, status)
130 .await?;
131 Ok(budgets.into_iter().map(BudgetResponse::from).collect())
132 }
133
134 pub async fn list_paginated(
136 &self,
137 page_request: &PageRequest,
138 organization_id: Option<Uuid>,
139 building_id: Option<Uuid>,
140 status: Option<BudgetStatus>,
141 ) -> Result<(Vec<BudgetResponse>, i64), String> {
142 let (budgets, total) = self
143 .repository
144 .find_all_paginated(page_request, organization_id, building_id, status)
145 .await?;
146
147 let dtos = budgets.into_iter().map(BudgetResponse::from).collect();
148 Ok((dtos, total))
149 }
150
151 pub async fn update_budget(
153 &self,
154 id: Uuid,
155 request: UpdateBudgetRequest,
156 ) -> Result<BudgetResponse, String> {
157 let mut budget = self
158 .repository
159 .find_by_id(id)
160 .await?
161 .ok_or_else(|| "Budget not found".to_string())?;
162
163 if request.ordinary_budget.is_some() || request.extraordinary_budget.is_some() {
165 let ordinary = request.ordinary_budget.unwrap_or(budget.ordinary_budget);
166 let extraordinary = request
167 .extraordinary_budget
168 .unwrap_or(budget.extraordinary_budget);
169 budget.update_amounts(ordinary, extraordinary)?;
170 }
171
172 if let Some(notes) = request.notes {
173 budget.update_notes(notes);
174 }
175
176 let updated = self.repository.update(&budget).await?;
177 Ok(BudgetResponse::from(updated))
178 }
179
180 pub async fn submit_for_approval(&self, id: Uuid) -> Result<BudgetResponse, String> {
182 let mut budget = self
183 .repository
184 .find_by_id(id)
185 .await?
186 .ok_or_else(|| "Budget not found".to_string())?;
187
188 budget.submit_for_approval()?;
189
190 let updated = self.repository.update(&budget).await?;
191 Ok(BudgetResponse::from(updated))
192 }
193
194 pub async fn approve_budget(
196 &self,
197 id: Uuid,
198 meeting_id: Uuid,
199 ) -> Result<BudgetResponse, String> {
200 let mut budget = self
201 .repository
202 .find_by_id(id)
203 .await?
204 .ok_or_else(|| "Budget not found".to_string())?;
205
206 budget.approve(meeting_id)?;
207
208 let updated = self.repository.update(&budget).await?;
209 Ok(BudgetResponse::from(updated))
210 }
211
212 pub async fn reject_budget(
214 &self,
215 id: Uuid,
216 reason: Option<String>,
217 ) -> Result<BudgetResponse, String> {
218 let mut budget = self
219 .repository
220 .find_by_id(id)
221 .await?
222 .ok_or_else(|| "Budget not found".to_string())?;
223
224 if let Some(reason) = reason {
226 let current_notes = budget.notes.clone().unwrap_or_default();
227 let new_notes = if current_notes.is_empty() {
228 format!("REJECTED: {}", reason)
229 } else {
230 format!("{}\n\nREJECTED: {}", current_notes, reason)
231 };
232 budget.update_notes(new_notes);
233 }
234
235 budget.reject()?;
236
237 let updated = self.repository.update(&budget).await?;
238 Ok(BudgetResponse::from(updated))
239 }
240
241 pub async fn archive_budget(&self, id: Uuid) -> Result<BudgetResponse, String> {
243 let mut budget = self
244 .repository
245 .find_by_id(id)
246 .await?
247 .ok_or_else(|| "Budget not found".to_string())?;
248
249 budget.archive()?;
250
251 let updated = self.repository.update(&budget).await?;
252 Ok(BudgetResponse::from(updated))
253 }
254
255 pub async fn delete_budget(&self, id: Uuid) -> Result<bool, String> {
257 self.repository.delete(id).await
258 }
259
260 pub async fn get_stats(&self, organization_id: Uuid) -> Result<BudgetStatsResponse, String> {
262 self.repository.get_stats(organization_id).await
263 }
264
265 pub async fn get_variance(
267 &self,
268 budget_id: Uuid,
269 ) -> Result<Option<BudgetVarianceResponse>, String> {
270 self.repository.get_variance(budget_id).await
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::application::dto::PageRequest;
278 use crate::application::ports::{
279 BudgetRepository, BudgetStatsResponse, BudgetVarianceResponse, BuildingRepository,
280 ExpenseRepository,
281 };
282 use crate::domain::entities::{Building, Expense};
283 use mockall::mock;
284 use mockall::predicate::*;
285
286 mock! {
288 pub BudgetRepo {}
289
290 #[async_trait::async_trait]
291 impl BudgetRepository for BudgetRepo {
292 async fn create(&self, budget: &Budget) -> Result<Budget, String>;
293 async fn find_by_id(&self, id: Uuid) -> Result<Option<Budget>, String>;
294 async fn find_by_building_and_fiscal_year(
295 &self,
296 building_id: Uuid,
297 fiscal_year: i32,
298 ) -> Result<Option<Budget>, String>;
299 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Budget>, String>;
300 async fn find_active_by_building(&self, building_id: Uuid) -> Result<Option<Budget>, String>;
301 async fn find_by_fiscal_year(
302 &self,
303 organization_id: Uuid,
304 fiscal_year: i32,
305 ) -> Result<Vec<Budget>, String>;
306 async fn find_by_status(
307 &self,
308 organization_id: Uuid,
309 status: BudgetStatus,
310 ) -> Result<Vec<Budget>, String>;
311 async fn find_all_paginated(
312 &self,
313 page_request: &PageRequest,
314 organization_id: Option<Uuid>,
315 building_id: Option<Uuid>,
316 status: Option<BudgetStatus>,
317 ) -> Result<(Vec<Budget>, i64), String>;
318 async fn update(&self, budget: &Budget) -> Result<Budget, String>;
319 async fn delete(&self, id: Uuid) -> Result<bool, String>;
320 async fn get_stats(&self, organization_id: Uuid) -> Result<BudgetStatsResponse, String>;
321 async fn get_variance(&self, budget_id: Uuid) -> Result<Option<BudgetVarianceResponse>, String>;
322 }
323 }
324
325 mock! {
327 pub BuildingRepo {}
328
329 #[async_trait::async_trait]
330 impl BuildingRepository for BuildingRepo {
331 async fn create(&self, building: &Building) -> Result<Building, String>;
332 async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
333 async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
334 async fn find_all(&self) -> Result<Vec<Building>, String>;
335 async fn find_all_paginated(
336 &self,
337 page_request: &crate::application::dto::PageRequest,
338 filters: &crate::application::dto::BuildingFilters,
339 ) -> Result<(Vec<Building>, i64), String>;
340 async fn update(&self, building: &Building) -> Result<Building, String>;
341 async fn delete(&self, id: Uuid) -> Result<bool, String>;
342 }
343 }
344
345 mock! {
347 pub ExpenseRepo {}
348
349 #[async_trait::async_trait]
350 impl ExpenseRepository for ExpenseRepo {
351 async fn create(&self, expense: &Expense) -> Result<Expense, String>;
352 async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String>;
353 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String>;
354 async fn find_all_paginated(
355 &self,
356 page_request: &crate::application::dto::PageRequest,
357 filters: &crate::application::dto::ExpenseFilters,
358 ) -> Result<(Vec<Expense>, i64), String>;
359 async fn update(&self, expense: &Expense) -> Result<Expense, String>;
360 async fn delete(&self, id: Uuid) -> Result<bool, String>;
361 }
362 }
363
364 fn make_building(org_id: Uuid) -> Building {
366 Building::new(
367 org_id,
368 "Résidence du Parc".to_string(),
369 "12 Rue de la Loi".to_string(),
370 "Brussels".to_string(),
371 "1000".to_string(),
372 "Belgium".to_string(),
373 20,
374 1000,
375 Some(2015),
376 )
377 .unwrap()
378 }
379
380 fn make_draft_budget(org_id: Uuid, building_id: Uuid) -> Budget {
382 Budget::new(org_id, building_id, 2025, 60000.0, 15000.0).unwrap()
383 }
384
385 fn make_use_cases(
387 budget_repo: MockBudgetRepo,
388 building_repo: MockBuildingRepo,
389 expense_repo: MockExpenseRepo,
390 ) -> BudgetUseCases {
391 BudgetUseCases::new(
392 Arc::new(budget_repo),
393 Arc::new(building_repo),
394 Arc::new(expense_repo),
395 )
396 }
397
398 #[tokio::test]
402 async fn test_create_budget_success() {
403 let org_id = Uuid::new_v4();
404 let building_id = Uuid::new_v4();
405
406 let mut budget_repo = MockBudgetRepo::new();
407 let mut building_repo = MockBuildingRepo::new();
408 let expense_repo = MockExpenseRepo::new();
409
410 let building = make_building(org_id);
412 building_repo
413 .expect_find_by_id()
414 .with(eq(building_id))
415 .times(1)
416 .returning(move |_| Ok(Some(building.clone())));
417
418 budget_repo
420 .expect_find_by_building_and_fiscal_year()
421 .with(eq(building_id), eq(2025))
422 .times(1)
423 .returning(|_, _| Ok(None));
424
425 budget_repo
427 .expect_create()
428 .times(1)
429 .returning(|b| Ok(b.clone()));
430
431 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
432
433 let request = CreateBudgetRequest {
434 organization_id: org_id,
435 building_id,
436 fiscal_year: 2025,
437 ordinary_budget: 60000.0,
438 extraordinary_budget: 15000.0,
439 notes: Some("Budget prévisionnel toiture".to_string()),
440 };
441
442 let result = uc.create_budget(request).await;
443 assert!(result.is_ok());
444 let resp = result.unwrap();
445 assert_eq!(resp.fiscal_year, 2025);
446 assert_eq!(resp.ordinary_budget, 60000.0);
447 assert_eq!(resp.extraordinary_budget, 15000.0);
448 assert_eq!(resp.total_budget, 75000.0);
449 assert_eq!(resp.status, BudgetStatus::Draft);
450 assert!(resp.is_editable);
451 assert!(!resp.is_active);
452 assert_eq!(resp.notes, Some("Budget prévisionnel toiture".to_string()));
453 }
454
455 #[tokio::test]
459 async fn test_create_budget_duplicate_fiscal_year() {
460 let org_id = Uuid::new_v4();
461 let building_id = Uuid::new_v4();
462
463 let mut budget_repo = MockBudgetRepo::new();
464 let mut building_repo = MockBuildingRepo::new();
465 let expense_repo = MockExpenseRepo::new();
466
467 let building = make_building(org_id);
468 building_repo
469 .expect_find_by_id()
470 .with(eq(building_id))
471 .times(1)
472 .returning(move |_| Ok(Some(building.clone())));
473
474 let existing = make_draft_budget(org_id, building_id);
476 budget_repo
477 .expect_find_by_building_and_fiscal_year()
478 .with(eq(building_id), eq(2025))
479 .times(1)
480 .returning(move |_, _| Ok(Some(existing.clone())));
481
482 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
483
484 let request = CreateBudgetRequest {
485 organization_id: org_id,
486 building_id,
487 fiscal_year: 2025,
488 ordinary_budget: 60000.0,
489 extraordinary_budget: 15000.0,
490 notes: None,
491 };
492
493 let result = uc.create_budget(request).await;
494 assert!(result.is_err());
495 assert!(result.unwrap_err().contains("Budget already exists"));
496 }
497
498 #[tokio::test]
502 async fn test_submit_for_approval_success() {
503 let org_id = Uuid::new_v4();
504 let building_id = Uuid::new_v4();
505 let budget_id = Uuid::new_v4();
506
507 let mut budget_repo = MockBudgetRepo::new();
508 let building_repo = MockBuildingRepo::new();
509 let expense_repo = MockExpenseRepo::new();
510
511 let mut draft = make_draft_budget(org_id, building_id);
512 draft.id = budget_id;
513
514 budget_repo
515 .expect_find_by_id()
516 .with(eq(budget_id))
517 .times(1)
518 .returning(move |_| Ok(Some(draft.clone())));
519
520 budget_repo
521 .expect_update()
522 .times(1)
523 .returning(|b| Ok(b.clone()));
524
525 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
526
527 let result = uc.submit_for_approval(budget_id).await;
528 assert!(result.is_ok());
529 let resp = result.unwrap();
530 assert_eq!(resp.status, BudgetStatus::Submitted);
531 assert!(resp.submitted_date.is_some());
532 }
533
534 #[tokio::test]
538 async fn test_approve_budget_success() {
539 let org_id = Uuid::new_v4();
540 let building_id = Uuid::new_v4();
541 let budget_id = Uuid::new_v4();
542 let meeting_id = Uuid::new_v4();
543
544 let mut budget_repo = MockBudgetRepo::new();
545 let building_repo = MockBuildingRepo::new();
546 let expense_repo = MockExpenseRepo::new();
547
548 let mut submitted = make_draft_budget(org_id, building_id);
550 submitted.id = budget_id;
551 submitted.submit_for_approval().unwrap();
552
553 budget_repo
554 .expect_find_by_id()
555 .with(eq(budget_id))
556 .times(1)
557 .returning(move |_| Ok(Some(submitted.clone())));
558
559 budget_repo
560 .expect_update()
561 .times(1)
562 .returning(|b| Ok(b.clone()));
563
564 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
565
566 let result = uc.approve_budget(budget_id, meeting_id).await;
567 assert!(result.is_ok());
568 let resp = result.unwrap();
569 assert_eq!(resp.status, BudgetStatus::Approved);
570 assert!(resp.approved_date.is_some());
571 assert_eq!(resp.approved_by_meeting_id, Some(meeting_id));
572 assert!(resp.is_active);
573 }
574
575 #[tokio::test]
579 async fn test_reject_budget_with_reason() {
580 let org_id = Uuid::new_v4();
581 let building_id = Uuid::new_v4();
582 let budget_id = Uuid::new_v4();
583
584 let mut budget_repo = MockBudgetRepo::new();
585 let building_repo = MockBuildingRepo::new();
586 let expense_repo = MockExpenseRepo::new();
587
588 let mut submitted = make_draft_budget(org_id, building_id);
589 submitted.id = budget_id;
590 submitted.submit_for_approval().unwrap();
591
592 budget_repo
593 .expect_find_by_id()
594 .with(eq(budget_id))
595 .times(1)
596 .returning(move |_| Ok(Some(submitted.clone())));
597
598 budget_repo
599 .expect_update()
600 .times(1)
601 .returning(|b| Ok(b.clone()));
602
603 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
604
605 let reason = Some("Montant extraordinaire trop élevé".to_string());
606 let result = uc.reject_budget(budget_id, reason).await;
607 assert!(result.is_ok());
608 let resp = result.unwrap();
609 assert_eq!(resp.status, BudgetStatus::Rejected);
610 assert!(resp.notes.is_some());
611 assert!(resp
612 .notes
613 .unwrap()
614 .contains("REJECTED: Montant extraordinaire trop élevé"));
615 }
616
617 #[tokio::test]
621 async fn test_archive_budget_success() {
622 let org_id = Uuid::new_v4();
623 let building_id = Uuid::new_v4();
624 let budget_id = Uuid::new_v4();
625 let meeting_id = Uuid::new_v4();
626
627 let mut budget_repo = MockBudgetRepo::new();
628 let building_repo = MockBuildingRepo::new();
629 let expense_repo = MockExpenseRepo::new();
630
631 let mut approved = make_draft_budget(org_id, building_id);
633 approved.id = budget_id;
634 approved.submit_for_approval().unwrap();
635 approved.approve(meeting_id).unwrap();
636
637 budget_repo
638 .expect_find_by_id()
639 .with(eq(budget_id))
640 .times(1)
641 .returning(move |_| Ok(Some(approved.clone())));
642
643 budget_repo
644 .expect_update()
645 .times(1)
646 .returning(|b| Ok(b.clone()));
647
648 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
649
650 let result = uc.archive_budget(budget_id).await;
651 assert!(result.is_ok());
652 let resp = result.unwrap();
653 assert_eq!(resp.status, BudgetStatus::Archived);
654 assert!(!resp.is_active);
655 assert!(!resp.is_editable);
656 }
657
658 #[tokio::test]
662 async fn test_get_variance_returns_analysis() {
663 let budget_id = Uuid::new_v4();
664 let building_id = Uuid::new_v4();
665
666 let mut budget_repo = MockBudgetRepo::new();
667 let building_repo = MockBuildingRepo::new();
668 let expense_repo = MockExpenseRepo::new();
669
670 let variance = BudgetVarianceResponse {
671 budget_id,
672 fiscal_year: 2025,
673 building_id,
674 budgeted_ordinary: 60000.0,
675 budgeted_extraordinary: 15000.0,
676 budgeted_total: 75000.0,
677 actual_ordinary: 45000.0,
678 actual_extraordinary: 20000.0,
679 actual_total: 65000.0,
680 variance_ordinary: 15000.0,
681 variance_extraordinary: -5000.0,
682 variance_total: 10000.0,
683 variance_ordinary_pct: 25.0,
684 variance_extraordinary_pct: -33.33,
685 variance_total_pct: 13.33,
686 has_overruns: true,
687 overrun_categories: vec!["Extraordinary".to_string()],
688 months_elapsed: 8,
689 projected_year_end_total: 97500.0,
690 };
691
692 let expected_variance = variance.clone();
693
694 budget_repo
695 .expect_get_variance()
696 .with(eq(budget_id))
697 .times(1)
698 .returning(move |_| Ok(Some(variance.clone())));
699
700 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
701
702 let result = uc.get_variance(budget_id).await;
703 assert!(result.is_ok());
704 let opt = result.unwrap();
705 assert!(opt.is_some());
706 let v = opt.unwrap();
707 assert_eq!(v.budget_id, expected_variance.budget_id);
708 assert_eq!(v.budgeted_total, 75000.0);
709 assert_eq!(v.actual_total, 65000.0);
710 assert_eq!(v.variance_total, 10000.0);
711 assert!(v.has_overruns);
712 assert_eq!(v.overrun_categories, vec!["Extraordinary".to_string()]);
713 }
714
715 #[tokio::test]
719 async fn test_get_active_budget_for_building() {
720 let org_id = Uuid::new_v4();
721 let building_id = Uuid::new_v4();
722 let meeting_id = Uuid::new_v4();
723
724 let mut budget_repo = MockBudgetRepo::new();
725 let building_repo = MockBuildingRepo::new();
726 let expense_repo = MockExpenseRepo::new();
727
728 let mut approved = make_draft_budget(org_id, building_id);
730 approved.submit_for_approval().unwrap();
731 approved.approve(meeting_id).unwrap();
732
733 let expected_id = approved.id;
734
735 budget_repo
736 .expect_find_active_by_building()
737 .with(eq(building_id))
738 .times(1)
739 .returning(move |_| Ok(Some(approved.clone())));
740
741 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
742
743 let result = uc.get_active_budget(building_id).await;
744 assert!(result.is_ok());
745 let opt = result.unwrap();
746 assert!(opt.is_some());
747 let resp = opt.unwrap();
748 assert_eq!(resp.id, expected_id);
749 assert_eq!(resp.status, BudgetStatus::Approved);
750 assert!(resp.is_active);
751 assert_eq!(resp.building_id, building_id);
752 }
753}