1use crate::application::dto::{
2 BudgetResponse, CreateBudgetRequest, PageRequest, UpdateBudgetRequest,
3};
4use crate::application::error::AppError;
5use crate::application::ports::{
6 BudgetRepository, BudgetStatsResponse, BudgetVarianceResponse, BuildingRepository,
7 ExpenseRepository,
8};
9use crate::domain::entities::{Budget, BudgetStatus};
10use std::sync::Arc;
11use uuid::Uuid;
12
13pub struct BudgetUseCases {
14 repository: Arc<dyn BudgetRepository>,
15 building_repository: Arc<dyn BuildingRepository>,
16 #[allow(dead_code)]
17 expense_repository: Arc<dyn ExpenseRepository>,
18}
19
20impl BudgetUseCases {
21 pub fn new(
22 repository: Arc<dyn BudgetRepository>,
23 building_repository: Arc<dyn BuildingRepository>,
24 expense_repository: Arc<dyn ExpenseRepository>,
25 ) -> Self {
26 Self {
27 repository,
28 building_repository,
29 expense_repository,
30 }
31 }
32
33 pub async fn create_budget(
35 &self,
36 request: CreateBudgetRequest,
37 ) -> Result<BudgetResponse, AppError> {
38 let building = self
41 .building_repository
42 .find_by_id(request.building_id)
43 .await
44 .map_err(AppError::from)?
45 .ok_or_else(|| AppError::NotFound("Building not found".to_string()))?;
46
47 if let Some(_existing) = self
49 .repository
50 .find_by_building_and_fiscal_year(request.building_id, request.fiscal_year)
51 .await?
52 {
53 return Err(AppError::Conflict(format!(
54 "Budget already exists for building {} and fiscal year {}",
55 request.building_id, request.fiscal_year
56 )));
57 }
58
59 let mut budget = Budget::new(
61 building.acp_id,
62 request.organization_id,
63 request.building_id,
64 request.fiscal_year,
65 request.ordinary_budget,
66 request.extraordinary_budget,
67 )?;
68
69 if let Some(notes) = request.notes {
71 budget.update_notes(notes);
72 }
73
74 let created = self.repository.create(&budget).await?;
75 Ok(BudgetResponse::from(created))
76 }
77
78 pub async fn get_budget(&self, id: Uuid) -> Result<Option<BudgetResponse>, AppError> {
80 let budget = self.repository.find_by_id(id).await?;
81 Ok(budget.map(BudgetResponse::from))
82 }
83
84 pub async fn get_by_building_and_fiscal_year(
86 &self,
87 building_id: Uuid,
88 fiscal_year: i32,
89 ) -> Result<Option<BudgetResponse>, AppError> {
90 let budget = self
91 .repository
92 .find_by_building_and_fiscal_year(building_id, fiscal_year)
93 .await?;
94 Ok(budget.map(BudgetResponse::from))
95 }
96
97 pub async fn get_active_budget(
99 &self,
100 building_id: Uuid,
101 ) -> Result<Option<BudgetResponse>, AppError> {
102 let budget = self.repository.find_active_by_building(building_id).await?;
103 Ok(budget.map(BudgetResponse::from))
104 }
105
106 pub async fn list_by_building(
108 &self,
109 building_id: Uuid,
110 ) -> Result<Vec<BudgetResponse>, AppError> {
111 let budgets = self.repository.find_by_building(building_id).await?;
112 Ok(budgets.into_iter().map(BudgetResponse::from).collect())
113 }
114
115 pub async fn list_by_fiscal_year(
117 &self,
118 organization_id: Uuid,
119 fiscal_year: i32,
120 ) -> Result<Vec<BudgetResponse>, AppError> {
121 let budgets = self
122 .repository
123 .find_by_fiscal_year(organization_id, fiscal_year)
124 .await?;
125 Ok(budgets.into_iter().map(BudgetResponse::from).collect())
126 }
127
128 pub async fn list_by_status(
130 &self,
131 organization_id: Uuid,
132 status: BudgetStatus,
133 ) -> Result<Vec<BudgetResponse>, AppError> {
134 let budgets = self
135 .repository
136 .find_by_status(organization_id, status)
137 .await?;
138 Ok(budgets.into_iter().map(BudgetResponse::from).collect())
139 }
140
141 pub async fn list_paginated(
143 &self,
144 page_request: &PageRequest,
145 organization_id: Option<Uuid>,
146 building_id: Option<Uuid>,
147 status: Option<BudgetStatus>,
148 ) -> Result<(Vec<BudgetResponse>, i64), AppError> {
149 let (budgets, total) = self
150 .repository
151 .find_all_paginated(page_request, organization_id, building_id, status)
152 .await?;
153
154 let dtos = budgets.into_iter().map(BudgetResponse::from).collect();
155 Ok((dtos, total))
156 }
157
158 pub async fn update_budget(
160 &self,
161 id: Uuid,
162 request: UpdateBudgetRequest,
163 ) -> Result<BudgetResponse, AppError> {
164 let mut budget = self
165 .repository
166 .find_by_id(id)
167 .await?
168 .ok_or_else(|| "Budget not found".to_string())?;
169
170 if request.ordinary_budget.is_some() || request.extraordinary_budget.is_some() {
172 let ordinary = request.ordinary_budget.unwrap_or(budget.ordinary_budget);
173 let extraordinary = request
174 .extraordinary_budget
175 .unwrap_or(budget.extraordinary_budget);
176 budget.update_amounts(ordinary, extraordinary)?;
177 }
178
179 if let Some(notes) = request.notes {
180 budget.update_notes(notes);
181 }
182
183 let updated = self.repository.update(&budget).await?;
184 Ok(BudgetResponse::from(updated))
185 }
186
187 pub async fn submit_for_approval(&self, id: Uuid) -> Result<BudgetResponse, AppError> {
189 let mut budget = self
190 .repository
191 .find_by_id(id)
192 .await?
193 .ok_or_else(|| "Budget not found".to_string())?;
194
195 budget.submit_for_approval()?;
196
197 let updated = self.repository.update(&budget).await?;
198 Ok(BudgetResponse::from(updated))
199 }
200
201 pub async fn approve_budget(
203 &self,
204 id: Uuid,
205 meeting_id: Uuid,
206 ) -> Result<BudgetResponse, AppError> {
207 let mut budget = self
208 .repository
209 .find_by_id(id)
210 .await?
211 .ok_or_else(|| "Budget not found".to_string())?;
212
213 budget.approve(meeting_id)?;
214
215 let updated = self.repository.update(&budget).await?;
216 Ok(BudgetResponse::from(updated))
217 }
218
219 pub async fn reject_budget(
221 &self,
222 id: Uuid,
223 reason: Option<String>,
224 ) -> Result<BudgetResponse, AppError> {
225 let mut budget = self
226 .repository
227 .find_by_id(id)
228 .await?
229 .ok_or_else(|| "Budget not found".to_string())?;
230
231 if let Some(reason) = reason {
233 let current_notes = budget.notes.clone().unwrap_or_default();
234 let new_notes = if current_notes.is_empty() {
235 format!("REJECTED: {}", reason)
236 } else {
237 format!("{}\n\nREJECTED: {}", current_notes, reason)
238 };
239 budget.update_notes(new_notes);
240 }
241
242 budget.reject()?;
243
244 let updated = self.repository.update(&budget).await?;
245 Ok(BudgetResponse::from(updated))
246 }
247
248 pub async fn archive_budget(&self, id: Uuid) -> Result<BudgetResponse, AppError> {
250 let mut budget = self
251 .repository
252 .find_by_id(id)
253 .await?
254 .ok_or_else(|| "Budget not found".to_string())?;
255
256 budget.archive()?;
257
258 let updated = self.repository.update(&budget).await?;
259 Ok(BudgetResponse::from(updated))
260 }
261
262 pub async fn delete_budget(&self, id: Uuid) -> Result<bool, AppError> {
264 self.repository.delete(id).await
265 }
266
267 pub async fn get_stats(&self, organization_id: Uuid) -> Result<BudgetStatsResponse, AppError> {
269 self.repository.get_stats(organization_id).await
270 }
271
272 pub async fn get_variance(
274 &self,
275 budget_id: Uuid,
276 ) -> Result<Option<BudgetVarianceResponse>, AppError> {
277 self.repository.get_variance(budget_id).await
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::application::dto::PageRequest;
285 use crate::application::ports::{
286 BudgetRepository, BudgetStatsResponse, BudgetVarianceResponse, BuildingRepository,
287 ExpenseRepository,
288 };
289 use crate::domain::entities::{Building, Expense};
290 use mockall::mock;
291 use mockall::predicate::*;
292 use rust_decimal_macros::dec;
293
294 mock! {
296 pub BudgetRepo {}
297
298 #[async_trait::async_trait]
299 impl BudgetRepository for BudgetRepo {
300 async fn create(&self, budget: &Budget) -> Result<Budget, AppError>;
301 async fn find_by_id(&self, id: Uuid) -> Result<Option<Budget>, AppError>;
302 async fn find_by_building_and_fiscal_year(
303 &self,
304 building_id: Uuid,
305 fiscal_year: i32,
306 ) -> Result<Option<Budget>, AppError>;
307 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Budget>, AppError>;
308 async fn find_active_by_building(&self, building_id: Uuid) -> Result<Option<Budget>, AppError>;
309 async fn find_by_fiscal_year(
310 &self,
311 organization_id: Uuid,
312 fiscal_year: i32,
313 ) -> Result<Vec<Budget>, AppError>;
314 async fn find_by_status(
315 &self,
316 organization_id: Uuid,
317 status: BudgetStatus,
318 ) -> Result<Vec<Budget>, AppError>;
319 async fn find_all_paginated(
320 &self,
321 page_request: &PageRequest,
322 organization_id: Option<Uuid>,
323 building_id: Option<Uuid>,
324 status: Option<BudgetStatus>,
325 ) -> Result<(Vec<Budget>, i64), AppError>;
326 async fn update(&self, budget: &Budget) -> Result<Budget, AppError>;
327 async fn delete(&self, id: Uuid) -> Result<bool, AppError>;
328 async fn get_stats(&self, organization_id: Uuid) -> Result<BudgetStatsResponse, AppError>;
329 async fn get_variance(&self, budget_id: Uuid) -> Result<Option<BudgetVarianceResponse>, AppError>;
330 }
331 }
332
333 mock! {
335 pub BuildingRepo {}
336
337 #[async_trait::async_trait]
338 impl BuildingRepository for BuildingRepo {
339 async fn create(&self, building: &Building) -> Result<Building, String>;
340 async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
341 async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
342 async fn find_all(&self) -> Result<Vec<Building>, String>;
343 async fn find_all_paginated(
344 &self,
345 page_request: &crate::application::dto::PageRequest,
346 filters: &crate::application::dto::BuildingFilters,
347 ) -> Result<(Vec<Building>, i64), String>;
348 async fn update(&self, building: &Building) -> Result<Building, String>;
349 async fn delete(&self, id: Uuid) -> Result<bool, String>;
350 async fn find_by_id_with_metrics(
351 &self,
352 id: Uuid,
353 ) -> Result<Option<(Building, crate::domain::entities::BuildingMetrics)>, String>;
354 }
355 }
356
357 mock! {
359 pub ExpenseRepo {}
360
361 #[async_trait::async_trait]
362 impl ExpenseRepository for ExpenseRepo {
363 async fn enregistrer_lignes_de_facture(
364 &self,
365 _expense_id: Uuid,
366 _lignes: &[crate::application::ports::expense_repository::LigneDeFacture],
367 ) -> Result<(), String> {
368 Ok(())
372 }
373
374 async fn create(&self, expense: &Expense) -> Result<Expense, String>;
375 async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String>;
376 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String>;
377 async fn find_all_paginated(
378 &self,
379 page_request: &crate::application::dto::PageRequest,
380 filters: &crate::application::dto::ExpenseFilters,
381 ) -> Result<(Vec<Expense>, i64), String>;
382 async fn update(&self, expense: &Expense) -> Result<Expense, String>;
383 async fn delete(&self, id: Uuid) -> Result<bool, String>;
384 }
385 }
386
387 fn make_building(acp_id: Uuid) -> Building {
394 Building::new(
395 acp_id,
396 "Résidence du Parc".to_string(),
397 "12 Rue de la Loi".to_string(),
398 "Brussels".to_string(),
399 "1000".to_string(),
400 "Belgium".to_string(),
401 20,
402 1000,
403 Some(2015),
404 )
405 .unwrap()
406 }
407
408 fn make_draft_budget(acp_id: Uuid, org_id: Uuid, building_id: Uuid) -> Budget {
413 Budget::new(acp_id, org_id, building_id, 2025, dec!(60000), dec!(15000)).unwrap()
414 }
415
416 fn make_use_cases(
418 budget_repo: MockBudgetRepo,
419 building_repo: MockBuildingRepo,
420 expense_repo: MockExpenseRepo,
421 ) -> BudgetUseCases {
422 BudgetUseCases::new(
423 Arc::new(budget_repo),
424 Arc::new(building_repo),
425 Arc::new(expense_repo),
426 )
427 }
428
429 #[tokio::test]
436 async fn test_le_budget_appartient_a_lacp_de_limmeuble_pas_au_syndic() {
437 let acp_de_limmeuble = Uuid::new_v4();
438 let cabinet_qui_encode = Uuid::new_v4();
439 let building_id = Uuid::new_v4();
440
441 let mut budget_repo = MockBudgetRepo::new();
442 let mut building_repo = MockBuildingRepo::new();
443 let expense_repo = MockExpenseRepo::new();
444
445 let building = make_building(acp_de_limmeuble);
446 building_repo
447 .expect_find_by_id()
448 .with(eq(building_id))
449 .times(1)
450 .returning(move |_| Ok(Some(building.clone())));
451 budget_repo
452 .expect_find_by_building_and_fiscal_year()
453 .times(1)
454 .returning(|_, _| Ok(None));
455 budget_repo
456 .expect_create()
457 .times(1)
458 .returning(|b| Ok(b.clone()));
459
460 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
461
462 let budget = uc
463 .create_budget(CreateBudgetRequest {
464 organization_id: cabinet_qui_encode,
465 building_id,
466 fiscal_year: 2026,
467 ordinary_budget: dec!(48000),
468 extraordinary_budget: dec!(12000),
469 notes: None,
470 })
471 .await
472 .expect("création valide");
473
474 assert_eq!(
475 budget.acp_id, acp_de_limmeuble,
476 "le budget doit être rattaché à l'ACP de l'immeuble"
477 );
478 assert_eq!(
479 budget.organization_id, cabinet_qui_encode,
480 "le syndic reste tracé comme auteur, sans que ça lui donne un droit"
481 );
482 assert_ne!(
483 budget.acp_id, budget.organization_id,
484 "l'ACP et le syndic sont deux entités distinctes, pas deux noms de la même"
485 );
486 }
487
488 #[tokio::test]
492 async fn test_create_budget_success() {
493 let acp_id = Uuid::new_v4();
494 let org_id = Uuid::new_v4();
495 let building_id = Uuid::new_v4();
496
497 let mut budget_repo = MockBudgetRepo::new();
498 let mut building_repo = MockBuildingRepo::new();
499 let expense_repo = MockExpenseRepo::new();
500
501 let building = make_building(acp_id);
503 building_repo
504 .expect_find_by_id()
505 .with(eq(building_id))
506 .times(1)
507 .returning(move |_| Ok(Some(building.clone())));
508
509 budget_repo
511 .expect_find_by_building_and_fiscal_year()
512 .with(eq(building_id), eq(2025))
513 .times(1)
514 .returning(|_, _| Ok(None));
515
516 budget_repo
518 .expect_create()
519 .times(1)
520 .returning(|b| Ok(b.clone()));
521
522 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
523
524 let request = CreateBudgetRequest {
525 organization_id: org_id,
526 building_id,
527 fiscal_year: 2025,
528 ordinary_budget: dec!(60000),
529 extraordinary_budget: dec!(15000),
530 notes: Some("Budget prévisionnel toiture".to_string()),
531 };
532
533 let result = uc.create_budget(request).await;
534 assert!(result.is_ok());
535 let resp = result.unwrap();
536 assert_eq!(resp.fiscal_year, 2025);
537 assert_eq!(resp.ordinary_budget, dec!(60000));
538 assert_eq!(resp.extraordinary_budget, dec!(15000));
539 assert_eq!(resp.total_budget, dec!(75000));
540 assert_eq!(resp.status, BudgetStatus::Draft);
541 assert!(resp.is_editable);
542 assert!(!resp.is_active);
543 assert_eq!(resp.notes, Some("Budget prévisionnel toiture".to_string()));
544 }
545
546 #[tokio::test]
550 async fn test_create_budget_duplicate_fiscal_year() {
551 let acp_id = Uuid::new_v4();
552 let org_id = Uuid::new_v4();
553 let building_id = Uuid::new_v4();
554
555 let mut budget_repo = MockBudgetRepo::new();
556 let mut building_repo = MockBuildingRepo::new();
557 let expense_repo = MockExpenseRepo::new();
558
559 let building = make_building(acp_id);
560 building_repo
561 .expect_find_by_id()
562 .with(eq(building_id))
563 .times(1)
564 .returning(move |_| Ok(Some(building.clone())));
565
566 let existing = make_draft_budget(acp_id, org_id, building_id);
568 budget_repo
569 .expect_find_by_building_and_fiscal_year()
570 .with(eq(building_id), eq(2025))
571 .times(1)
572 .returning(move |_, _| Ok(Some(existing.clone())));
573
574 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
575
576 let request = CreateBudgetRequest {
577 organization_id: org_id,
578 building_id,
579 fiscal_year: 2025,
580 ordinary_budget: dec!(60000),
581 extraordinary_budget: dec!(15000),
582 notes: None,
583 };
584
585 let result = uc.create_budget(request).await;
586 assert!(result.is_err());
587 assert!(result
588 .unwrap_err()
589 .to_string()
590 .contains("Budget already exists"));
591 }
592
593 #[tokio::test]
597 async fn test_submit_for_approval_success() {
598 let acp_id = Uuid::new_v4();
599 let org_id = Uuid::new_v4();
600 let building_id = Uuid::new_v4();
601 let budget_id = Uuid::new_v4();
602
603 let mut budget_repo = MockBudgetRepo::new();
604 let building_repo = MockBuildingRepo::new();
605 let expense_repo = MockExpenseRepo::new();
606
607 let mut draft = make_draft_budget(acp_id, org_id, building_id);
608 draft.id = budget_id;
609
610 budget_repo
611 .expect_find_by_id()
612 .with(eq(budget_id))
613 .times(1)
614 .returning(move |_| Ok(Some(draft.clone())));
615
616 budget_repo
617 .expect_update()
618 .times(1)
619 .returning(|b| Ok(b.clone()));
620
621 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
622
623 let result = uc.submit_for_approval(budget_id).await;
624 assert!(result.is_ok());
625 let resp = result.unwrap();
626 assert_eq!(resp.status, BudgetStatus::Submitted);
627 assert!(resp.submitted_date.is_some());
628 }
629
630 #[tokio::test]
634 async fn test_approve_budget_success() {
635 let acp_id = Uuid::new_v4();
636 let org_id = Uuid::new_v4();
637 let building_id = Uuid::new_v4();
638 let budget_id = Uuid::new_v4();
639 let meeting_id = Uuid::new_v4();
640
641 let mut budget_repo = MockBudgetRepo::new();
642 let building_repo = MockBuildingRepo::new();
643 let expense_repo = MockExpenseRepo::new();
644
645 let mut submitted = make_draft_budget(acp_id, org_id, building_id);
647 submitted.id = budget_id;
648 submitted.submit_for_approval().unwrap();
649
650 budget_repo
651 .expect_find_by_id()
652 .with(eq(budget_id))
653 .times(1)
654 .returning(move |_| Ok(Some(submitted.clone())));
655
656 budget_repo
657 .expect_update()
658 .times(1)
659 .returning(|b| Ok(b.clone()));
660
661 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
662
663 let result = uc.approve_budget(budget_id, meeting_id).await;
664 assert!(result.is_ok());
665 let resp = result.unwrap();
666 assert_eq!(resp.status, BudgetStatus::Approved);
667 assert!(resp.approved_date.is_some());
668 assert_eq!(resp.approved_by_meeting_id, Some(meeting_id));
669 assert!(resp.is_active);
670 }
671
672 #[tokio::test]
676 async fn test_reject_budget_with_reason() {
677 let acp_id = Uuid::new_v4();
678 let org_id = Uuid::new_v4();
679 let building_id = Uuid::new_v4();
680 let budget_id = Uuid::new_v4();
681
682 let mut budget_repo = MockBudgetRepo::new();
683 let building_repo = MockBuildingRepo::new();
684 let expense_repo = MockExpenseRepo::new();
685
686 let mut submitted = make_draft_budget(acp_id, org_id, building_id);
687 submitted.id = budget_id;
688 submitted.submit_for_approval().unwrap();
689
690 budget_repo
691 .expect_find_by_id()
692 .with(eq(budget_id))
693 .times(1)
694 .returning(move |_| Ok(Some(submitted.clone())));
695
696 budget_repo
697 .expect_update()
698 .times(1)
699 .returning(|b| Ok(b.clone()));
700
701 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
702
703 let reason = Some("Montant extraordinaire trop élevé".to_string());
704 let result = uc.reject_budget(budget_id, reason).await;
705 assert!(result.is_ok());
706 let resp = result.unwrap();
707 assert_eq!(resp.status, BudgetStatus::Rejected);
708 assert!(resp.notes.is_some());
709 assert!(resp
710 .notes
711 .unwrap()
712 .contains("REJECTED: Montant extraordinaire trop élevé"));
713 }
714
715 #[tokio::test]
719 async fn test_archive_budget_success() {
720 let acp_id = Uuid::new_v4();
721 let org_id = Uuid::new_v4();
722 let building_id = Uuid::new_v4();
723 let budget_id = Uuid::new_v4();
724 let meeting_id = Uuid::new_v4();
725
726 let mut budget_repo = MockBudgetRepo::new();
727 let building_repo = MockBuildingRepo::new();
728 let expense_repo = MockExpenseRepo::new();
729
730 let mut approved = make_draft_budget(acp_id, org_id, building_id);
732 approved.id = budget_id;
733 approved.submit_for_approval().unwrap();
734 approved.approve(meeting_id).unwrap();
735
736 budget_repo
737 .expect_find_by_id()
738 .with(eq(budget_id))
739 .times(1)
740 .returning(move |_| Ok(Some(approved.clone())));
741
742 budget_repo
743 .expect_update()
744 .times(1)
745 .returning(|b| Ok(b.clone()));
746
747 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
748
749 let result = uc.archive_budget(budget_id).await;
750 assert!(result.is_ok());
751 let resp = result.unwrap();
752 assert_eq!(resp.status, BudgetStatus::Archived);
753 assert!(!resp.is_active);
754 assert!(!resp.is_editable);
755 }
756
757 #[tokio::test]
761 async fn test_get_variance_returns_analysis() {
762 let budget_id = Uuid::new_v4();
763 let building_id = Uuid::new_v4();
764
765 let mut budget_repo = MockBudgetRepo::new();
766 let building_repo = MockBuildingRepo::new();
767 let expense_repo = MockExpenseRepo::new();
768
769 let variance = BudgetVarianceResponse {
770 budget_id,
771 fiscal_year: 2025,
772 building_id,
773 budgeted_ordinary: dec!(60000),
774 budgeted_extraordinary: dec!(15000),
775 budgeted_total: dec!(75000),
776 actual_ordinary: dec!(45000),
777 actual_extraordinary: dec!(20000),
778 actual_total: dec!(65000),
779 variance_ordinary: dec!(15000),
780 variance_extraordinary: dec!(-5000),
781 variance_total: dec!(10000),
782 variance_ordinary_pct: dec!(25),
783 variance_extraordinary_pct: dec!(-33.33),
784 variance_total_pct: dec!(13.33),
785 has_overruns: true,
786 overrun_categories: vec!["Extraordinary".to_string()],
787 months_elapsed: 8,
788 projected_year_end_total: dec!(97500),
789 };
790
791 let expected_variance = variance.clone();
792
793 budget_repo
794 .expect_get_variance()
795 .with(eq(budget_id))
796 .times(1)
797 .returning(move |_| Ok(Some(variance.clone())));
798
799 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
800
801 let result = uc.get_variance(budget_id).await;
802 assert!(result.is_ok());
803 let opt = result.unwrap();
804 assert!(opt.is_some());
805 let v = opt.unwrap();
806 assert_eq!(v.budget_id, expected_variance.budget_id);
807 assert_eq!(v.budgeted_total, dec!(75000));
808 assert_eq!(v.actual_total, dec!(65000));
809 assert_eq!(v.variance_total, dec!(10000));
810 assert!(v.has_overruns);
811 assert_eq!(v.overrun_categories, vec!["Extraordinary".to_string()]);
812 }
813
814 #[tokio::test]
818 async fn test_get_active_budget_for_building() {
819 let acp_id = Uuid::new_v4();
820 let org_id = Uuid::new_v4();
821 let building_id = Uuid::new_v4();
822 let meeting_id = Uuid::new_v4();
823
824 let mut budget_repo = MockBudgetRepo::new();
825 let building_repo = MockBuildingRepo::new();
826 let expense_repo = MockExpenseRepo::new();
827
828 let mut approved = make_draft_budget(acp_id, org_id, building_id);
830 approved.submit_for_approval().unwrap();
831 approved.approve(meeting_id).unwrap();
832
833 let expected_id = approved.id;
834
835 budget_repo
836 .expect_find_active_by_building()
837 .with(eq(building_id))
838 .times(1)
839 .returning(move |_| Ok(Some(approved.clone())));
840
841 let uc = make_use_cases(budget_repo, building_repo, expense_repo);
842
843 let result = uc.get_active_budget(building_id).await;
844 assert!(result.is_ok());
845 let opt = result.unwrap();
846 assert!(opt.is_some());
847 let resp = opt.unwrap();
848 assert_eq!(resp.id, expected_id);
849 assert_eq!(resp.status, BudgetStatus::Approved);
850 assert!(resp.is_active);
851 assert_eq!(resp.building_id, building_id);
852 }
853}